You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

Building Robust API Endpoints

Create well-structured endpoints with proper routing, validation, and response handling using Minimal APIs patterns

Building Robust API Endpoints

Master the art of creating reliable API endpoints with free flashcards and proven design patterns. This lesson covers input validation, error handling strategies, model binding techniques, and response formattingβ€”essential skills for building production-ready ASP.NET applications with .NET 10.

Welcome to API Endpoint Development πŸ’»

API endpoints are the backbone of modern web applications, serving as the bridge between client applications and your server-side logic. In ASP.NET with .NET 10, building robust API endpoints means creating interfaces that gracefully handle errors, validate inputs rigorously, and respond consistentlyβ€”even when things go wrong.

Think of your API endpoints like the front desk at a hospital πŸ₯: they need to verify patient information (validation), handle emergencies appropriately (error handling), direct people to the right departments (routing), and communicate clearly in a language everyone understands (standardized responses). A poorly designed endpoint is like a chaotic reception areaβ€”it might work sometimes, but it creates confusion and frustration when edge cases arise.

Core Concepts: The Foundation of Robust APIs πŸ”§

1. Model Binding and Validation πŸ“‹

Model binding is the automatic process by which ASP.NET maps incoming HTTP request data to your C# objects. Understanding this mechanism is crucial for building robust endpoints.

Binding SourceAttributeExample
Route parameter[FromRoute]/api/users/{id}
Query string[FromQuery]/api/users?page=1
Request body[FromBody]JSON payload
HTTP header[FromHeader]Authorization token
Form data[FromForm]File uploads

Data Annotations provide declarative validation rules directly on your model properties:

public class CreateUserRequest
{
    [Required(ErrorMessage = "Username is required")]
    [StringLength(50, MinimumLength = 3)]
    public string Username { get; set; }

    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
    public int Age { get; set; }

    [RegularExpression(@"^(?=.*[A-Z])(?=.*\d).{8,}$")]
    public string Password { get; set; }
}

πŸ’‘ Tip: ASP.NET automatically validates models before your controller action executes. Check ModelState.IsValid to determine if validation passed.

2. Problem Details: Standardized Error Responses 🚨

.NET 10 embraces RFC 7807 Problem Details as the standard format for HTTP API error responses. This provides a consistent structure that clients can reliably parse:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["The Email field is not a valid e-mail address."]
  },
  "traceId": "00-abc123-def456-00"
}
ERROR RESPONSE FLOW

  Client Request β†’ Validation Fails
        β”‚                β”‚
        β–Ό                β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   ModelState.IsValid?       β”‚
  β”‚         false               β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚
             β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Generate ProblemDetails    β”‚
  β”‚  - Status: 400              β”‚
  β”‚  - Title: "Validation..."   β”‚
  β”‚  - Errors: {...}            β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚
             β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Return BadRequest(problem) β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚
             β–Ό
      Client receives
      structured error

3. Result Types and ActionResult 🎯

ASP.NET provides typed result objects that combine status codes with strongly-typed data:

[HttpGet("{id}")]
public ActionResult<UserDto> GetUser(int id)
{
    var user = _userService.GetById(id);
    
    if (user == null)
        return NotFound(new ProblemDetails 
        { 
            Status = 404,
            Title = "User not found",
            Detail = $"No user exists with ID {id}"
        });
    
    return Ok(user); // 200 OK with UserDto
}

Common result methods:

  • Ok(data) β†’ 200 with response body
  • Created(uri, data) β†’ 201 with Location header
  • NoContent() β†’ 204 (successful, no body)
  • BadRequest(problem) β†’ 400 with validation errors
  • NotFound() β†’ 404
  • Conflict() β†’ 409 (business rule violation)
  • UnprocessableEntity() β†’ 422 (semantic errors)

🧠 Memory Device - HTTP Status Families:

  • 2xx = Success ("Too good to be true")
  • 3xx = Redirection ("Three's a crowd, go elsewhere")
  • 4xx = Client Error ("Four-letter words from client")
  • 5xx = Server Error ("Five-alarm fire on server")

4. Filters and Middleware for Cross-Cutting Concerns πŸ”„

Action Filters execute code before/after controller actions, perfect for validation, logging, and error handling:

public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            var errors = context.ModelState
                .Where(e => e.Value.Errors.Count > 0)
                .ToDictionary(
                    e => e.Key,
                    e => e.Value.Errors.Select(x => x.ErrorMessage).ToArray()
                );

            context.Result = new BadRequestObjectResult(new ValidationProblemDetails(errors));
        }
    }
}

Apply globally or per-controller:

[ApiController]
[ValidateModel]  // Applied to all actions in this controller
public class UsersController : ControllerBase
{
    // Actions here...
}

5. Exception Handling Middleware πŸ›‘οΈ

Global exception handling ensures no unhandled exception reaches the client as a cryptic 500 error:

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An unhandled exception occurred");
            await HandleExceptionAsync(context, ex);
        }
    }

    private static Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred while processing your request",
            Type = "https://httpstatuses.com/500",
            Instance = context.Request.Path
        };

        // Don't expose internal details in production
        if (IsDevelopment())
            problemDetails.Detail = exception.ToString();

        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;

        return context.Response.WriteAsJsonAsync(problemDetails);
    }
}

Register in Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapControllers();
app.Run();

6. Content Negotiation and Response Formatting πŸ“€

ASP.NET supports content negotiation through the Accept header, allowing clients to request different formats:

[HttpGet]
[Produces("application/json", "application/xml")]
public ActionResult<List<Product>> GetProducts()
{
    var products = _productService.GetAll();
    return Ok(products); // Will be JSON or XML based on Accept header
}
Accept HeaderResponse Format
application/jsonJSON (default)
application/xmlXML
text/plainPlain text
*/*Default format

Practical Examples πŸ”

Example 1: Comprehensive CRUD Endpoint with Validation

Here's a complete create endpoint showcasing multiple robustness patterns:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(IProductService productService, ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
    public async Task<ActionResult<ProductDto>> CreateProduct([FromBody] CreateProductRequest request)
    {
        // Automatic model validation via [ApiController]
        if (!ModelState.IsValid)
            return BadRequest(new ValidationProblemDetails(ModelState));

        // Business rule validation
        if (await _productService.ExistsBySkuAsync(request.Sku))
        {
            return Conflict(new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "Product already exists",
                Detail = $"A product with SKU '{request.Sku}' already exists"
            });
        }

        try
        {
            var product = await _productService.CreateAsync(request);
            _logger.LogInformation("Product created with ID {ProductId}", product.Id);
            
            // Return 201 Created with Location header
            return CreatedAtAction(
                nameof(GetProduct),
                new { id = product.Id },
                product
            );
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating product");
            throw; // Let middleware handle it
        }
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<ProductDto>> GetProduct(int id)
    {
        var product = await _productService.GetByIdAsync(id);
        return product == null ? NotFound() : Ok(product);
    }
}

public class CreateProductRequest
{
    [Required]
    [StringLength(100, MinimumLength = 3)]
    public string Name { get; set; }

    [Required]
    [RegularExpression(@"^[A-Z]{3}-\d{6}$", ErrorMessage = "SKU must be in format XXX-123456")]
    public string Sku { get; set; }

    [Range(0.01, 999999.99)]
    public decimal Price { get; set; }

    [Range(0, int.MaxValue)]
    public int StockQuantity { get; set; }
}

Why this is robust:

  • βœ… Automatic model validation with clear error messages
  • βœ… Business rule checking (duplicate SKU)
  • βœ… Proper HTTP status codes (201, 400, 409)
  • βœ… Structured error responses (ProblemDetails)
  • βœ… Logging for observability
  • βœ… Type-safe responses with documentation

Example 2: Query Parameter Validation and Pagination

[HttpGet]
public ActionResult<PagedResult<ProductDto>> GetProducts(
    [FromQuery] ProductQueryParameters parameters)
{
    // Manual validation for complex query logic
    if (parameters.PageSize > 100)
    {
        ModelState.AddModelError(nameof(parameters.PageSize), 
            "Page size cannot exceed 100");
        return BadRequest(new ValidationProblemDetails(ModelState));
    }

    if (parameters.MinPrice > parameters.MaxPrice)
    {
        return BadRequest(new ProblemDetails
        {
            Status = 400,
            Title = "Invalid price range",
            Detail = "MinPrice cannot be greater than MaxPrice"
        });
    }

    var result = _productService.GetProducts(parameters);
    
    // Add pagination metadata to headers
    Response.Headers.Append("X-Total-Count", result.TotalCount.ToString());
    Response.Headers.Append("X-Page-Number", parameters.PageNumber.ToString());
    Response.Headers.Append("X-Page-Size", parameters.PageSize.ToString());

    return Ok(result);
}

public class ProductQueryParameters
{
    [Range(1, int.MaxValue)]
    public int PageNumber { get; set; } = 1;

    [Range(1, 100)]
    public int PageSize { get; set; } = 20;

    [StringLength(100)]
    public string? SearchTerm { get; set; }

    [Range(0, double.MaxValue)]
    public decimal? MinPrice { get; set; }

    [Range(0, double.MaxValue)]
    public decimal? MaxPrice { get; set; }
}

Example 3: Custom Validation Attribute

For complex validation logic, create reusable custom attributes:

public class FutureDateAttribute : ValidationAttribute
{
    private readonly int _daysInFuture;

    public FutureDateAttribute(int daysInFuture = 0)
    {
        _daysInFuture = daysInFuture;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        if (value is DateTime dateTime)
        {
            var minDate = DateTime.UtcNow.AddDays(_daysInFuture);
            
            if (dateTime < minDate)
            {
                return new ValidationResult(
                    $"Date must be at least {_daysInFuture} days in the future");
            }
        }

        return ValidationResult.Success;
    }
}

// Usage:
public class CreateEventRequest
{
    [Required]
    public string Title { get; set; }

    [FutureDate(7)] // Must be at least 7 days in future
    public DateTime StartDate { get; set; }
}

Example 4: Result Pattern for Complex Operations

Implement a Result pattern to distinguish between different failure scenarios:

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string ErrorMessage { get; }
    public string ErrorType { get; }

    private Result(bool isSuccess, T? value, string errorMessage, string errorType)
    {
        IsSuccess = isSuccess;
        Value = value;
        ErrorMessage = errorMessage;
        ErrorType = errorType;
    }

    public static Result<T> Success(T value) => 
        new Result<T>(true, value, string.Empty, string.Empty);

    public static Result<T> Failure(string message, string type = "ValidationError") => 
        new Result<T>(false, default, message, type);
}

[HttpPost("{id}/activate")]
public async Task<IActionResult> ActivateProduct(int id)
{
    var result = await _productService.ActivateAsync(id);

    if (!result.IsSuccess)
    {
        return result.ErrorType switch
        {
            "NotFound" => NotFound(new ProblemDetails
            {
                Status = 404,
                Title = "Product not found",
                Detail = result.ErrorMessage
            }),
            "BusinessRuleViolation" => Conflict(new ProblemDetails
            {
                Status = 409,
                Title = "Cannot activate product",
                Detail = result.ErrorMessage
            }),
            _ => BadRequest(new ProblemDetails
            {
                Status = 400,
                Title = "Activation failed",
                Detail = result.ErrorMessage
            })
        };
    }

    return Ok(result.Value);
}

πŸ€” Did you know? The Result pattern originated in functional programming languages and has gained popularity in C# as an alternative to throwing exceptions for expected failure cases. It makes error handling explicit and testable!

Common Mistakes ⚠️

Mistake 1: Not Validating Query Parameters

❌ Wrong:

[HttpGet]
public IActionResult GetUsers(int page, int pageSize)
{
    // Negative page or pageSize of 1000000? Server overload!
    var users = _db.Users.Skip((page - 1) * pageSize).Take(pageSize);
    return Ok(users);
}

βœ… Correct:

[HttpGet]
public IActionResult GetUsers([FromQuery][Range(1, int.MaxValue)] int page = 1,
                               [FromQuery][Range(1, 100)] int pageSize = 20)
{
    var users = _db.Users.Skip((page - 1) * pageSize).Take(pageSize);
    return Ok(users);
}

Mistake 2: Returning Generic 500 Errors Without Logging

❌ Wrong:

try
{
    var result = await _service.DoSomethingAsync();
    return Ok(result);
}
catch
{
    return StatusCode(500);
}

βœ… Correct:

try
{
    var result = await _service.DoSomethingAsync();
    return Ok(result);
}
catch (Exception ex)
{
    _logger.LogError(ex, "Failed to process request for user {UserId}", userId);
    
    return Problem(
        title: "An error occurred processing your request",
        statusCode: 500,
        detail: _env.IsDevelopment() ? ex.Message : null
    );
}

Mistake 3: Not Using ProducesResponseType Attributes

❌ Wrong:

[HttpGet("{id}")]
public ActionResult<UserDto> GetUser(int id)
{
    // No documentation for Swagger/OpenAPI
    return Ok(user);
}

βœ… Correct:

[HttpGet("{id}")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<UserDto> GetUser(int id)
{
    // Now API consumers know what to expect
    return Ok(user);
}

Mistake 4: Not Sanitizing Input for Injection Attacks

❌ Wrong:

[HttpGet("search")]
public IActionResult Search(string query)
{
    // SQL Injection vulnerability!
    var sql = $"SELECT * FROM Products WHERE Name LIKE '%{query}%'";
    var results = _db.Database.SqlQueryRaw<Product>(sql);
    return Ok(results);
}

βœ… Correct:

[HttpGet("search")]
public IActionResult Search([FromQuery][StringLength(100)] string query)
{
    // Use parameterized queries or LINQ
    var results = _db.Products
        .Where(p => EF.Functions.Like(p.Name, $"%{query}%"))
        .ToList();
    return Ok(results);
}

Mistake 5: Inconsistent Error Response Formats

❌ Wrong:

if (user == null)
    return NotFound("User not found"); // Plain string

if (!ModelState.IsValid)
    return BadRequest(ModelState); // ModelState object

if (existingUser != null)
    return Conflict(new { error = "Duplicate" }); // Anonymous object

βœ… Correct:

if (user == null)
    return NotFound(new ProblemDetails { Title = "User not found" });

if (!ModelState.IsValid)
    return BadRequest(new ValidationProblemDetails(ModelState));

if (existingUser != null)
    return Conflict(new ProblemDetails { Title = "User already exists" });

Key Takeaways 🎯

  1. Always validate inputs at both the data annotation level and business logic level
  2. Use ProblemDetails for consistent, structured error responses across your API
  3. Return appropriate HTTP status codes (200, 201, 400, 404, 409, 422, 500) that accurately reflect the outcome
  4. Leverage ActionResult for type-safe responses with multiple possible status codes
  5. Implement global exception handling middleware to catch and format unexpected errors
  6. Document your endpoints with ProducesResponseType attributes for better API documentation
  7. Log errors thoroughly for debugging and monitoring in production
  8. Validate query parameters to prevent resource exhaustion and invalid operations
  9. Use custom validation attributes for complex, reusable validation logic
  10. Never expose sensitive error details in production responses

πŸ’‘ Final Tip: Test your error handling! Write unit tests that simulate validation failures, not-found scenarios, and exceptions to ensure your endpoints handle edge cases gracefully.

πŸ“š Further Study

  1. Microsoft Official Documentation - Handle errors in ASP.NET Core web APIs: https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors
  2. RFC 7807 - Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc7807
  3. ASP.NET Core Model Validation: https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation

πŸ“‹ Quick Reference Card

Binding Source[FromRoute], [FromQuery], [FromBody], [FromHeader]
Validation[Required], [Range], [StringLength], [EmailAddress], [RegularExpression]
Result MethodsOk(), Created(), BadRequest(), NotFound(), Conflict()
Status Codes200 OK, 201 Created, 400 Bad Request, 404 Not Found, 409 Conflict, 422 Unprocessable, 500 Server Error
Error FormatProblemDetails for general errors, ValidationProblemDetails for validation
Documentation[ProducesResponseType(typeof(T), statusCode)]
Model StateModelState.IsValid checks all validation attributes

Practice Questions

Test your understanding with these questions:

Q1: Complete the model validation attribute: ```csharp [{{1}}] [EmailAddress] public string Email { get; set; } ```
A: Required
Q2: What HTTP status code should this endpoint return? ```csharp [HttpPost] public IActionResult CreateUser(UserDto user) { if (_userService.ExistsByEmail(user.Email)) return {{1}}(new ProblemDetails { ... }); // ... } ```
A: Conflict
Q3: Which binding attribute reads data from the request body? A. [FromRoute] B. [FromQuery] C. [FromBody] D. [FromHeader] E. [FromForm]
A: C
Q4: Fill in the validation check: ```csharp [HttpPost] public IActionResult Create([FromBody] CreateRequest request) { if (!ModelState.{{1}}) return BadRequest(new ValidationProblemDetails(ModelState)); // ... } ```
A: IsValid
Q5: What does this code return when validation fails? ```csharp [ApiController] [Route("api/users")] public class UsersController : ControllerBase { [HttpPost] public IActionResult Create([FromBody] UserRequest request) { var user = _service.Create(request); return Ok(user); } } public class UserRequest { [Required] public string Name { get; set; } } ``` A. 200 OK with null B. 500 Internal Server Error C. 400 Bad Request with ValidationProblemDetails D. 422 Unprocessable Entity E. Nothing, throws exception
A: C