Beetje Coderen

Les 4 · Module 1 · API-basis

DTO's en model binding

Scheid wat de API naar buiten laat zien van je domeinmodel: request- en response-DTO's, mapping, model binding uit route, query, body en headers, en overposting voorkomen.

⏱ 75 minuten leestijd + oefenen

Illustratie: documenten met pijlen tussen een browser en een server, gegevens in een vaste vorm uitwisselen

đŸŽ¯ Na deze les kun je

  • uitleggen waarom je DTO's gebruikt in plaats van domeinobjecten
  • request- en response-DTO's ontwerpen voor Winkel.Api
  • parameters binden uit route, query, body, header en services
  • overposting herkennen en voorkomen

Tot nu toe ontvangt en verstuurt de API Product zelf. Dat lijkt handig, maar zodra het domein groeit (een interne inkoopprijs, een voorraadteller, relaties) lekt alles naar buiten en kan een client velden zetten die hij niet hoort te zetten. DTO's (data transfer objects) zijn aparte klassen die precies beschrijven wat er over de lijn gaat.

Het domein groeit

// Domein/Product.cs
public class Product
{
    public int Id { get; set; }
    public required string Naam { get; set; }
    public decimal Prijs { get; set; }
    public decimal Inkoopprijs { get; set; }       // geheim voor klanten
    public int Voorraad { get; set; }
    public bool Actief { get; set; } = true;
    public DateTime AangemaaktOp { get; set; }
}

DTO's

// Contracten/ProductContracten.cs
public record ProductResponse(int Id, string Naam, decimal Prijs, bool OpVoorraad);
public record MaakProductRequest(string Naam, decimal Prijs, decimal Inkoopprijs, int Voorraad);
public record WijzigProductRequest(string Naam, decimal Prijs, bool Actief);

public static class ProductMapping
{
    public static ProductResponse NaarResponse(this Product p) => new(p.Id, p.Naam, p.Prijs, p.Voorraad > 0);

    public static Product NaarDomein(this MaakProductRequest r) => new()
    {
        Naam = r.Naam.Trim(), Prijs = r.Prijs, Inkoopprijs = r.Inkoopprijs, Voorraad = r.Voorraad,
        AangemaaktOp = DateTime.UtcNow,
    };
}
static CreatedAtRoute<ProductResponse> Maak(MaakProductRequest req, ProductOpslag opslag)
{
    var product = opslag.Voeg(req.NaarDomein());        // opslag kiest het id
    return TypedResults.CreatedAtRoute(product.NaarResponse(), "ProductOphalen", new { id = product.Id });
}
  • De client stuurt geen Id en geen AangemaaktOp meer: die bepaalt de server.
  • ProductResponse toont OpVoorraad (ja/nee) in plaats van het exacte aantal en nooit de inkoopprijs.
  • Mapping met de hand in extensiemethodes is duidelijk en snel. Bibliotheken zoals Mapster of AutoMapper kunnen het ook, maar verbergen fouten (een vergeten veld) achter conventies.

Voorspel eerst: zonder DTO stuurt een client {"naam":"Muis","prijs":1,"inkoopprijs":0,"actief":true,"id":1} naar PUT /v1/producten/1. Wat kan er misgaan?

Uitkomst

Alles wat in Product staat, kan de client overschrijven: de inkoopprijs, de voorraad, zelfs het id (en daarmee misschien een ander product). Dat heet overposting (of mass assignment). Met WijzigProductRequest kan hij alleen naam, prijs en actief wijzigen; andere velden in de JSON worden genegeerd.

Model binding: waar komt een parameter vandaan?

Bron Standaard voor Expliciet
route parameter met dezelfde naam als {id} [FromRoute]
query eenvoudige types die niet in de route staan [FromQuery]
body ÊÊn complex type (JSON) [FromBody]
header – [FromHeader(Name = "X-Kanaal")]
services geregistreerde types [FromServices] (meestal automatisch)
formulier – [FromForm] (uploads)

Meerdere queryparameters bundelen:

public record ProductFilter(string? Zoek, decimal? MaxPrijs, int Pagina = 1);

groep.MapGet("/", ([AsParameters] ProductFilter filter, ProductOpslag opslag) => ...);

[AsParameters] leest elke eigenschap als losse parameter: ?zoek=muis&maxPrijs=30&pagina=2.

JSON-instellingen

builder.Services.ConfigureHttpJsonOptions(o =>
{
    o.SerializerOptions.Converters.Add(new JsonStringEnumConverter());     // enums als tekst
    o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});

Enums als tekst ("status": "Verzonden") zijn leesbaarder en breken minder snel dan getallen.

Typische fouten

  • Body is null of velden zijn leeg: de JSON-namen kloppen niet (product_naam vs naam) of Content-Type ontbreekt.
  • InvalidOperationException: Body was inferred but the method does not allow inferred body parameters: een complex type op een GET. Gebruik [AsParameters] of [FromQuery].
  • Inkoopprijs zichtbaar in de API: je gaf ergens nog het domeinobject terug. Zoek op TypedResults.Ok(p) met een Product.

Oefenen

  1. Breid Product uit zoals hierboven en voer de drie DTO's en de mapping in voor alle endpoints.
  2. Maak PUT /v1/producten/{id} met WijzigProductRequest en laat zien dat een meegestuurd inkoopprijs genegeerd wordt.
  3. Voeg ProductFilter toe met [AsParameters] en filter op zoekterm en maximumprijs.
  4. Lees een optionele header X-Kanaal (web of app) en log hem.
Modelantwoord oefening 1

Elk endpoint geeft nu ProductResponse of een lijst daarvan. Zoek ter controle: grep -rn "Ok<Product>" Endpoints/ geeft niets.

Modelantwoord oefening 2
static Results<Ok<ProductResponse>, NotFound> Wijzig(int id, WijzigProductRequest req, ProductOpslag opslag)
{
    if (opslag.Zoek(id) is not { } p) return TypedResults.NotFound();
    p.Naam = req.Naam.Trim(); p.Prijs = req.Prijs; p.Actief = req.Actief;
    return TypedResults.Ok(p.NaarResponse());
}

System.Text.Json negeert onbekende velden standaard, dus inkoopprijs in de body heeft geen effect.

Modelantwoord oefening 3
var q = opslag.Alle().AsEnumerable();
if (!string.IsNullOrWhiteSpace(filter.Zoek)) q = q.Where(p => p.Naam.Contains(filter.Zoek, StringComparison.OrdinalIgnoreCase));
if (filter.MaxPrijs is { } max) q = q.Where(p => p.Prijs <= max);
return TypedResults.Ok(q.Select(p => p.NaarResponse()).ToList());
Modelantwoord oefening 4
groep.MapGet("/", ([AsParameters] ProductFilter f, [FromHeader(Name = "X-Kanaal")] string? kanaal, ILogger<Program> log, ProductOpslag opslag) =>
{
    log.LogInformation("Productlijst via {Kanaal}", kanaal ?? "onbekend");
    ...
});

Lokaal oefenen

Geen live lab. Stuur met je .http-bestand bewust extra en foute velden mee en kijk wat de API ermee doet.

Samenvatting

  • DTO's bepalen wat er over de lijn gaat; het domein blijft binnen.
  • Mapping in extensiemethodes is expliciet; overposting is met request-DTO's opgelost.
  • Model binding haalt parameters uit route, query, body, header of services; [AsParameters] bundelt.
  • Volgende les: validatie en ProblemDetails.

📝 Quiz: DTO's en model binding

5 vragen ¡ 70% nodig om te slagen

1. Waarom een aparte ProductResponse?
2. Wat is overposting?
3. Waar komt een decimal? maxPrijs-parameter vandaan als hij niet in de route staat?
4. Wat doet [AsParameters]?
5. Wie hoort het id van een nieuw product te kiezen?