Construire une API
Middlewares, routage, contrôleurs contre Minimal API, liaison, validation.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 17 min
Une application ASP.NET Core est une seule fonction : un HttpContext en entrée, une Task en sortie. Kestrel accepte la connexion, remplit ce contexte, appelle la fonction une fois par requête, et envoie sur le réseau, au fur et à mesure, ce que la fonction écrit dans HttpContext.Response. Tout le reste — l'authentification, le routage, MVC, la sérialisation — est soit un maillon de cette fonction, soit une métadonnée qu'un maillon lit au passage. Les surprises de ce framework viennent presque toutes de là : d'un maillon placé au mauvais rang, ou d'un code qui lit une information qu'aucun maillon n'a encore écrite.
Le pipeline de middlewares
Un middleware est un RequestDelegate — un Func<HttpContext, Task> — qui en enveloppe un autre. Build() compose la liste inscrite à l'envers, si bien que le premier Use écrit devient le plus externe. Ce qui précède await next() s'exécute à l'aller, ce qui le suit s'exécute au retour, dans l'ordre inverse. Ne pas appeler next court-circuite tout l'aval : c'est ainsi qu'un cache ou une autorisation répond sans que le reste du pipeline n'existe pour cette requête.
La chaîne est construite une fois, au démarrage, pas par requête. Un middleware écrit comme une classe par convention reçoit donc son RequestDelegate et ses dépendances dans un constructeur appelé une seule fois : tout ce qu'il capture là devient de fait un singleton. Les dépendances à portée de requête se déclarent en paramètres d'InvokeAsync, où le conteneur les résout dans la portée courante — ou bien la classe implémente IMiddleware, et le conteneur la résout à chaque requête. Pour la même raison, la surcharge de Use qui donne un Func<Task>, celle de l'exemple, alloue deux objets par requête, là où celle qui donne un RequestDelegate n'en alloue aucun.
using System.Diagnostics;
var app = WebApplication.Create(args);
// Use ajoute un maillon a la chaine. L'ordre d'inscription est celui de
// l'aller ; le retour se fait dans l'ordre inverse, en depilant les next().
// Ici next est un Func<Task> : cette surcharge, la plus lisible, alloue deux
// objets par requete ; (context, next) => next(context), ou next est un
// RequestDelegate, ne les alloue pas.
app.Use(async (context, next) =>
{
var chrono = Stopwatch.StartNew();
// Une fois next() rendu, la reponse est le plus souvent deja partie et
// aucun en-tete n'est plus modifiable. OnStarting est le dernier point ou
// elle l'est encore : il se declenche a l'envoi des en-tetes, donc la
// duree posee ici est celle du temps jusqu'au premier octet, pas celle de
// la requete entiere.
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Duree-Ms"] = chrono.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
await next();
// Chemin de retour : le statut est connu et la duree est complete.
app.Logger.LogInformation(
"{Methode} {Chemin} -> {Statut} en {Duree} ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
chrono.ElapsedMilliseconds);
// Toucher la reponse ici la casse des lors qu'elle a commence :
// context.Response.Headers["X-Tard"] = "1";
// InvalidOperationException: Headers are read-only, response has already started.
// context.Response.StatusCode = 503;
// InvalidOperationException: StatusCode cannot be set because the
// response has already started.
});
app.MapGet("/", () => "ok");
app.Run();
// GET / -> 200 en 4 ms (la valeur varie), et la reponse porte X-Duree-Ms: 4 Le chemin de retour a une limite dure. Dès que le premier octet part, HttpResponse.HasStarted passe à vrai et la réponse est gelée : écrire un en-tête lève Headers are read-only, response has already started, changer le statut lève StatusCode cannot be set because the response has already started. La conséquence dépasse ces deux lignes. Un gestionnaire d'exception placé tout en haut ne peut rien faire d'une panne survenue après le premier octet : le client a déjà reçu 200 OK, et il reçoit un corps tronqué là où il attendait une erreur. C'est la raison pour laquelle UseExceptionHandler teste HasStarted et relance l'exception au lieu de tenter une réponse.
L'ordre n'est donc pas une convention, c'est une chaîne de dépendances : chaque middleware lit ce qu'un autre a écrit plus haut. UseAuthorization en donne le cas le plus coûteux, parce qu'il confronte la politique de l'endpoint à HttpContext.User sans vérifier que quelqu'un l'a rempli, et que rien ne signale l'inversion.
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://exemple.fr",
ValidAudience = "commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
});
builder.Services.AddAuthorization();
var app = builder.Build();
// UseAuthorization confronte la politique de l'endpoint a HttpContext.User.
// Place avant UseAuthentication, il lit ce principal avant que quiconque l'ait
// rempli, et le principal par defaut porte une identite anonyme. La politique
// « utilisateur authentifie » echoue donc systematiquement, et le middleware
// repond par un challenge. Aucune erreur au demarrage, et le journal ne dit
// que « Authorization failed » : rien ne pointe vers l'ordre des middlewares.
app.UseAuthorization();
app.UseAuthentication();
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization();
app.Run();
// GET /commandes, avec un jeton parfaitement valide :
// HTTP/1.1 401 Unauthorized
// WWW-Authenticate: Bearer
// Content-Length: 0 L'application démarre, le journal ne mentionne qu'un échec d'autorisation, rien qui pointe vers l'ordre des middlewares, et un jeton parfaitement valide reçoit 401 : le principal que l'autorisation a examiné est celui par défaut, anonyme, puisque l'authentification ne l'a pas encore remplacé. L'ordre juste range chaque appel derrière celui qui le rend utile.
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://exemple.fr",
ValidAudience = "commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
});
builder.Services.AddAuthorization();
builder.Services.AddProblemDetails();
builder.Services.AddCors(options =>
options.AddDefaultPolicy(politique => politique.WithOrigins("https://exemple.fr")));
var app = builder.Build();
// Chaque ligne depend de ce que la precedente a produit.
app.UseExceptionHandler(); // le plus externe : il n'attrape que ce qui suit
// (sans AddProblemDetails, il refuse de demarrer)
app.UseHsts();
app.UseHttpsRedirection(); // rediriger avant de travailler, pas apres
app.UseRouting(); // choisit l'endpoint et le pose dans HttpContext
app.UseCors(); // lit la politique CORS portee par cet endpoint
app.UseAuthentication(); // remplit HttpContext.User
app.UseAuthorization(); // lit l'endpoint ET HttpContext.User
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization();
app.Run();
// GET /commandes, avec le meme jeton :
// HTTP/1.1 200 OK
// ["C-1","C-2"] Restent UseRouting et UseEndpoints, qui ne sont pas un seul geste coupé en deux. UseRouting choisit le point de terminaison et le dépose dans le contexte, sans l'exécuter ; UseEndpoints l'exécute. Tout ce qui est écrit entre les deux sait donc quel point de terminaison répondra, et peut lire ses métadonnées — c'est de là que UseCors tire sa politique et UseAuthorization ses attributs [Authorize] — sans que la réponse ait commencé.
WebApplication ajoute une subtilité qu'il vaut mieux connaître. Si aucun UseRouting n'est écrit, il en insère un avant tout le pipeline et un UseEndpoints après : le point de terminaison est alors disponible dès le premier middleware, et app.UseAuthorization() seul fonctionne. Écrire app.UseRouting() explicitement désactive cette insertion et déplace le point de décision là où il est écrit — tout ce qui le précède voit HttpContext.GetEndpoint() nul. Placer UseAuthorization avant UseRouting ne provoque alors pas un trou de sécurité silencieux mais un 500 sur chaque requête visant un point protégé : le middleware de point de terminaison constate qu'une métadonnée d'autorisation n'a été traitée par personne et lève Endpoint ... contains authorization metadata, but a middleware was not found that supports authorization.
Démarrage minimal
WebApplication.CreateBuilder(args) n'est pas une fabrique vide : il a déjà empilé les sources de configuration dans un ordre où le dernier lecteur l'emporte, branché la journalisation, créé la collection de services et choisi Kestrel. Ce qui reste à écrire est donc du delta, et un fichier suffit.
Les durées de vie se choisissent sur une seule question : qu'est-ce que l'instance a le droit de retenir ? Un singleton traverse toutes les requêtes, donc il ne peut rien retenir qui appartienne à l'une d'elles ; un service scoped vit le temps d'une requête, ce qui est exactement la durée d'un DbContext ; un transient est recréé à chaque résolution. Faire dépendre un singleton d'un scoped prolongerait ce dernier au-delà de sa requête — la validation du conteneur, active par défaut en développement, refuse alors le démarrage plutôt que de laisser la faute surgir sous charge ; le cours Injection de dépendances détaille les deux options qui la composent.
var builder = WebApplication.CreateBuilder(args);
// CreateBuilder a deja empile la configuration : appsettings.json, puis
// appsettings.{Environnement}.json, les secrets utilisateur en developpement,
// les variables d'environnement, la ligne de commande. Le dernier lecteur
// l'emporte, ce qui permet de surcharger un reglage sans toucher au fichier.
var delaiMs = builder.Configuration.GetValue("Amont:DelaiMs", 2000);
// Singleton : une instance pour toute l'application. Scoped : une par requete,
// et c'est la duree d'un DbContext. Transient : une par resolution. Un service
// singleton ne peut pas dependre d'un service scoped, et la validation de
// portee, active par defaut en developpement, le fait echouer au demarrage
// plutot qu'a la premiere requete.
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<DepotClients>();
builder.Services.AddProblemDetails();
builder.Services.AddHttpClient("amont", client =>
{
client.BaseAddress = new Uri("https://amont.exemple.fr");
client.Timeout = TimeSpan.FromMilliseconds(delaiMs);
});
// Build construit le conteneur et fige la collection :
// InvalidOperationException: The service collection cannot be modified
// because it is read-only.
var app = builder.Build();
// En developpement, WebApplication a deja insere UseDeveloperExceptionPage en
// tete de sa propre chaine : rien a ecrire pour l'obtenir. En production, c'est
// a UseExceptionHandler de repondre, et son corps est un ProblemDetails des
// lors que AddProblemDetails est enregistre.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
app.UseHsts();
}
app.UseStatusCodePages();
app.MapGet("/sante", (TimeProvider horloge) => new { etat = "ok", instant = horloge.GetUtcNow() });
// Deux Run homonymes : IApplicationBuilder.Run(RequestDelegate) ajoute un
// maillon terminal au pipeline, WebApplication.Run() demarre le serveur et
// bloque jusqu'a l'arret.
app.Run();
internal sealed class DepotClients;
// GET /sante -> 200
// {"etat":"ok","instant":"2026-09-25T14:57:42.655153+00:00"} Trois détails de ce fichier surprennent quand on ne les a pas en tête. builder.Build() fige la collection de services : un ajout après cette ligne lève The service collection cannot be modified because it is read-only. En développement, la page d'exception détaillée est déjà là sans qu'on l'ait demandée, car WebApplication l'insère lui-même. Et app.Run() est un homonyme : sans argument il démarre le serveur et bloque, avec un RequestDelegate il ajoute un maillon terminal au pipeline.
Minimal API ou contrôleurs
Les deux produisent des objets Endpoint, passent par le même UseRouting, le même pipeline, la même autorisation, la même génération OpenAPI. Le choix ne porte donc ni sur les fonctionnalités du serveur ni sur la sécurité : il porte sur ce qui s'exécute entre l'instant où la route est choisie et l'instant où votre code est appelé.
En Minimal API, ce qui s'exécute là est un délégué fabriqué au démarrage à partir de votre signature : il lit exactement les valeurs dont vous avez besoin et appelle votre lambda. Il n'y a ni ModelState, ni filtres d'action, ni formateurs de sortie, ni négociation de contenu — la sérialisation est System.Text.Json, toujours, et l'en-tête Accept n'est pas consulté. L'interception se fait par AddEndpointFilter, une chaîne courte qui reçoit les arguments déjà liés.
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
// Sans cette ligne, DepotClients n'est pas un service : l'inference le prend
// pour le corps JSON. L'application demarre quand meme, puis chaque requete,
// meme vers une autre route, finit en 500 : « Body was inferred but the method
// does not allow inferred body parameters ».
builder.Services.AddScoped<DepotClients>();
var app = builder.Build();
app.MapClients();
app.Run();
public static class RoutesClients
{
public static RouteGroupBuilder MapClients(this IEndpointRouteBuilder routes)
{
// MapGroup pose un prefixe et, surtout, des metadonnees appliquees a
// tout le groupe. Elles sont inscrites une fois au demarrage sur chaque
// point de terminaison du groupe, pas evaluees a chaque requete.
var groupe = routes.MapGroup("/clients").WithTags("Clients");
// Les parametres se resolvent dans cet ordre : attribut explicite, type
// special (HttpContext, ClaimsPrincipal, CancellationToken...), methode
// statique BindAsync, string ou type a TryParse lu dans la route puis
// dans la chaine de requete, service enregistre au conteneur, et enfin
// le corps JSON. id vient donc de la route, depot du conteneur, ct du
// contexte, et rien de tout cela n'est ecrit.
//
// Le type de retour declare est ce que lit le generateur OpenAPI. Avec
// IResult — ce que rend Results.Ok — il n'y a rien a lire et le
// document ne decrit aucun corps ; avec Results<Ok<Client>, NotFound>,
// les deux statuts et le schema du 200 sont deduits sans attribut.
groupe.MapGet("/{id:int}", async Task<Results<Ok<Client>, NotFound>> (
int id,
DepotClients depot,
CancellationToken ct) =>
{
var client = await depot.LireAsync(id, ct);
return client is null ? TypedResults.NotFound() : TypedResults.Ok(client);
});
groupe.MapPost("/", async Task<Created<Client>> (
CreationClient corps,
DepotClients depot,
CancellationToken ct) =>
{
var client = await depot.CreerAsync(corps, ct);
return TypedResults.Created($"/clients/{client.Id}", client);
});
return groupe;
}
}
public sealed record Client(int Id, string Nom);
public sealed record CreationClient(string Nom);
public sealed class DepotClients
{
private static readonly Dictionary<int, Client> Table = new() { [7] = new Client(7, "Alice") };
public Task<Client?> LireAsync(int id, CancellationToken ct) =>
Task.FromResult(Table.GetValueOrDefault(id));
public Task<Client> CreerAsync(CreationClient corps, CancellationToken ct)
{
var client = new Client(Table.Count + 7, corps.Nom);
Table[client.Id] = client;
return Task.FromResult(client);
}
}
// GET /clients/7 -> 200 {"id":7,"nom":"Alice"}
// GET /clients/99 -> 404, Content-Length: 0
// POST /clients {"nom":"Bob"} -> 201, Location: /clients/8
// {"id":8,"nom":"Bob"} En contrôleur, ce qui s'exécute là est l'invocateur d'action de MVC : un pipeline de filtres à cinq étages — autorisation, ressource, action, exception, résultat —, une liaison alimentée par des fournisseurs de valeurs, un ModelState, puis l'exécution du IActionResult rendu, qui pour un ObjectResult passe par les formateurs de sortie et négocie le type de contenu.
using Microsoft.AspNetCore.Mvc;
// Meme projet que l'exemple precedent : Client, CreationClient et DepotClients
// en viennent. Program.cs remplace app.MapClients() — sinon deux endpoints
// portent /clients/{id:int} et le routage leve AmbiguousMatchException — par :
// builder.Services.AddControllers(); // avant Build
// app.MapControllers(); // avant Run
//
// [ApiController] n'est pas decoratif. Il active notamment : l'inference des
// sources de liaison — un type complexe vient du corps —, le filtre qui
// transforme un ModelState invalide en 400, et la traduction des statuts
// d'erreur sans corps en ProblemDetails.
[ApiController]
[Route("clients")]
public sealed class ClientsController(DepotClients depot) : ControllerBase
{
// ActionResult<T> porte soit la valeur, soit un autre resultat, mais son
// type n'enumere pas les statuts possibles comme Results<Ok<Client>,
// NotFound> : OpenAPI ne connait le 404 que par l'attribut.
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Client>> Lire(int id, CancellationToken ct)
{
var client = await depot.LireAsync(id, ct);
return client is null ? NotFound() : Ok(client);
}
// CreatedAtAction fait construire Location par le routage, a partir du nom
// de l'action et de ses valeurs de route : l'URL suit le template si
// celui-ci change, et elle sort absolue. TypedResults.Created prend la
// chaine telle quelle, donc relative ici — deux 201, deux Location.
[HttpPost]
public async Task<ActionResult<Client>> Creer(CreationClient corps, CancellationToken ct)
{
var client = await depot.CreerAsync(corps, ct);
return CreatedAtAction(nameof(Lire), new { id = client.Id }, client);
}
}
// GET /clients/99 -> 404 application/problem+json; charset=utf-8
// {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5",
// "title":"Not Found","status":404,
// "traceId":"00-668f587bb98fa566a8395f5fc707f68b-c4897277dbc1f774-00"}
// POST /clients -> 201, Location: http://localhost:5000/clients/8 Les critères qui tranchent sont ceux qui touchent cette zone-là. Le XML, la négociation de contenu, un IModelBinder maison, des conventions MVC appliquées à tous les contrôleurs d'un coup : tout cela vit dans MVC et n'a pas d'équivalent direct côté minimal. Un filtre commun à des dizaines d'endpoints n'en fait pas partie : MapGroup suivi d'AddEndpointFilter le pose sur tout un groupe. Un coût par requête plus faible, la compatibilité avec l'AOT natif et des handlers qui sont des fonctions ordinaires, donc testables sans hôte, penchent de l'autre côté. Le volume de code, lui, n'est pas un critère : un groupe minimal bien découpé et un contrôleur mince se ressemblent.
Routage et liaison de modèle
Un template de route découpe le chemin en segments littéraux et en paramètres : {id}, {id?} pour facultatif, {page=1} pour une valeur par défaut, {*reste} pour capturer la fin. Une contrainte comme {id:int} ou {code:length(3)} s'écrit après deux-points.
Une contrainte n'est pas une validation. Elle participe à la sélection de la route : si elle échoue, ce template ne correspond pas, et s'il n'y en a aucun autre, la requête finit en 404 — jamais en 400. C'est voulu : cela permet à /clients/{id:int} et /clients/moi de coexister. C'est aussi pourquoi une contrainte ne doit pas refuser une valeur métier, sous peine de 404 incompréhensibles. Quand deux templates conviennent également, le plus précis gagne — un segment littéral bat un paramètre, qui bat un catch-all — et s'ils restent à égalité le routage lève une AmbiguousMatchException.
En Minimal API, la source de chaque paramètre se déduit dans un ordre fixe : un attribut explicite d'abord ; sinon un type spécial reconnu, comme HttpContext, ClaimsPrincipal ou CancellationToken ; sinon une méthode statique BindAsync sur le type ; sinon, pour un string ou un type muni de TryParse, la route puis la chaîne de requête ; sinon un service enregistré au conteneur ; et en dernier recours le corps JSON. Un paramètre non nullable qu'on ne trouve nulle part ne devient pas default : la requête reçoit un 400, vide en production ; en développement, RouteHandlerOptions.ThrowOnBadRequest vaut vrai et ce 400 porte la trace de l'exception qui nomme le paramètre manquant. Un corps envoyé sans Content-Type: application/json reçoit un 415, avant même que la désérialisation soit tentée.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<DepotCommandes>();
var app = builder.Build();
// Chaque attribut nomme une source. Sans eux, la regle par defaut choisirait
// les memes : id est un int a TryParse et porte le nom d'un segment de route,
// donc route ; numeroPage est un int a TryParse sans segment homonyme, donc
// chaine de requete — mais sous la cle numeroPage, et c'est Name qui la
// renomme en page ; corps est un type complexe sans TryParse ni BindAsync, non
// enregistre au conteneur, donc corps JSON ; depot est enregistre, donc
// service. Les ecrire rend la lecture sure, et permet de renommer.
//
// Un seul parametre peut venir du corps : deux [FromBody] sont une erreur de
// compilation, ASP0024, levee par l'analyseur du SDK.
app.MapPut("/clients/{id:int}/commandes", (
[FromRoute] int id,
[FromQuery(Name = "page")] int numeroPage,
[FromBody] MiseAJourCommandes corps,
[FromServices] DepotCommandes depot,
CancellationToken ct) => depot.RemplacerAsync(id, numeroPage, corps, ct));
app.Run();
public sealed record MiseAJourCommandes(string[] References);
public sealed class DepotCommandes
{
public Task<int> RemplacerAsync(
int client,
int page,
MiseAJourCommandes corps,
CancellationToken ct) => Task.FromResult(corps.References.Length);
}
// Corps envoye : {"references":["C-1","C-2"]}
// PUT /clients/7/commandes?page=2 -> 200, corps : 2
// PUT /clients/7/commandes?numeroPage=2 -> 400 : c'est page qui est attendu
// En Production : Content-Length: 0, rien d'autre.
// En Development : text/plain pour curl, la page HTML pour un navigateur
// (Accept: text/html) ; le texte commence par
// Microsoft.AspNetCore.Http.BadHttpRequestException: Required parameter
// "int numeroPage" was not provided from query string.La règle des contrôleurs est différente, et c'est là que l'on se fait prendre. Sans [ApiController], un type complexe n'est pas lu dans le corps : il est composé propriété par propriété à partir des champs de formulaire, de la route et de la chaîne de requête. Un JSON posté sur une telle action arrive donc dans une instance vide, sans la moindre erreur, et seul ModelState en garde la trace. [ApiController] rétablit l'attendu en inférant les sources : type complexe vers le corps, IFormFile vers le formulaire, nom correspondant à un segment vers la route, le reste vers la chaîne de requête. En sens inverse, cette composition propriété par propriété est une capacité que Minimal API n'a pas : pour agréger plusieurs valeurs de requête dans un objet, il faut [AsParameters].
Validation
Les attributs de System.ComponentModel.DataAnnotations ne valident rien par eux-mêmes : ce sont des métadonnées, et tout dépend de qui les lit. Dans un contrôleur marqué [ApiController], un filtre d'action les lit après la liaison, et si ModelState est invalide il court-circuite : l'action n'est pas appelée, et la réponse est un 400 au format ValidationProblemDetails. Ce filtre se désactive par SuppressModelStateInvalidFilter quand on veut mettre en forme les erreurs soi-même. Sans [ApiController], les attributs sont toujours évalués mais personne n'agit : le test ModelState.IsValid est à écrire à la main, dans chaque action.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
public sealed record CreationClient
{
[Required]
[StringLength(80, MinimumLength = 2)]
public string Nom { get; init; } = "";
[Required]
[EmailAddress]
public string Courriel { get; init; } = "";
[Range(0, 120)]
public int Age { get; init; }
// La validation de MVC descend dans les objets imbriques et les
// collections, jusqu'a MvcOptions.MaxValidationDepth (32 par defaut).
public Adresse? Adresse { get; init; }
}
public sealed record Adresse
{
[Required]
public string? Ville { get; init; }
}
// Program.cs : builder.Services.AddControllers() et app.MapControllers().
[ApiController]
[Route("inscriptions")]
public sealed class InscriptionsController : ControllerBase
{
// Aucune ligne de validation ici. [ApiController] installe un filtre
// d'action qui s'execute apres la liaison : si ModelState est invalide, il
// court-circuite et rend un 400 ValidationProblemDetails. Le corps de
// l'action n'est jamais atteint.
//
// Sans [ApiController], les attributs sont toujours evalues pendant la
// liaison mais personne n'en tire de conclusion : il faut ecrire
// if (!ModelState.IsValid) return ValidationProblem(ModelState);
// Et le probleme est pire qu'un test oublie, car sans l'inference de source
// un type complexe ne vient plus du corps mais des champs de formulaire et
// de la chaine de requete : le DTO arrive vide, donc toujours invalide.
[HttpPost]
public ActionResult<CreationClient> Creer(CreationClient corps) => Ok(corps);
}
// POST /inscriptions {"nom":"a","courriel":"pasunmail","age":300,"adresse":{}}
// HTTP/1.1 400 Bad Request
// Content-Type: application/problem+json; charset=utf-8Le corps produit, réindenté ici pour la lecture, mérite d'être connu par cœur : c'est lui que les clients d'une API ASP.NET Core rencontrent le plus souvent.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Age": ["The field Age must be between 0 and 120."],
"Nom": [
"The field Nom must be a string with a minimum length of 2 and a maximum length of 80."
],
"Courriel": ["The Courriel field is not a valid e-mail address."],
"Adresse.Ville": ["The Ville field is required."]
},
"traceId": "00-8739169bbc39ea5ce60b4796d0e8daaa-f52dbfb76a551b2a-00"
}En Minimal API, les attributs ne sont lus que si on le demande. Jusqu'à ASP.NET Core 9, rien ne les lisait : un corps aberrant traversait la liaison jusqu'au gestionnaire, et il fallait écrire soi-même un filtre de point de terminaison. ASP.NET Core 10 fournit ce filtre par builder.Services.AddValidation(). Le piège n'a pas disparu pour autant : oublier cette ligne ne produit ni erreur ni journal, et le DTO annoté a exactement la même tête que dans la version contrôleur, ce qui donne toutes les apparences d'un code protégé.
var builder = WebApplication.CreateBuilder(args);
// ASP.NET Core 10. Un generateur de source repere a la compilation les types
// annotes qui apparaissent dans les signatures des gestionnaires, et
// AddValidation pose un filtre sur les endpoints concernes. Sans cette ligne —
// ou en ASP.NET Core 9, ou elle n'existe pas — les attributs ne sont lus par
// personne : le corps absurde arrive tel quel au gestionnaire, qui rend 200.
builder.Services.AddValidation();
// Sans AddProblemDetails, ce 400 n'a pas la forme de celui de MVC : il part en
// application/json, reduit a {"title":...,"errors":{...}}, sans type, status
// ni traceId.
builder.Services.AddProblemDetails();
var app = builder.Build();
app.MapPost("/inscriptions", (CreationClient corps) => TypedResults.Ok(corps));
app.Run();
// CreationClient et Adresse : ceux de l'exemple precedent, sans le controleur.
// POST /inscriptions {"nom":"a","courriel":"pasunmail","age":300,"adresse":{}}
// -> 400 application/problem+json
// {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1",
// "title":"One or more validation errors occurred.","status":400,
// "errors":{"Nom":[...],"Courriel":[...],"Age":[...],
// "Adresse.Ville":["The Ville field is required."]},
// "traceId":"00-683c37c3293e3988e4438be9f11bc0b3-f8af06ad5752b164-00"}
//
// POST /inscriptions {"nom":"Alice","courriel":"[email protected]","age":30,
// "adresse":{"ville":"Lyon"}}
// -> 200 {"nom":"Alice","courriel":"[email protected]","age":30,"adresse":{"ville":"Lyon"}} La validation de MVC et celle d'AddValidation descendent toutes deux dans les objets imbriqués et les collections, d'où l'erreur Adresse.Ville. Validator.TryValidateObject, qu'un filtre écrit à la main appelle d'ordinaire, s'arrête au premier niveau : sur le même corps, il ne signale rien pour Adresse. Reste une limite commune à tous : les annotations ne décrivent qu'une forme — longueur, intervalle, présence. Une règle qui dépend de l'état du système, comme l'unicité d'un courriel, n'a rien à faire là : elle appartient au domaine, et sa réponse n'est pas un 400 mais un 409 ou un 422.
Rendre la bonne réponse
Results et TypedResults exposent les mêmes fabriques et diffèrent par le type rendu : Results.Ok(client) rend un IResult, TypedResults.Ok(client) rend un Ok<Client>. La différence n'est pas esthétique. Le générateur OpenAPI déduit le statut et le schéma du type de retour déclaré ; avec IResult il n'a rien à lire, et le document ne décrit aucun corps. Un test unitaire du handler, de même, peut affirmer sur un Ok<Client> sans exécuter la réponse. Quand plusieurs issues sont possibles, le type d'union Results<Ok<Client>, NotFound> les déclare toutes.
Le format d'erreur est normalisé par la RFC 9457 : un objet application/problem+json dont type identifie la classe de problème, title la résume pour un humain, status reprend le code HTTP, detail décrit ce cas précis et instance l'occurrence. Un client branche donc sa logique sur type et status, jamais sur title ni detail, qui sont du texte pour un humain et peuvent changer.
var builder = WebApplication.CreateBuilder(args);
// Enregistre IProblemDetailsService. Sans lui, les erreurs produites par le
// framework lui-meme — 404 de routage, 400 de liaison, 500 non gere — partent
// sans corps JSON, et le client n'a que le code de statut.
//
// Instance est une propriete de ProblemDetails, pas une extension : ecrire
// Extensions["instance"] produirait deux cles "instance" dans le JSON des
// qu'un resultat renseigne deja la sienne. ??= respecte celle-ci.
builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = contexte =>
contexte.ProblemDetails.Instance ??=
$"{contexte.HttpContext.Request.Method} {contexte.HttpContext.Request.Path}");
var app = builder.Build();
app.UseExceptionHandler(); // une exception non geree -> 500 ProblemDetails
app.UseStatusCodePages(); // un statut d'erreur sans corps -> ProblemDetails
// 201 : l'interet du resultat est l'en-tete Location, pas le corps.
app.MapPost("/clients", (Client corps) =>
TypedResults.Created("/clients/7", corps with { Id = 7 }));
// 404 nu. Sans UseStatusCodePages il partirait avec Content-Length: 0 ; avec,
// il devient un application/problem+json.
app.MapGet("/clients/{id:int}", (int id) => TypedResults.NotFound());
// 409 renseigne. type est l'identifiant du probleme, celui sur lequel un
// client branche sa logique : sans lui, ce serait le type generique du 409.
// title le resume pour un humain et peut etre traduit ; detail decrit ce
// cas-ci. Ni l'un ni l'autre ne doit servir de cle.
app.MapDelete("/clients/{id:int}", (int id) => TypedResults.Problem(
type: "https://exemple.fr/problemes/client-archive",
title: "Client archive",
detail: $"Le client {id} est archive et ne peut plus etre supprime.",
statusCode: StatusCodes.Status409Conflict));
app.Run();
public sealed record Client(int Id, string Nom);{
"type": "https://exemple.fr/problemes/client-archive",
"title": "Client archive",
"status": 409,
"detail": "Le client 42 est archive et ne peut plus etre supprime.",
"instance": "DELETE /clients/42",
"traceId": "00-52163eb86386dc5031438e7580277d67-86443330424149f4-00"
} Qui produit ce corps varie selon le style, et l'asymétrie surprend. TypedResults.Problem et TypedResults.ValidationProblem le sérialisent d'eux-mêmes, toujours. Mais un résultat sans corps — TypedResults.NotFound(), un 404 de routage, un 400 de liaison en production — part avec Content-Length: 0 tant que AddProblemDetails() et UseStatusCodePages() ne sont pas là pour le remplir. Dans un contrôleur [ApiController], au contraire, un NotFound() nu devient un ProblemDetails sans qu'on enregistre quoi que ce soit, parce que MVC applique sa propre traduction des statuts d'erreur. AddProblemDetails() aligne les deux mondes, ajoute le traceId qui relie la réponse aux journaux, et donne à CustomizeProblemDetails un point unique où enrichir toutes les erreurs. Il rend un dernier service : UseExceptionHandler() sans argument refuse de démarrer tant que rien ne lui dit quoi écrire.