Building RESTful APIs

Learning Objectives:

  • Understand best practices for designing RESTful APIs, including resource modeling and endpoint conventions.
  • Learn how to use JSON serialization with System.Text.Json for data conversion.
  • Understand how to validate requests using data annotations and fluent validation in ASP.NET Core.

RESTful Design

What is REST? REST (Representational State Transfer) is an architectural style for designing networked applications. RESTful APIs use HTTP methods (GET, POST, PUT, DELETE) to manage resources and are easy to use, scalable, and widely adopted for web services.

Key RESTful Concepts:

  1. Resource Modeling: Resources are the entities represented in the API, such as products, users, or orders. Resources are typically exposed via URLs.
  2. HTTP Methods:
    • GET: Retrieve data.
    • POST: Create a new resource.
    • PUT: Update an existing resource.
    • DELETE: Remove a resource.

Example of Resource URL:

  • /api/products: Represents a collection of products.
  • /api/products/{id}: Represents a single product identified by {id}.

Example: Creating RESTful Endpoints:

[ApiController]
[Route("api/products")]
public class ProductController : ControllerBase
{
    [HttpGet]
    public IActionResult GetProducts()
    {
        return Ok(new List<string> { "Product1", "Product2", "Product3" });
    }

    [HttpGet("{id}")]
    public IActionResult GetProductById(int id)
    {
        if (id <= 0)
        {
            return BadRequest("Invalid product ID.");
        }
        return Ok($"Product with ID: {id}");
    }

    [HttpPost]
    public IActionResult CreateProduct([FromBody] string product)
    {
        return CreatedAtAction(nameof(GetProductById), new { id = 4 }, product);
    }
}

Real-Life Example: In an e-commerce system, this API could be used to list products, get product details by ID, or add a new product.

Data Serialization

What is Data Serialization? Serialization is the process of converting an object into a format that can be easily transmitted or stored, such as JSON. Deserialization is the reverse—converting JSON back into an object.

In ASP.NET Core, System.Text.Json is used for JSON serialization, which is lightweight and high-performance compared to alternatives.

Serializing an Object to JSON:

using System.Text.Json;

var product = new { Id = 1, Name = "Laptop", Price = 999.99 };
string json = JsonSerializer.Serialize(product);
Console.WriteLine(json);

Output:

{"Id":1,"Name":"Laptop","Price":999.99}

Deserializing JSON to an Object:

var jsonString = "{\"Id\":1,\"Name\":\"Laptop\",\"Price\":999.99}";
var productObj = JsonSerializer.Deserialize<Product>(jsonString);
Console.WriteLine(productObj.Name); // Output: Laptop

Real-Life Example: In a user management system, JSON serialization is used to convert user objects to JSON format for sending as responses to clients.

Request Validation

Why Validate Requests? Validating incoming requests ensures that the data sent to the server is correct and meets certain criteria. This helps in preventing bad data from entering the system, thereby reducing runtime errors.

1. Data Annotations:

Data Annotations are attributes used on model properties to specify validation rules.

Example:

public class Product
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Name is required")]
    [StringLength(50, ErrorMessage = "Name length can't be more than 50.")]
    public string Name { get; set; }

    [Range(0.01, 10000, ErrorMessage = "Price must be between 0.01 and 10000.")]
    public decimal Price { get; set; }
}

This ensures that a product has a Name and Price that meets certain conditions.

Data annotations are attributes applied directly to the properties of a model. They provide basic validation rules directly in the model class.

Advantages:

  • Simplicity: Data annotations are simple and easy to use.
  • Consistency: The validation rules are applied consistently across all consumers of the model, such as in controllers that use model binding.
  • Built-in Support: ASP.NET Core's model binding process automatically checks data annotations, which means you don’t need additional code to validate incoming data.

When to Use Data Annotations:

  • When you need basic, reusable validation directly tied to the model.
  • When you want the validation logic to be tightly coupled with the model definition itself.

2. Fluent Validation:

FluentValidation is an external library used for building validation rules fluently.

Example:

using FluentValidation;

public class ProductValidator : AbstractValidator<Product>
{
    public ProductValidator()
    {
        RuleFor(product => product.Name).NotEmpty().WithMessage("Name is required");
        RuleFor(product => product.Price).GreaterThan(0).WithMessage("Price must be greater than 0");
    }
}

FluentValidation is an external library that provides a more fluent, expressive, and flexible way to define validation logic.

Advantages:

  • Flexible and Powerful: FluentValidation allows for more complex and conditional rules compared to data annotations.
  • Separation of Concerns: By moving validation to a separate class, you keep the model clean and business logic centralized. This aligns with the single responsibility principle.
  • Custom Rules: FluentValidation allows defining complex rules that depend on multiple properties or custom logic, which cannot be easily expressed with data annotations.

When to Use FluentValidation:

  • When you need more advanced and complex validation rules.
  • When you want to keep validation logic separate from the model definition.
  • When you need to centralize business rules and apply different validation rules under different scenarios (e.g., when creating vs. updating).

Using Validation in Controllers:

[HttpPost]
public IActionResult CreateProduct([FromBody] Product product)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }
    return CreatedAtAction(nameof(GetProductById), new { id = product.Id }, product);
}

Real-Life Example: In a financial application, validation ensures that transaction amounts are positive and required fields are present, preventing invalid transactions.

Examples

Simple Example: GET Request

Endpoint to get a list of products.

Code Snippet:

[HttpGet]
public IActionResult GetProducts()
{
    return Ok(new List<string> { "Product1", "Product2" });
}

Real-Life Example: Used in an e-commerce API to list all available products.

Simple Example: POST Request with Data Annotations

Use data annotations to validate a product model.

Code Snippet:

[HttpPost]
public IActionResult CreateProduct([FromBody] Product product)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }
    return CreatedAtAction(nameof(GetProductById), new { id = product.Id }, product);
}

Real-Life Example: In a user management system, validate user details before saving to the database.

Simple Example: Serialization and Deserialization

Serialize a product object to JSON.

Code Snippet:

var product = new Product { Id = 1, Name = "Laptop", Price = 1000.0m };
string jsonString = JsonSerializer.Serialize(product);
Console.WriteLine(jsonString);

Real-Life Example: Convert a financial transaction object to JSON before sending it to a third-party payment gateway.

Key Takeaways

  • RESTful APIs are designed around resources, and each resource has well-defined endpoints.
  • Data serialization using System.Text.Json is essential for sending data over the network.
  • Request validation helps ensure data integrity, using either data annotations or fluent validation for more flexibility.

Practical Questions

  1. What are the main principles of RESTful API design, and why are they important?
  2. How would you use System.Text.Json to serialize and deserialize objects in ASP.NET Core?
  3. How can you validate user input before saving it to the database in an ASP.NET Core API?

New Concepts in .NET 8

New in .NET 8:

  • Native AOT Compilation: This feature allows for ahead-of-time (AOT) compilation, which helps improve performance by generating self-contained executables. For building APIs, this can mean faster startup times, which is beneficial for scaling in cloud environments.

Add a product to a database example

This example involves:

  1. Controller: Manages HTTP requests and returns appropriate responses.
  2. Service: Implements business logic, including validation.
  3. Repository: Handles direct interaction with the database.

Step-by-Step Full Example

Step 1: Create the Product Model

Define a Product model that represents the data structure.

public class Product
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Name is required")]
    [StringLength(50, ErrorMessage = "Name length can't be more than 50.")]
    public string Name { get; set; }

    [Range(0.01, 10000, ErrorMessage = "Price must be between 0.01 and 10000.")]
    public decimal Price { get; set; }
}

Step 2: Create the Repository Layer for Database Interaction

The repository will handle the direct interaction with the database using ADO.NET.

using System.Data.SqlClient;

public interface IProductRepository
{
    void AddProduct(Product product);
}

public class ProductRepository : IProductRepository
{
    private readonly string _connectionString;

    public ProductRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public void AddProduct(Product product)
    {
        using (SqlConnection connection = new SqlConnection(_connectionString))
        {
            try
            {
                connection.Open();
                using (SqlCommand command = new SqlCommand("AddNewProduct", connection))
                {
                    command.CommandType = System.Data.CommandType.StoredProcedure;
                    command.Parameters.AddWithValue("@Name", product.Name);
                    command.Parameters.AddWithValue("@Price", product.Price);
                    command.ExecuteNonQuery();
                }
            }
            catch (SqlException ex)
            {
                throw new Exception($"Database connection error: {ex.Message}");
            }
        }
    }
}

Stored Procedure: The AddNewProduct stored procedure should be defined in the SQL Server database.Error Handling: If there's an issue with the connection, an exception is thrown with an error message.

Step 3: Create the Service Layer for Business Logic

The service layer implements validation and manages the call to the repository.

using FluentValidation;

public interface IProductService
{
    void AddProduct(Product product);
}

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;

    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public void AddProduct(Product product)
    {
        // Validate the product
        var validator = new ProductValidator();
        var validationResult = validator.Validate(product);
        
        if (!validationResult.IsValid)
        {
            throw new ValidationException(validationResult.Errors);
        }

        // If valid, add the product
        _productRepository.AddProduct(product);
    }
}

// FluentValidation for Product
public class ProductValidator : AbstractValidator<Product>
{
    public ProductValidator()
    {
        RuleFor(product => product.Name).NotEmpty().WithMessage("Name is required");
        RuleFor(product => product.Price).GreaterThan(0).WithMessage("Price must be greater than 0");
    }
}
  • The service validates the product using FluentValidation.
  • If the product is valid, it calls the repository to add the product.
  • If validation fails, an exception is thrown with the list of errors.

Step 4: Create the Product Controller

The controller receives the HTTP request, manages input, and uses the service to add the product.

using Microsoft.AspNetCore.Mvc;
using FluentValidation;

[ApiController]
[Route("api/products")]
public class ProductController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpPost]
    public IActionResult AddProduct([FromBody] Product product)
    {
        try
        {
            _productService.AddProduct(product);
            return CreatedAtAction(nameof(AddProduct), new { id = product.Id }, product);
        }
        catch (ValidationException ex)
        {
            return BadRequest(new { Errors = ex.Errors.Select(e => e.ErrorMessage) });
        }
        catch (Exception ex)
        {
            return StatusCode(500, new { Error = ex.Message });
        }
    }
}
  • The controller handles incoming requests and calls the service.
  • If there are validation errors, they are returned with a 400 Bad Request.
  • If there is a database error (or any other issue), a 500 Internal Server Error is returned with the relevant message.

Step 5: Configure Dependency Injection in Program.cs

Register the services and repository in the DI container.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();

// Add connection string from appsettings.json or environment variable
string connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

// Register repository and service for dependency injection
builder.Services.AddScoped<IProductRepository>(provider => new ProductRepository(connectionString));
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseRouting();
app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});

app.Run();

Step 6: SQL Stored Procedure Example

Create a stored procedure in the SQL Server database to add a new product.

CREATE PROCEDURE AddNewProduct
    @Name NVARCHAR(50),
    @Price DECIMAL(18, 2)
AS
BEGIN
    INSERT INTO Products (Name, Price) VALUES (@Name, @Price);
END

Summary

This example demonstrates a complete end-to-end implementation of adding a product to the database using an ASP.NET Core Web API:

  1. Controller (ProductController):
    • Handles the incoming HTTP POST request and returns appropriate responses.
    • Uses FluentValidation to validate input.
  2. Service Layer (ProductService):
    • Handles business logic and validation using a validator.
    • Calls the repository to save data after validation.
  3. Repository Layer (ProductRepository):
    • Uses ADO.NET to interact with the SQL Server database.
    • Executes a stored procedure to add the product.

Key Features Implemented:

  • Validation: Using FluentValidation to validate the request data.
  • Error Handling:
    • ValidationException: Returns 400 Bad Request with details of validation errors.
    • General Exception: Returns 500 Internal Server Error if a database connection or other error occurs.
  • Layered Architecture: Follows separation of concerns by splitting responsibilities into controller, service, and repository layers.

ניווט במאמר

מאמרים אחרונים

Weekly Tutorial