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
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 API | Contrôleurs MVC | |
|---|---|---|
| Cérémonie | Aucune | Classe + héritage + attributs |
| Performance de démarrage | Meilleure (pas de découverte par réflexion) | Bonne |
| Compatible Native AOT | Oui | Non |
| Filtres, conventions, model binding riche | Filtres d'endpoint (plus simples) | Écosystème très complet |
| Vues Razor / MVC classique | Non | Oui |
| Très grosse API (200+ endpoints) | Nécessite de la discipline d'organisation | Structure imposée, donc rassurante |
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 de | Règle | Exemple |
|---|---|---|
| La route | Le nom correspond à un segment {...} | "/p/{id}" + int id |
| La chaîne de requête | Type simple non présent dans la route | ?page=2 + int page |
| Le corps JSON | Type complexe (une seule fois par endpoint) | Produit nouveau |
| Les services | Type enregistré dans le conteneur | AppDb db, ILogger<T> |
| Explicitement | Attribut | [FromHeader(Name="X-Cle")] string cle |
| Un formulaire | [FromForm] ou IFormFile | IFormFile fichier |
| Le contexte | Types spéciaux reconnus | HttpContext, ClaimsPrincipal, CancellationToken |
// 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 | À renvoyer | Statut |
|---|---|---|
| Lecture réussie | TypedResults.Ok(objet) | 200 |
| Création réussie | TypedResults.Created($"/produits/{id}", objet) | 201 + en-tête Location |
| Mise à jour ou suppression réussie | TypedResults.NoContent() | 204 |
| Ressource absente | TypedResults.NotFound() | 404 |
| Données invalides | TypedResults.ValidationProblem(erreurs) | 400 + détail par champ |
| Conflit métier | TypedResults.Conflict(...) ou Problem(statusCode: 409) | 409 |
| Fichier | TypedResults.File(flux, "application/pdf", "facture.pdf") | 200 |
| Flux d'événements | TypedResults.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.
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();
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
{
"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);
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()));
- Idempotence : un client qui n'a pas reçu la réponse rejouera son POST. Accepte un en-tête
Idempotency-Key, stocke la réponse associée, et renvoie-la à l'identique en cas de rejeu. Sinon tu factureras deux fois. - Concurrence : pour un PUT, exige
If-Matchavec un ETag et réponds 412 si la ressource a changé entre-temps (concurrence optimiste). - Pagination par curseur plutôt que
Skip/Takeau-delà de quelques milliers de pages :WHERE Id > @dernierId ORDER BY Id LIMIT 20reste rapide là oùOFFSET 200000s'effondre. - Native AOT : compatible Minimal API, mais impose
JsonSerializerContext(sérialisation générée à la compilation). Le modèledotnet new webapiaotle met en place. - Court-circuit .NET 11 :
[ShortCircuit]sur un endpoint (santé,robots.txt) le fait répondre juste après le routage, en sautant le reste du pipeline.
Vérifie que c'est passé
🎯 Quiz — 5 questions
1. Pourquoi préférer TypedResults.Ok(x) à Results.Ok(x) ?
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 ?
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 ?
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 ?
5. Un client appelle GET /produits qui renvoie 400 000 lignes. Que corriges-tu ?
?taille=100000. Sans tri stable, les pages se recouvrent.Fiches de révision
MapGroup + méthodes d'extension nommées.ProblemDetails (RFC 9457), enrichi d'un traceId.AddValidation() + annotations sur le DTO. Async en .NET 11.IAsyncEnumerable + TypedResults.ServerSentEvents(...).- Minimal API = le défaut pour une API .NET moderne ; compatible Native AOT.
- Un fichier par domaine +
MapGroup: l'organisation ne se dégrade pas. - DTO toujours, entités jamais exposées.
TypedResults,ProblemDetails,AddValidation(): le trio de base.- Pagination plafonnée, tri stable, limitation de débit, contrôle de santé : non négociables.