11 KiB
11 KiB
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
{
"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
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:
// 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
curl -X POST http://localhost:5000/api/command/createUser \
-H "Content-Type: application/json" \
-d '{"name": "", "email": "john@example.com"}'
Response:
{
"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
curl -X POST http://localhost:5000/api/command/createUser \
-H "Content-Type: application/json" \
-d '{"name": "", "email": "invalid", "age": 16}'
Response:
{
"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
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:
{
"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
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:
{
"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
RuleFor(x => x.Name)
.MaximumLength(100)
.WithMessage("Name must not exceed {MaxLength} characters. You entered {TotalLength} characters.");
Response:
{
"errors": {
"Name": ["Name must not exceed 100 characters. You entered 125 characters."]
}
}
Client-Side Handling
JavaScript/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
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
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:
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
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