Beetje Coderen

Les 3 · Module 1 · API-basis

Minimal API's of controllers

Vergelijk minimal API's en controllers voor Winkel.Api: route-groepen en TypedResults, of ApiController met attribuutroutes, en een structuur die meegroeit.

โฑ 60–75 minuten leestijd + oefenen

Illustratie: twee bouwblokken naast een server, twee manieren om dezelfde API te bouwen

๐ŸŽฏ Na deze les kun je

  • dezelfde endpoints met minimal API's en met een controller schrijven
  • route-groepen en TypedResults gebruiken
  • endpoints in aparte bestanden met extensiemethodes organiseren
  • beargumenteren wanneer je welke stijl kiest

ASP.NET Core heeft twee manieren om een API te schrijven: minimal API's (app.MapGet(...), zoals in les 1) en controllers (klassen met [HttpGet]-attributen). Ze draaien op dezelfde basis en kunnen naast elkaar bestaan. In deze les schrijf je hetzelfde endpoint op beide manieren, en kies je een structuur voor de rest van de cursus.

Minimal API's, netjes georganiseerd

Program.cs met tachtig MapGet-regels is onleesbaar. Zet endpoints per resource in een eigen bestand:

// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProducten(this IEndpointRouteBuilder app)
    {
        var groep = app.MapGroup("/v1/producten").WithTags("Producten");
        groep.MapGet("/", Lijst);
        groep.MapGet("/{id:int}", EenProduct).WithName("ProductOphalen");
        groep.MapPost("/", Maak);
        return groep;
    }

    static Ok<List<Product>> Lijst(ProductOpslag opslag) => TypedResults.Ok(opslag.Alle());

    static Results<Ok<Product>, NotFound> EenProduct(int id, ProductOpslag opslag) =>
        opslag.Zoek(id) is { } p ? TypedResults.Ok(p) : TypedResults.NotFound();

    static CreatedAtRoute<Product> Maak(Product nieuw, ProductOpslag opslag)
    {
        opslag.Voeg(nieuw);
        return TypedResults.CreatedAtRoute(nieuw, "ProductOphalen", new { id = nieuw.Id });
    }
}
// Program.cs
builder.Services.AddSingleton<ProductOpslag>();
var app = builder.Build();
app.MapProducten();
app.Run();
  • TypedResults in plaats van Results: het returntype (Results<Ok<Product>, NotFound>) zegt precies welke antwoorden mogelijk zijn. OpenAPI (les 6) en unit-tests lezen dat.
  • MapGroup deelt het voorvoegsel, tags en later ook autorisatie en filters.
  • ProductOpslag is een eenvoudige klasse die de lijst beheert; de container (les 7) geeft hem aan elke handler.

Hetzelfde als controller

[ApiController]
[Route("v1/producten")]
public class ProductenController(ProductOpslag opslag) : ControllerBase
{
    [HttpGet]
    public ActionResult<List<Product>> Lijst() => opslag.Alle();

    [HttpGet("{id:int}", Name = "ProductOphalenCtrl")]
    public ActionResult<Product> EenProduct(int id) => opslag.Zoek(id) is { } p ? p : NotFound();

    [HttpPost]
    public ActionResult<Product> Maak(Product nieuw)
    {
        opslag.Voeg(nieuw);
        return CreatedAtRoute("ProductOphalenCtrl", new { id = nieuw.Id }, nieuw);
    }
}
builder.Services.AddControllers();
...
app.MapControllers();

[ApiController] zet handige gedragingen aan: automatische 400 bij ongeldige invoer (les 5), body-binding zonder [FromBody], en ProblemDetails bij fouten.

Voorspel eerst: je registreert beide versies tegelijk op /v1/producten. Wat gebeurt er bij GET /v1/producten?

Uitkomst

Een exception: AmbiguousMatchException: The request matched multiple endpoints. Twee endpoints met dezelfde methode en route kunnen niet naast elkaar bestaan. Kies per route รฉรฉn stijl.

Vergelijking

Minimal API's Controllers
Opzet functies, weinig ceremonie klassen, attributen
Prestaties iets sneller, AOT-vriendelijk iets meer overhead
Filters endpoint filters action filters, rijk ecosysteem
Model validatie zelf of met een filter (les 5) automatisch met [ApiController]
Bekend bij teams nieuwer veel bestaande code en voorbeelden

Beide zijn prima. Deze cursus gebruikt minimal API's in groepen per resource: kort, expliciet en de richting waarin ASP.NET Core zich ontwikkelt. Kom je in een team met controllers, dan herken je alles.

Mappenstructuur

Winkel.Api/
โ”œโ”€โ”€ Program.cs
โ”œโ”€โ”€ Endpoints/      ProductEndpoints.cs, BestellingEndpoints.cs
โ”œโ”€โ”€ Domein/         Product.cs, Bestelling.cs
โ”œโ”€โ”€ Contracten/     DTO's (les 4)
โ”œโ”€โ”€ Data/           DbContext (les 8)
โ””โ”€โ”€ Diensten/       ProductOpslag.cs, ...

Typische fouten

  • AmbiguousMatchException: twee endpoints op dezelfde route en methode.
  • Controller wordt niet gevonden: AddControllers() of MapControllers() vergeten, of de klasse is niet public.
  • CreatedAtRoute faalt met "No route matches": de naam in WithName/Name = klopt niet.

Oefenen

  1. Verplaats de endpoints uit les 2 naar ProductEndpoints met TypedResults en een ProductOpslag-singleton.
  2. Voeg DELETE /v1/producten/{id} toe met returntype Results<NoContent, NotFound>.
  3. Schrijf dezelfde DELETE als controller-actie (in een tijdelijke /v1/ctrl/producten) en vergelijk de hoeveelheid code.
  4. Maak een unit-test (zonder webserver) die EenProduct(99, opslag) aanroept en controleert dat het resultaat NotFound is.
Modelantwoord oefening 1
public class ProductOpslag
{
    private readonly List<Product> _items = [new(1, "Toetsenbord", 39.95m), new(2, "Muis", 19.95m)];
    private readonly object _slot = new();
    public List<Product> Alle() { lock (_slot) return [.. _items]; }
    public Product? Zoek(int id) { lock (_slot) return _items.FirstOrDefault(p => p.Id == id); }
    public void Voeg(Product p) { lock (_slot) _items.Add(p); }
    public bool Verwijder(int id) { lock (_slot) return _items.RemoveAll(p => p.Id == id) > 0; }
}

Het lock is nodig: een singleton wordt door gelijktijdige verzoeken gedeeld.

Modelantwoord oefening 2
static Results<NoContent, NotFound> Verwijder(int id, ProductOpslag opslag) =>
    opslag.Verwijder(id) ? TypedResults.NoContent() : TypedResults.NotFound();
Modelantwoord oefening 3
[HttpDelete("{id:int}")]
public IActionResult Verwijder(int id) => opslag.Verwijder(id) ? NoContent() : NotFound();

Bijna even kort; het verschil zit vooral in de klasse eromheen en de attributen.

Modelantwoord oefening 4
[Fact]
public void Onbekend_product_geeft_NotFound()
{
    var resultaat = ProductEndpoints.EenProduct(99, new ProductOpslag());
    Assert.IsType<NotFound>(resultaat.Result);
}

Maak EenProduct hiervoor internal en zet InternalsVisibleTo voor het testproject, of public. Met TypedResults kun je handlers testen als gewone functies.

Lokaal oefenen

Geen live lab. Controleer met je .http-bestand dat alle endpoints nog werken na het verhuizen.

Samenvatting

  • Minimal API's en controllers zijn gelijkwaardig; kies per route รฉรฉn stijl.
  • MapGroup + extensiemethodes per resource houden Program.cs klein; TypedResults maakt antwoorden expliciet en testbaar.
  • Volgende les: DTO's en model binding.

๐Ÿ“ Quiz: Minimal API's of controllers

5 vragen ยท 70% nodig om te slagen

1. Wat is het voordeel van TypedResults boven Results?
2. Wat gebeurt er als een minimal API en een controller dezelfde route en methode hebben?
3. Wat doet [ApiController] onder meer?
4. Waarvoor dient MapGroup?
5. Waarom een lock in een singleton-opslag met een List?