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 Source | Attribute | Example |
|---|---|---|
| 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 bodyCreated(uri, data)β 201 with Location headerNoContent()β 204 (successful, no body)BadRequest(problem)β 400 with validation errorsNotFound()β 404Conflict()β 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 Header | Response Format |
|---|---|
application/json | JSON (default) |
application/xml | XML |
text/plain | Plain 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 π―
- Always validate inputs at both the data annotation level and business logic level
- Use ProblemDetails for consistent, structured error responses across your API
- Return appropriate HTTP status codes (200, 201, 400, 404, 409, 422, 500) that accurately reflect the outcome
- Leverage ActionResult
for type-safe responses with multiple possible status codes - Implement global exception handling middleware to catch and format unexpected errors
- Document your endpoints with ProducesResponseType attributes for better API documentation
- Log errors thoroughly for debugging and monitoring in production
- Validate query parameters to prevent resource exhaustion and invalid operations
- Use custom validation attributes for complex, reusable validation logic
- 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
- Microsoft Official Documentation - Handle errors in ASP.NET Core web APIs: https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors
- RFC 7807 - Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc7807
- 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 Methods | Ok(), Created(), BadRequest(), NotFound(), Conflict() |
| Status Codes | 200 OK, 201 Created, 400 Bad Request, 404 Not Found, 409 Conflict, 422 Unprocessable, 500 Server Error |
| Error Format | ProblemDetails for general errors, ValidationProblemDetails for validation |
| Documentation | [ProducesResponseType(typeof(T), statusCode)] |
| Model State | ModelState.IsValid checks all validation attributes |