Beetje Coderen

Les 5 · Module 1 · API-basis

Validatie en ProblemDetails

Valideer invoer van Winkel.Api met DataAnnotations of FluentValidation in een endpoint filter, en geef fouten terug als gestandaardiseerde ProblemDetails (RFC 9457).

⏱ 75–90 minuten leestijd + oefenen

Illustratie: een formulierdocument naast een groen schild met vinkje, invoer die gecontroleerd wordt

🎯 Na deze les kun je

  • invoer valideren met DataAnnotations en met FluentValidation
  • validatie in een herbruikbaar endpoint filter plaatsen
  • fouten teruggeven als ProblemDetails en ValidationProblem
  • onverwachte fouten omzetten naar een veilige 500 met ProblemDetails

Een client stuurt nu een product met naam "" en prijs -5, en de API slaat het op. Elke API moet invoer controleren, en fouten zo teruggeven dat een programma ze kan lezen. Daarvoor bestaat een standaard: ProblemDetails (RFC 9457), een vast JSON-formaat voor fouten dat ASP.NET Core ingebouwd heeft.

ProblemDetails

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Naam": ["Naam is verplicht."],
    "Prijs": ["Prijs moet tussen 0,01 en 10.000 liggen."]
  }
}

Met Content-Type: application/problem+json. Clients weten zo altijd waar ze de foutmeldingen vinden.

builder.Services.AddProblemDetails();

Optie 1: DataAnnotations

public record MaakProductRequest(
    [property: Required, StringLength(60, MinimumLength = 2)] string Naam,
    [property: Range(0.01, 10_000)] decimal Prijs,
    [property: Range(0, 10_000)] decimal Inkoopprijs,
    [property: Range(0, 100_000)] int Voorraad);

Controllers met [ApiController] valideren dit automatisch. Minimal API's in .NET 8 niet: je roept de validator zelf aan, het liefst in een endpoint filter zodat elke handler het krijgt.

Optie 2: FluentValidation (deze cursus)

dotnet add package FluentValidation.DependencyInjectionExtensions
public class MaakProductValidator : AbstractValidator<MaakProductRequest>
{
    public MaakProductValidator()
    {
        RuleFor(x => x.Naam).NotEmpty().Length(2, 60);
        RuleFor(x => x.Prijs).InclusiveBetween(0.01m, 10_000m).PrecisionScale(7, 2, true);
        RuleFor(x => x.Inkoopprijs).GreaterThanOrEqualTo(0).LessThan(x => x.Prijs)
            .WithMessage("Inkoopprijs moet lager zijn dan de verkoopprijs.");
        RuleFor(x => x.Voorraad).InclusiveBetween(0, 100_000);
    }
}

Regels die velden vergelijken (inkoop < verkoop) zijn met FluentValidation duidelijker dan met attributen.

builder.Services.AddValidatorsFromAssemblyContaining<Program>();

Het filter

public class ValidatieFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var validator = ctx.HttpContext.RequestServices.GetRequiredService<IValidator<T>>();
        var model = ctx.Arguments.OfType<T>().Single();
        var resultaat = await validator.ValidateAsync(model);
        if (!resultaat.IsValid)
            return TypedResults.ValidationProblem(resultaat.ToDictionary());
        return await next(ctx);
    }
}

public static class ValidatieExtensies
{
    public static RouteHandlerBuilder Valideer<T>(this RouteHandlerBuilder b) => b.AddEndpointFilter<ValidatieFilter<T>>();
}
groep.MapPost("/", Maak).Valideer<MaakProductRequest>();

Voorspel eerst: een client stuurt {"naam": "Muis", "prijs": "gratis"}. Komt het verzoek bij je validator?

Uitkomst

Nee. "gratis" kan niet naar decimal: het mislukt al bij het binden van de JSON, vóór filters en validators. ASP.NET Core geeft dan een 400 (BadHttpRequestException). Zonder AddProblemDetails() is die body leeg; met AddProblemDetails() en de exception-handler hieronder krijgt de client ook hier een ProblemDetails. Er zijn dus twee soorten fouten: kapotte JSON en inhoudelijk ongeldig.

Fouten die geen validatie zijn

Een dubbele naam (409) of een product dat niet bestaat (404) geef je ook als ProblemDetails:

return TypedResults.Problem(
    title: "Productnaam bestaat al",
    detail: $"Er is al een product met de naam '{req.Naam}'.",
    statusCode: StatusCodes.Status409Conflict);

AddProblemDetails() zorgt ook dat een kale TypedResults.NotFound() een ProblemDetails-body krijgt.

Onverwachte fouten

app.UseExceptionHandler();      // gebruikt AddProblemDetails
app.UseStatusCodePages();

Een exception in je code geeft nu een 500 met een ProblemDetails zonder stacktrace. In Development kun je app.UseDeveloperExceptionPage() gebruiken. Wil je eigen exceptions vertalen (bijvoorbeeld VoorraadTeLaagException → 409), schrijf dan een IExceptionHandler:

public class DomeinFoutHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not VoorraadTeLaagException v) return false;
        ctx.Response.StatusCode = 409;
        await ctx.Response.WriteAsJsonAsync(new ProblemDetails { Status = 409, Title = "Te weinig voorraad", Detail = v.Message }, ct);
        return true;
    }
}
// builder.Services.AddExceptionHandler<DomeinFoutHandler>();

Typische fouten

  • Validator draait niet: niet geregistreerd (AddValidatorsFromAssemblyContaining) of filter niet gekoppeld.
  • InvalidOperationException: Sequence contains no elements in het filter: het endpoint heeft geen parameter van type T.
  • Stacktrace in productie: UseDeveloperExceptionPage staat buiten een IsDevelopment()-check.

Oefenen

  1. Voeg AddProblemDetails, UseExceptionHandler en UseStatusCodePages toe en bekijk de body van een 404.
  2. Schrijf MaakProductValidator en WijzigProductValidator en koppel ze met .Valideer<T>().
  3. Geef 409 als ProblemDetails bij een dubbele productnaam (hoofdletterongevoelig).
  4. Maak VoorraadTeLaagException en een IExceptionHandler die hem vertaalt naar 409.
Modelantwoord oefening 1

GET /v1/producten/999 geeft {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404} met Content-Type: application/problem+json.

Modelantwoord oefening 2

POST met {"naam":"","prijs":0,"inkoopprijs":5,"voorraad":-1} geeft 400 met vier velden in errors. De handler Maak wordt niet aangeroepen: zet er een breakpoint in om het te zien.

Modelantwoord oefening 3
if (opslag.Alle().Any(p => p.Naam.Equals(req.Naam.Trim(), StringComparison.OrdinalIgnoreCase)))
    return TypedResults.Problem(title: "Productnaam bestaat al", statusCode: 409);

Het returntype wordt Results<CreatedAtRoute<ProductResponse>, ProblemHttpResult>. In les 10 dwingt een unieke index in de database dit af.

Modelantwoord oefening 4

Zie DomeinFoutHandler hierboven. Registreer hem vóór AddProblemDetails() en zorg dat app.UseExceptionHandler() in de pipeline staat.

Lokaal oefenen

Geen live lab. Maak in je .http-bestand een blok "foute verzoeken": lege velden, kapotte JSON, verkeerde typen, dubbele namen.

Samenvatting

  • Fouten als ProblemDetails (application/problem+json); validatiefouten als ValidationProblem met errors.
  • FluentValidation in een endpoint filter houdt handlers schoon.
  • Binding-fouten komen vóór validatie; onverwachte fouten worden een veilige 500.
  • Volgende les: OpenAPI en het testen van je API.

📝 Quiz: Validatie en ProblemDetails

5 vragen · 70% nodig om te slagen

1. Wat is ProblemDetails?
2. Valideren minimal API's in .NET 8 DataAnnotations automatisch?
3. Een client stuurt prijs: "gratis" voor een decimal. Wat gebeurt er?
4. Welke code voor een dubbele productnaam?
5. Wat doet app.UseExceptionHandler() met AddProblemDetails()?

Dit was de laatste gratis les

Je hebt les 1–5 van ASP.NET Core Web API en EF Core gratis gevolgd. Wil je verder? Met één aankoop (€ 49,00, eenmalig) krijg je de overige 15 lessen, de live labs en het certificaat. Geen abonnement.

Cursus kopen en verdergaan