Accueil › Construire le web › Chapitre 14

Minimal API (et les API en général)

Oui, tu as raison : depuis .NET 6, les Minimal API sont la façon par défaut d'écrire une API HTTP en .NET. Les contrôleurs MVC existent toujours et gardent leur intérêt — on verra précisément lequel. Ce chapitre construit une API complète et réaliste, morceau par morceau.

Une API complète en 15 lignes

Program.cs — c'est tout le fichier
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDb>(o => o.UseSqlite("Data Source=boutique.db"));
builder.Services.AddOpenApi();

var app = builder.Build();
app.MapOpenApi();                                    // /openapi/v1.json

app.MapGet("/produits", async (AppDb db) =>
    await db.Produits.AsNoTracking().ToListAsync());

app.MapGet("/produits/{id:int}", async (int id, AppDb db) =>
    await db.Produits.FindAsync(id) is Produit p
        ? Results.Ok(p)
        : Results.NotFound());

app.MapPost("/produits", async (Produit nouveau, AppDb db) =>
{
    db.Produits.Add(nouveau);
    await db.SaveChangesAsync();
    return Results.Created($"/produits/{nouveau.Id}", nouveau);
});

app.Run();

Aucune classe contrôleur, aucun attribut, aucune convention à mémoriser. Les paramètres du gestionnaire sont remplis automatiquement : id vient de l'URL, nouveau du corps JSON, db du conteneur d'injection de dépendances.

Minimal API ou contrôleurs ?

Minimal APIContrôleurs MVC
CérémonieAucuneClasse + héritage + attributs
Performance de démarrageMeilleure (pas de découverte par réflexion)Bonne
Compatible Native AOTOuiNon
Filtres, conventions, model binding richeFiltres d'endpoint (plus simples)Écosystème très complet
Vues Razor / MVC classiqueNonOui
Très grosse API (200+ endpoints)Nécessite de la discipline d'organisationStructure imposée, donc rassurante
La recommandation honnête

Nouveau projet d'API : Minimal API. Avec des groupes de routes et un fichier par domaine fonctionnel, l'organisation reste excellente jusqu'à plusieurs centaines d'endpoints. Garde les contrôleurs si tu rends des vues Razor, si ton équipe possède déjà une base MVC importante, ou si tu dépends de filtres MVC très spécifiques. Les deux peuvent cohabiter dans la même application.

Routes et liaison des paramètres

// Contraintes de route : filtrées AVANT ton code, renvoient 404 si le format ne colle pas
app.MapGet("/produits/{id:int}", ...);                    // entier seulement
app.MapGet("/clients/{id:guid}", ...);                    // GUID
app.MapGet("/archives/{annee:int:min(2000)}", ...);       // valeur minimale
app.MapGet("/fichiers/{*chemin}", ...);                   // capture le reste, slash inclus
app.MapGet("/pages/{slug:regex(^[a-z0-9-]+$)}", ...);     // expression régulière
Le paramètre vient deRègleExemple
La routeLe nom correspond à un segment {...}"/p/{id}" + int id
La chaîne de requêteType simple non présent dans la route?page=2 + int page
Le corps JSONType complexe (une seule fois par endpoint)Produit nouveau
Les servicesType enregistré dans le conteneurAppDb db, ILogger<T>
ExplicitementAttribut[FromHeader(Name="X-Cle")] string cle
Un formulaire[FromForm] ou IFormFileIFormFile fichier
Le contexteTypes spéciaux reconnusHttpContext, ClaimsPrincipal, CancellationToken
Regrouper des paramètres avec AsParameters
// Au lieu de sept paramètres dans la signature
public record FiltreProduits(string? Recherche, decimal? PrixMin, decimal? PrixMax,
                             int Page = 1, int Taille = 20);

app.MapGet("/produits", async ([AsParameters] FiltreProduits f, AppDb db) => { /* ... */ });
// GET /produits?recherche=clavier&prixMin=20&page=2

Renvoyer une réponse : TypedResults

// TypedResults est préférable à Results : le type de retour est explicite,
// donc testable sans HTTP et automatiquement documenté dans OpenAPI.
app.MapGet("/produits/{id:int}",
    async Task<Results<Ok<Produit>, NotFound>> (int id, AppDb db) =>
    {
        var p = await db.Produits.FindAsync(id);
        return p is null ? TypedResults.NotFound() : TypedResults.Ok(p);
    });
SituationÀ renvoyerStatut
Lecture réussieTypedResults.Ok(objet)200
Création réussieTypedResults.Created($"/produits/{id}", objet)201 + en-tête Location
Mise à jour ou suppression réussieTypedResults.NoContent()204
Ressource absenteTypedResults.NotFound()404
Données invalidesTypedResults.ValidationProblem(erreurs)400 + détail par champ
Conflit métierTypedResults.Conflict(...) ou Problem(statusCode: 409)409
FichierTypedResults.File(flux, "application/pdf", "facture.pdf")200
Flux d'événementsTypedResults.ServerSentEvents(source)200 (connexion maintenue)

Organiser une vraie API (le point crucial)

Un Program.cs de 800 lignes est le seul vrai reproche fait aux Minimal API — et il est facile à éviter : un fichier par domaine fonctionnel, une méthode d'extension par groupe.

Endpoints/ProduitsEndpoints.cs
public static class ProduitsEndpoints
{
    public static RouteGroupBuilder MapProduits(this IEndpointRouteBuilder routes)
    {
        var groupe = routes.MapGroup("/produits")
            .WithTags("Produits")                // regroupement dans la documentation OpenAPI
            .RequireAuthorization()              // s'applique à TOUT le groupe
            .WithOpenApi();

        groupe.MapGet("/", Lister);
        groupe.MapGet("/{id:int}", Obtenir).WithName("ObtenirProduit");
        groupe.MapPost("/", Creer).RequireAuthorization("admin");
        groupe.MapPut("/{id:int}", Modifier);
        groupe.MapDelete("/{id:int}", Supprimer).RequireAuthorization("admin");

        return groupe;
    }

    // Des méthodes nommées, pas des lambdas : testables unitairement, lisibles, réutilisables
    private static async Task<Ok<PageDe<ProduitDto>>> Lister(
        [AsParameters] FiltreProduits f, AppDb db, CancellationToken ct)
    {
        var requete = db.Produits.AsNoTracking();

        if (!string.IsNullOrWhiteSpace(f.Recherche))
            requete = requete.Where(p => p.Nom.Contains(f.Recherche));
        if (f.PrixMax is not null)
            requete = requete.Where(p => p.Prix <= f.PrixMax);

        var total = await requete.CountAsync(ct);
        var elements = await requete
            .OrderBy(p => p.Nom).ThenBy(p => p.Id)          // tri STABLE : indispensable en pagination
            .Skip((f.Page - 1) * f.Taille).Take(f.Taille)
            .Select(p => new ProduitDto(p.Id, p.Nom, p.Prix))  // projection : pas d'entité exposée
            .ToListAsync(ct);

        return TypedResults.Ok(new PageDe<ProduitDto>(elements, total, f.Page, f.Taille));
    }

    private static async Task<Results<Ok<ProduitDto>, NotFound>> Obtenir(int id, AppDb db, CancellationToken ct)
        => await db.Produits.AsNoTracking()
               .Where(p => p.Id == id)
               .Select(p => new ProduitDto(p.Id, p.Nom, p.Prix))
               .FirstOrDefaultAsync(ct) is { } dto
           ? TypedResults.Ok(dto)
           : TypedResults.NotFound();

    private static async Task<Created<ProduitDto>> Creer(CreerProduit dto, AppDb db, CancellationToken ct)
    {
        var p = new Produit { Nom = dto.Nom, Prix = dto.Prix };
        db.Produits.Add(p);
        await db.SaveChangesAsync(ct);
        return TypedResults.Created($"/produits/{p.Id}", new ProduitDto(p.Id, p.Nom, p.Prix));
    }
    // … Modifier, Supprimer …
}

// Program.cs redevient un sommaire lisible
app.MapProduits();
app.MapCommandes();
app.MapClients();
Ne jamais exposer directement ses entités EF Core

Renvoyer un Produit issu de la base expose ses relations (donc parfois des données confidentielles), crée des cycles de sérialisation infinis (Commande → Client → Commandes), et transforme chaque évolution du modèle en breaking change pour tes clients. Utilise un DTO par cas d'usage — un record suffit.

Validation .NET 10

builder.Services.AddValidation();      // ASP.NET Core 10 : validation automatique des endpoints

public record CreerProduit(
    [Required, StringLength(120, MinimumLength = 2)] string Nom,
    [Range(0.01, 100_000)]                          decimal Prix,
    [Required, RegularExpression("^[A-Z]{2}-[0-9]{4}$")] string Reference);

app.MapPost("/produits", (CreerProduit dto) => TypedResults.Created("/produits/1", dto));

// Corps invalide → 400 + ProblemDetails détaillant chaque champ, sans une ligne de code :
// { "errors": { "Nom": ["The field Nom must be a string with a minimum length of 2..."] } }

// Désactiver ponctuellement
app.MapPost("/import", Import).DisableValidation();

La validation traverse les objets imbriqués et les collections (annote le type racine de [ValidatableType] si nécessaire). ASP.NET Core 11 y ajoute la validation asynchrone : AsyncValidationAttribute et IAsyncValidatableObject permettent de vérifier en base qu'un email est libre, sans bloquer de thread (chapitre 22). Pour des règles plus riches, FluentValidation reste un choix très répandu.

Filtres d'endpoint : le code transversal

// Un filtre s'exécute autour du gestionnaire : idéal pour journaliser, mesurer, transformer
public class FiltreChrono(ILogger<FiltreChrono> log) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx,
                                                EndpointFilterDelegate suivant)
    {
        var chrono = Stopwatch.StartNew();
        var resultat = await suivant(ctx);        // ← appelle le gestionnaire (ou le filtre suivant)
        if (chrono.ElapsedMilliseconds > 500)
            log.LogWarning("Endpoint lent : {Route} en {Ms}ms",
                ctx.HttpContext.Request.Path, chrono.ElapsedMilliseconds);
        return resultat;
    }
}

app.MapProduits().AddEndpointFilter<FiltreChrono>();       // sur tout un groupe

// Version courte, en ligne
app.MapPost("/commandes", Creer)
   .AddEndpointFilter(async (ctx, suivant) =>
   {
        if (!ctx.HttpContext.Request.Headers.ContainsKey("Idempotency-Key"))
            return TypedResults.BadRequest("En-tête Idempotency-Key requis");
        return await suivant(ctx);
   });

Filtre ou middleware ? Le middleware voit toutes les requêtes et ne connaît pas la route ; le filtre s'applique à des endpoints précis et connaît leurs arguments déjà liés. Le premier pour la technique (compression, HTTPS), le second pour le fonctionnel (idempotence, quotas par endpoint).

Erreurs : ProblemDetails partout

builder.Services.AddProblemDetails(o =>
{
    // Enrichir toutes les réponses d'erreur d'un identifiant de corrélation
    o.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path;
    };
});
app.UseExceptionHandler();
app.UseStatusCodePages();       // transforme aussi les 404 « vides » en ProblemDetails
Ce que reçoit le client (RFC 9457)
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.9",
  "title": "Conflict",
  "status": 409,
  "detail": "Stock insuffisant pour la référence AB-1234 : 3 demandés, 1 disponible.",
  "instance": "/commandes",
  "traceId": "0HN7GKQ2M3F1A:00000003"
}

Documenter : OpenAPI

builder.Services.AddOpenApi(o =>
{
    o.AddDocumentTransformer((doc, ctx, ct) =>
    {
        doc.Info = new() { Title = "API Boutique", Version = "v1",
                           Description = "Catalogue et commandes" };
        return Task.CompletedTask;
    });
});

var app = builder.Build();
app.MapOpenApi();                          // document JSON : /openapi/v1.json
app.MapOpenApi("/openapi/{documentName}.yaml");   // .NET 10 : sortie YAML

// Décrire un endpoint
app.MapGet("/produits/{id:int}", Obtenir)
   .WithSummary("Obtient un produit par identifiant")
   .WithDescription("Renvoie 404 si le produit est inconnu ou retiré du catalogue.")
   .Produces<ProduitDto>(StatusCodes.Status200OK)
   .ProducesProblem(StatusCodes.Status404NotFound);
Les commentaires XML alimentent la documentation .NET 10

Active <GenerateDocumentationFile>true</GenerateDocumentationFile> : les commentaires /// de tes méthodes et de tes DTO sont repris dans le document OpenAPI par un générateur de source, sans réflexion (compatible AOT). Écris ta documentation là où vit le code.

Et l'interface graphique ? Depuis .NET 9, aucune n'est incluse par défaut. Ajoute Scalar.AspNetCore (app.MapScalarApiReference()) ou Swashbuckle si tu tiens à Swagger UI — en développement uniquement, la plupart du temps.

Streaming et temps réel .NET 10

// Server-Sent Events : le serveur pousse des messages sur une connexion HTTP maintenue
app.MapGet("/cours-bourse", (CancellationToken ct) =>
{
    async IAsyncEnumerable<Cours> Suivre([EnumeratorCancellation] CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            yield return new Cours("MSFT", Random.Shared.Next(400, 460), DateTime.UtcNow);
            await Task.Delay(1000, ct);
        }
    }
    return TypedResults.ServerSentEvents(Suivre(ct), eventType: "cours");
});

// Côté navigateur : new EventSource("/cours-bourse").onmessage = e => ...
// Renvoyer un IAsyncEnumerable<T> ordinaire produit un tableau JSON diffusé au fil de l'eau.

SSE convient au sens serveur → client. Pour du bidirectionnel (chat, collaboration), utilise SignalR.

La liste de contrôle avant la mise en production

// Pagination obligatoire sur toute liste (jamais de « tout renvoyer »)
// Tri stable, sinon la page 2 peut répéter des éléments de la page 1

// Limitation de débit
builder.Services.AddRateLimiter(o => o.AddFixedWindowLimiter("api", l =>
{
    l.Window = TimeSpan.FromMinutes(1);
    l.PermitLimit = 100;
    l.QueueLimit = 0;
}));
app.UseRateLimiter();
app.MapProduits().RequireRateLimiting("api");

// Versionnement (paquet Asp.Versioning.Http)
var v1 = app.NewVersionedApi("Produits").MapGroup("/api/v{version:apiVersion}")
            .HasApiVersion(1.0);

// Compression et cache
app.MapGet("/catalogue", Catalogue).CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)));

// Contrôle de santé
app.MapHealthChecks("/sante");

// CORS pour un front séparé
builder.Services.AddCors(o => o.AddPolicy("front", p => p
    .WithOrigins("https://boutique.fr")     // ⚠️ jamais AllowAnyOrigin avec des identifiants
    .AllowAnyHeader().AllowAnyMethod()));
Détails qui comptent en production

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. Pourquoi préférer TypedResults.Ok(x) à Results.Ok(x) ?

Avec Results<Ok<Produit>, NotFound>, la signature déclare les réponses possibles ; un test unitaire peut vérifier le type retourné sans passer par le réseau.

2. Ton Program.cs atteint 600 lignes d'endpoints. La bonne réaction ?

Les groupes de routes permettent de factoriser préfixe, autorisation, filtres et tags. Program.cs redevient un sommaire de dix lignes.

3. Que se passe-t-il si tu renvoies directement une entité EF Core avec ses relations ?

Un record DTO par cas d'usage coûte trois lignes et évite ces trois problèmes à la fois.

4. Filtre d'endpoint ou middleware pour exiger un en-tête sur un seul endpoint ?

Un middleware s'exécuterait pour toutes les requêtes, y compris les fichiers statiques.

5. Un client appelle GET /produits qui renvoie 400 000 lignes. Que corriges-tu ?

Toute liste doit être paginée et plafonnée côté serveur — un client peut toujours demander ?taille=100000. Sans tri stable, les pages se recouvrent.

Fiches de révision

Minimal API ou contrôleurs ?
Minimal par défaut. Contrôleurs pour les vues Razor ou une base MVC existante.
D'où vient un paramètre complexe ?
Du corps JSON. Type simple → route ou query. Type enregistré → conteneur DI.
Organiser 200 endpoints ?
Un fichier par domaine + MapGroup + méthodes d'extension nommées.
Format d'erreur standard ?
ProblemDetails (RFC 9457), enrichi d'un traceId.
Validation automatique ?
AddValidation() + annotations sur le DTO. Async en .NET 11.
Streaming vers le client ?
IAsyncEnumerable + TypedResults.ServerSentEvents(...).
À retenir