439 lines
11 KiB
Markdown
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)
|