dotnet-cqrs/docs/core-features/validation/http-validation.md

439 lines
11 KiB
Markdown

# HTTP Validation
HTTP validation with RFC 7807 Problem Details for structured error responses.
## Overview
When validation fails for HTTP endpoints, the framework returns structured error responses following **RFC 7807 Problem Details for HTTP APIs** standard. This provides:
-**Standardized format** - Consistent error structure across all endpoints
-**Machine-readable** - Clients can parse errors programmatically
-**Human-friendly** - Clear messages for debugging
-**Field-level errors** - Know exactly which fields failed validation
-**HTTP 400 status** - Standard Bad Request response
## RFC 7807 Problem Details
### Standard Format
```json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required"],
"Email": ["Valid email address is required"],
"Age": ["User must be at least 18 years old"]
}
}
```
### Response Headers
```
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
```
## ASP.NET Core Integration
### Enable Problem Details
```csharp
var builder = WebApplication.CreateBuilder(args);
// Enable Problem Details
builder.Services.AddProblemDetails();
var app = builder.Build();
// Use Problem Details middleware
app.UseExceptionHandler();
app.UseStatusCodePages();
app.Run();
```
### Automatic Validation
Validation happens automatically when using Minimal API endpoints:
```csharp
// Command with validator
public record CreateUserCommand
{
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
}
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Name is required");
RuleFor(x => x.Email)
.EmailAddress()
.WithMessage("Valid email address is required");
}
}
// Registration
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
// Endpoint automatically validates
app.MapSvrntyCommands();
```
### Validation Flow
```
POST /api/command/createUser
Body: { "name": "", "email": "invalid" }
┌──────────────────┐
│ Model Binding │
└──────┬───────────┘
┌──────────────────┐
│ Run Validator │
└──────┬───────────┘
├─ Valid ──────────▶ Execute Handler ──▶ 200 OK
└─ Invalid ────────▶ Return Problem Details ──▶ 400 Bad Request
```
## Example Validation Errors
### Single Field Error
```bash
curl -X POST http://localhost:5000/api/command/createUser \
-H "Content-Type: application/json" \
-d '{"name": "", "email": "john@example.com"}'
```
**Response:**
```json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required"]
}
}
```
### Multiple Field Errors
```bash
curl -X POST http://localhost:5000/api/command/createUser \
-H "Content-Type: application/json" \
-d '{"name": "", "email": "invalid", "age": 16}'
```
**Response:**
```json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required"],
"Email": ["Valid email address is required"],
"Age": ["User must be at least 18 years old"]
}
}
```
### Multiple Errors Per Field
```bash
curl -X POST http://localhost:5000/api/command/createUser \
-H "Content-Type: application/json" \
-d '{"name": "A very long name that exceeds the maximum allowed length of 100 characters and should trigger a validation error"}'
```
**Response:**
```json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"Name must not exceed 100 characters"
]
}
}
```
## Custom Error Messages
### Override Default Messages
```csharp
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Please provide a name")
.MaximumLength(100)
.WithMessage("Name is too long (max 100 characters)");
RuleFor(x => x.Email)
.EmailAddress()
.WithMessage("Please provide a valid email address");
}
}
```
**Response:**
```json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Please provide a name"],
"Email": ["Please provide a valid email address"]
}
}
```
### Message Placeholders
```csharp
RuleFor(x => x.Name)
.MaximumLength(100)
.WithMessage("Name must not exceed {MaxLength} characters. You entered {TotalLength} characters.");
```
**Response:**
```json
{
"errors": {
"Name": ["Name must not exceed 100 characters. You entered 125 characters."]
}
}
```
## Client-Side Handling
### JavaScript/TypeScript
```typescript
interface ProblemDetails {
type: string;
title: string;
status: number;
errors?: { [key: string]: string[] };
}
async function createUser(data: CreateUserCommand) {
const response = await fetch('/api/command/createUser', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.status === 400) {
const problem: ProblemDetails = await response.json();
// Display field-level errors
for (const [field, errors] of Object.entries(problem.errors || {})) {
console.error(`${field}: ${errors.join(', ')}`);
}
return null;
}
if (response.ok) {
return await response.json();
}
throw new Error('Unexpected error');
}
```
### C# HttpClient
```csharp
public class UserApiClient
{
private readonly HttpClient _httpClient;
public async Task<int?> CreateUserAsync(CreateUserCommand command)
{
var response = await _httpClient.PostAsJsonAsync("/api/command/createUser", command);
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
if (problem?.Errors != null)
{
foreach (var (field, errors) in problem.Errors)
{
Console.WriteLine($"{field}: {string.Join(", ", errors)}");
}
}
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<int>();
}
}
```
## Testing Validation
### Integration Tests
```csharp
public class CreateUserCommandTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public CreateUserCommandTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateUser_WithMissingName_Returns400()
{
var command = new { name = "", email = "john@example.com" };
var response = await _client.PostAsJsonAsync("/api/command/createUser", command);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.NotNull(problem);
Assert.True(problem.Errors.ContainsKey("Name"));
}
[Fact]
public async Task CreateUser_WithInvalidEmail_Returns400()
{
var command = new { name = "John", email = "invalid" };
var response = await _client.PostAsJsonAsync("/api/command/createUser", command);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.Contains("Email", problem.Errors.Keys);
}
[Fact]
public async Task CreateUser_WithValidData_Returns200()
{
var command = new { name = "John Doe", email = "john@example.com" };
var response = await _client.PostAsJsonAsync("/api/command/createUser", command);
response.EnsureSuccessStatusCode();
var userId = await response.Content.ReadFromJsonAsync<int>();
Assert.True(userId > 0);
}
}
```
## Swagger/OpenAPI Integration
Validation errors are automatically documented in Swagger:
```csharp
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
```
**Swagger shows:**
- 200 OK with response schema
- 400 Bad Request with ProblemDetails schema
- Field descriptions and constraints
## Custom Problem Details
### Add Additional Information
```csharp
public class CustomProblemDetailsFactory : ProblemDetailsFactory
{
public override ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
var problemDetails = base.CreateValidationProblemDetails(
httpContext, modelStateDictionary, statusCode, title, type, detail, instance);
// Add custom fields
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
problemDetails.Extensions["timestamp"] = DateTime.UtcNow;
return problemDetails;
}
}
// Register
builder.Services.AddSingleton<ProblemDetailsFactory, CustomProblemDetailsFactory>();
```
## Best Practices
### ✅ DO
- Use RFC 7807 Problem Details format
- Return HTTP 400 for validation errors
- Provide field-level error messages
- Use descriptive error messages
- Test validation scenarios
- Document validation rules in OpenAPI
- Handle validation errors gracefully in clients
### ❌ DON'T
- Don't return HTTP 500 for validation errors
- Don't use generic error messages
- Don't expose internal details
- Don't skip validation
- Don't return HTML error pages for API endpoints
- Don't throw exceptions for validation failures
## See Also
- [Validation Overview](README.md)
- [FluentValidation Setup](fluentvalidation-setup.md)
- [gRPC Validation](grpc-validation.md)
- [RFC 7807 Specification](https://tools.ietf.org/html/rfc7807)
- [ASP.NET Core Problem Details](https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors)