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

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

See Also