Quand je documente une API métier comme AuditStock, je ne cherche pas seulement à produire une jolie page avec des routes et des boutons Try it. Je veux surtout que l'intégrateur comprenne très vite trois choses : quelle version appeler, comment s'authentifier, et quelles routes sont réellement faites pour lui.
Dans AuditStock, ces trois sujets se rejoignent dans une zone assez compacte du code :
src/AuditStock.AdminWebApp/Extensions/OpenApiExtensions.cspour la configuration OpenAPI et Scalar.src/AuditStock.AdminWebApp/Controllers/ImportController.cspour l'historique des versions de l'API d'import.src/AuditStock.AdminWebApp/Controllers/CatalogController.cspour les nouvelles routes exposées uniquement en v12.src/AuditStock.AdminWebApp/Middlewares/ApiRequestLogMiddleware.cspour le diagnostic client des appels API, y compris ceux qui n'atteignent jamais une action de contrôleur.src/AuditStock.AdminWebApp/Endpoints/SupportChatEndpoint.cspour un exemple concret d'API masquée avecExcludeFromDescription().
Je vais détailler comment tout cela fonctionne, pourquoi c'est structuré ainsi, et comment je m'y prends quand je dois faire évoluer l'API sans transformer la documentation en classeur Excel oublié dans un placard.
Le modèle mental : une API publique, plusieurs contrats
AuditStock expose ses endpoints publics sous une forme volontairement explicite :
/api/v{version}/...
Par exemple :
GET /api/v12.0/imports/ping
POST /api/v12.0/imports/start-import-session
GET /api/v12.0/catalog/products/{id}
Le choix du segment d'URL n'est pas cosmétique. Il permet de figer le contrat côté client. Quand un ERP, un connecteur ou un script planifié appelle /api/v10.0/imports/process-import, je sais quelle sémantique il attend, même si la v12 a changé le comportement de la session d'import.
Voici la vue d'ensemble.
flowchart LR
Client[Client ERP ou intégrateur] -->|X-Api-Key| Api[/API AuditStock/]
Api --> VersionReader[UrlSegmentApiVersionReader]
VersionReader --> V7[Document OpenAPI v7]
VersionReader --> V8[Document OpenAPI v8]
VersionReader --> V9[Document OpenAPI v9]
VersionReader --> V10[Document OpenAPI v10]
VersionReader --> V11[Document OpenAPI v11]
VersionReader --> V12[Document OpenAPI v12 par défaut]
V12 --> Scalar[Interface Scalar /api]Mon anecdote préférée sur le versioning : le jour où j'ai vu un client automatiser un import depuis un vieux fichier CSV avec une tâche planifiée dont personne ne connaissait plus l'existence, j'ai arrêté de considérer les anciennes versions comme du bruit. Une vieille version d'API, c'est parfois le dernier fil qui tient une intégration debout à 3 h du matin.
Où le versioning est configuré
La configuration principale est dans AddAuditStockOpenApi.
public static IServiceCollection AddAuditStockOpenApi(
this IServiceCollection services,
IHostEnvironment environment)
{
services.AddOpenApi("v7", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
services.AddOpenApi("v8", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
services.AddOpenApi("v9", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
services.AddOpenApi("v10", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
services.AddOpenApi("v11", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
services.AddOpenApi("v12", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
var apiVersion = services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new Asp.Versioning.ApiVersion(12, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
apiVersion.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
services.AddEndpointsApiExplorer();
return services;
}
Il y a plusieurs décisions importantes dans ce bloc.
Je déclare un document OpenAPI par version publique
Chaque AddOpenApi("vX") produit un document distinct :
/openapi/v7.json
/openapi/v8.json
/openapi/v9.json
/openapi/v10.json
/openapi/v11.json
/openapi/v12.json
Je préfère cette approche à un seul gros document contenant toutes les variantes, parce que Scalar peut ensuite présenter un sélecteur clair. L'intégrateur choisit sa version et ne se retrouve pas avec des routes obsolètes mélangées aux routes actuelles.
Je force OpenAPI 3.1
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
OpenAPI 3.1 est plus moderne, notamment sur l'alignement avec JSON Schema. Dans la pratique, cela évite quelques bizarreries quand on documente des contrats C# riches et que des outils externes génèrent des clients.
Je lis la version dans l'URL
options.ApiVersionReader = new UrlSegmentApiVersionReader();
Cela signifie que la version est lue dans /api/v12.0/..., et non dans un header ou un query string. J'aime cette lisibilité. Quand je regarde un log HTTP, je vois immédiatement si le client appelle v10, v11 ou v12.
Je garde la v12 comme version par défaut
options.DefaultApiVersion = new Asp.Versioning.ApiVersion(12, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
Cette décision est pratique, mais je la traite avec prudence. Le contrat public reste le chemin versionné. La version par défaut sert surtout à éviter des erreurs inutiles dans certains scénarios d'exploration ou d'outillage.
Comment les contrôleurs annoncent leurs versions
Dans ImportController, la route contient le segment versionné :
[ApiController]
[Route("api/v{version:apiVersion}/imports")]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0", Deprecated = true)]
[ApiVersion("3.0", Deprecated = true)]
[ApiVersion("4.0", Deprecated = true)]
[ApiVersion("5.0", Deprecated = true)]
[ApiVersion("6.0", Deprecated = true)]
[ApiVersion("7.0")]
[ApiVersion("8.0")]
[ApiVersion("9.0")]
[ApiVersion("10.0")]
[ApiVersion("11.0")]
[ApiVersion("12.0")]
[Authorize(AuthenticationSchemes = "ApiKeyScheme")]
public class ImportController : ControllerBase
{
}
La classe annonce toutes les versions qu'elle sait servir. Les versions 1 à 6 sont marquées comme obsolètes, mais elles existent encore. C'est le compromis que je veux : je peux dire clairement aux clients que le contrat est ancien, sans leur couper brutalement l'accès.
Ensuite, chaque action précise les versions auxquelles elle appartient.
[HttpGet("ping")]
[MapToApiVersion("7.0")]
[MapToApiVersion("8.0")]
[MapToApiVersion("9.0")]
[MapToApiVersion("10.0")]
[MapToApiVersion("11.0")]
[MapToApiVersion("12.0")]
[Tags("Diagnostic")]
[EndpointSummary("Test de connexion")]
[EndpointDescription("Retourne 'pong' si l'authentification par clé API est valide.")]
public IActionResult Ping()
{
return Ok("pong");
}
Cette granularité est essentielle. Une API ne change pas toujours d'un seul bloc. Un endpoint peut exister depuis la v7, un autre arriver en v10, et un autre être complètement repensé en v12.
Pourquoi j'ai gardé des contrôleurs classiques plutôt que des Minimal APIs
J'aime bien les Minimal APIs. Pour une petite API, un webhook, un endpoint de diagnostic ou quelques routes techniques, c'est direct, lisible et efficace. Mais sur AuditStock, j'ai volontairement gardé des contrôleurs MVC classiques pour les surfaces principales, notamment les imports et le catalogue.
La raison est très simple : avec beaucoup d'endpoints, beaucoup de versions, beaucoup de métadonnées OpenAPI et plusieurs comportements historiques, le code Minimal API peut vite devenir illisible.
Une route AuditStock n'est pas seulement :
app.MapPost("/api/v12.0/imports/import-sale", ...);
Elle porte souvent :
- une version ou plusieurs versions ;
- un tag OpenAPI ;
- un résumé ;
- une description métier ;
- plusieurs types de réponses ;
- une authentification par clé API ;
- parfois un comportement obsolète ;
- parfois des headers
WarningetDeprecated; - parfois une logique métier asynchrone avec médiateur, cache ou tâche longue.
En contrôleur, cette densité reste structurée :
[HttpPost("import-sale")]
[MapToApiVersion("7.0")]
[MapToApiVersion("8.0")]
[MapToApiVersion("9.0")]
[MapToApiVersion("10.0")]
[MapToApiVersion("11.0")]
[MapToApiVersion("12.0")]
[Tags("Imports", "Activity")]
[EndpointSummary("Import commande client")]
[EndpointDescription("Importe une commande client. Retourne le résultat de l'import.")]
[ProducesResponseType<ImportResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ImportResult>(StatusCodes.Status201Created)]
[ProducesResponseType<ImportResult>(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ImportSaleAsync(
[FromBody] ImportSaleRequest request,
CancellationToken cancellationToken = default)
{
if (settings.ImportState == Configuration.ImportState.Processing)
{
return ImportBadRequest("Import operations are currently disabled.");
}
var result = await mediator.Send(request, cancellationToken);
return ToHttpResult(result);
}
La même intention en Minimal API devient rapidement une chaîne longue, avec des lambdas qui capturent des services, des appels .WithTags(), .WithSummary(), .WithDescription(), .Produces<T>(), .MapToApiVersion() et parfois des branches de compatibilité. Pour trois endpoints, ça va. Pour une API d'import avec un historique de v1 à v12, ça commence à ressembler à une rallonge électrique après un salon professionnel : tout est branché, mais personne n'a vraiment envie de démêler.
Un exemple volontairement simplifié :
app.MapPost(
"/api/v{version:apiVersion}/imports/import-sale",
async (
ImportSaleRequest request,
IMediator mediator,
Configuration.AuditStockConfiguration settings,
CancellationToken cancellationToken) =>
{
if (settings.ImportState == Configuration.ImportState.Processing)
{
return Results.BadRequest(new ImportResult
{
Status = ImportStatus.Invalid
});
}
var result = await mediator.Send(request, cancellationToken);
return Results.Ok(result);
})
.WithTags("Imports", "Activity")
.WithSummary("Import commande client")
.WithDescription("Importe une commande client. Retourne le résultat de l'import.")
.Produces<ImportResult>(StatusCodes.Status200OK)
.Produces<ImportResult>(StatusCodes.Status201Created)
.Produces<ImportResult>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status500InternalServerError)
.RequireAuthorization();
Ce n'est pas mauvais en soi. Mais dès que j'ajoute les versions, les variantes obsolètes, les retours spécifiques, les endpoints fichier multipart, les actions de session et les helpers communs comme ToHttpResult, je préfère largement la forme contrôleur.
Avec un contrôleur classique, je gagne plusieurs choses :
- les attributs restent au-dessus de l'action qu'ils décrivent ;
- les routes d'un domaine sont regroupées dans une classe cohérente ;
- les dépendances sont injectées une seule fois dans le constructeur ;
- les helpers privés comme
ImportBadRequestouToHttpResultrestent proches des actions ; - les versions sont visibles sans devoir lire une longue chaîne de configuration ;
- les endpoints obsolètes peuvent cohabiter proprement avec les endpoints actuels.
Je ne rejette donc pas les Minimal APIs. AuditStock en utilise d'ailleurs pour une route technique comme le support chat :
endpoints.MapGet("/support-chat", (HttpContext context) =>
{
context.Response.Headers.CacheControl = "no-store";
return Results.Content(Html, "text/html; charset=utf-8");
})
.ExcludeFromDescription();
C'est exactement le bon usage : une route isolée, technique, très courte, qui n'a pas besoin de porter tout le poids documentaire et historique des endpoints d'import.
Ma règle pratique est donc assez simple : Minimal API pour les routes petites, isolées et techniques ; contrôleur classique pour les surfaces métier nombreuses, versionnées, documentées et maintenues dans le temps.
Le cas très concret de la v12 : un changement de contrat assumé
La session d'import montre bien pourquoi j'utilise MapToApiVersion.
En v10 et v11, start-import-session existe encore, mais il est obsolète :
[HttpPost("start-import-session")]
[MapToApiVersion("10.0")]
[MapToApiVersion("11.0")]
[EndpointSummary("Démarre une session d'import (obsolète)")]
[Obsolete("Cette version de la méthode est obsolète. Utilisez la version 12.0.")]
public async Task<IActionResult> StartImportSessionAsync(
[FromBody] StartImportSessionRequest request,
CancellationToken cancellationToken = default)
{
Response.Headers.Append(
"Warning",
"299 - \"this method is deprecated for api versions 10.0 and 11.0. use /api/v12.0/imports/start-import-session instead.\"");
Response.Headers.Append("Deprecated", "true");
return await StartImportSessionCoreAsync(request, cancellationToken);
}
En v12, la méthode devient la version de référence :
[HttpPost("start-import-session")]
[MapToApiVersion("12.0")]
[EndpointSummary("Démarre une session d'import")]
[EndpointDescription(
"Initialise une nouvelle session d'import. La direction de l'import est désormais obligatoire dès l'ouverture de la session (breaking change V12).")]
public async Task<IActionResult> StartImportSessionV12Async(
[FromBody] StartImportSessionRequest request,
CancellationToken cancellationToken = default)
{
if (settings.ImportState != Configuration.ImportState.Allowed)
{
return ImportBadRequest("Import operations are currently disabled.");
}
await mediator.Send(request, cancellationToken);
return Accepted("/api/imports/report");
}
J'aime beaucoup cette manière de faire parce qu'elle raconte l'histoire dans le code :
- la même route métier continue d'exister ;
- les anciennes versions répondent encore ;
- les clients reçoivent un header
Warning; - la documentation Scalar montre la bonne description selon le document choisi.
Le petit piège, que j'ai déjà vu arriver, c'est de changer uniquement le DTO et d'oublier la documentation. Résultat : l'intégrateur lit une chose, poste autre chose, puis tout le monde regarde les logs avec l'air concentré de quelqu'un qui essaie de comprendre pourquoi une cafetière demande un token OAuth. Depuis, je préfère que le changement soit explicite dans l'action, dans les attributs, dans les headers et dans OpenAPI.
Ajouter une nouvelle version sans casser l'existant
Quand je dois ajouter une v13 demain, je suis une checklist simple.
1. Ajouter le document OpenAPI
services.AddOpenApi("v13", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
2. Changer la version par défaut si la v13 devient la version courante
options.DefaultApiVersion = new Asp.Versioning.ApiVersion(13, 0);
3. Ajouter la version dans Scalar
.AddDocument("v13", "Api v13", routePattern: "/openapi/v13.json", isDefault: true);
4. Ajouter [ApiVersion("13.0")] sur les contrôleurs concernés
[ApiVersion("13.0")]
public class ImportController : ControllerBase
{
}
5. Mapper les actions compatibles
[MapToApiVersion("13.0")]
public async Task<IActionResult> ImportSaleAsync(...)
Je ne mappe pas mécaniquement toutes les routes. Je vérifie la compatibilité une par une. C'est plus lent sur le moment, mais beaucoup plus rapide que de gérer un client qui découvre un changement cassant en production.
Comment Scalar est branché dans l'application
Dans Program.cs, l'ordre est très lisible :
// Configuration OpenAPI
builder.Services.AddAuditStockOpenApi(builder.Environment);
// ...
app.MapControllers();
app.MapAuditStockOpenApi();
Les services OpenAPI sont enregistrés au démarrage, puis les documents et l'interface Scalar sont mappés après les contrôleurs.
La méthode MapAuditStockOpenApi fait deux choses :
public static void MapAuditStockOpenApi(this WebApplication app)
{
app.MapOpenApi("/openapi/{documentName}.json");
app.MapScalarApiReference("/api", options =>
{
options.Servers = Array.Empty<ScalarServer>();
options
.WithTitle("AuditStock API Documentation")
.WithTheme(ScalarTheme.Saturn)
.AddDocument("v7", "Api v7", routePattern: "/openapi/v7.json")
.AddDocument("v8", "Api v8", routePattern: "/openapi/v8.json")
.AddDocument("v9", "Api v9", routePattern: "/openapi/v9.json")
.AddDocument("v10", "Api v10", routePattern: "/openapi/v10.json")
.AddDocument("v11", "Api v11", routePattern: "/openapi/v11.json")
.AddDocument("v12", "Api v12", routePattern: "/openapi/v12.json", isDefault: true);
});
}
La documentation interactive est donc disponible sur :
/api
Et les documents bruts sont disponibles sur :
/openapi/v12.json
J'apprécie ce découpage : Scalar est l'interface humaine, OpenAPI est le contrat machine. Quand un développeur veut lire, il va sur /api. Quand un générateur de client veut consommer, il lit /openapi/v12.json.
Personnaliser Scalar depuis OpenAPI
Scalar affiche ce qu'on lui donne. La personnalisation se fait donc à deux niveaux :
- au niveau de l'interface Scalar elle-même ;
- au niveau du document OpenAPI transformé avant rendu.
Personnalisation de l'interface
Dans AuditStock, je personnalise déjà :
options
.WithTitle("AuditStock API Documentation")
.WithTheme(ScalarTheme.Saturn)
.AddDocument("v12", "Api v12", routePattern: "/openapi/v12.json", isDefault: true);
Cela donne :
- un titre clair ;
- un thème visuel ;
- un sélecteur multi-documents ;
- une version par défaut.
J'ai aussi ce réglage :
options.Servers = Array.Empty<ScalarServer>();
Ce choix évite que Scalar injecte une liste de serveurs côté interface. C'est utile quand je veux que la documentation soit portable entre environnements ou que je ne veux pas afficher un serveur qui n'est pas le bon pour le client.
Personnalisation du document OpenAPI
Le vrai levier est le transformer :
private sealed class ConfigureDocumentTransformer(IHostEnvironment environment)
: IOpenApiDocumentTransformer
{
public Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
ConfigureSecurityScheme(document);
ConfigureDocumentationInfo(document, environment, context.DocumentName);
return Task.CompletedTask;
}
}
Ce transformer intervient à la génération du document. Je l'utilise pour injecter la sécurité, les informations de contact, les liens externes et la description globale.
sequenceDiagram
participant Browser as Navigateur
participant Scalar as Scalar /api
participant OpenAPI as /openapi/v12.json
participant Transformer as ConfigureDocumentTransformer
participant Controllers as Controllers MVC
Browser->>Scalar: Ouvre /api
Scalar->>OpenAPI: Charge le document v12
OpenAPI->>Controllers: Découvre routes et attributs
OpenAPI->>Transformer: Transforme le document
Transformer-->>OpenAPI: Ajoute sécurité, contact, description
OpenAPI-->>Scalar: Retourne OpenAPI 3.1
Scalar-->>Browser: Affiche la documentation interactiveEnrichir l'introduction en récupérant le contenu du site
Une documentation OpenAPI brute est très utile pour connaître les routes, les schémas et les statuts HTTP. Mais pour un nouvel intégrateur, ce n'est pas toujours suffisant. Avant de savoir quel endpoint appeler, il a souvent besoin de comprendre le vocabulaire métier, le cycle d'import, les prérequis, les pièges fréquents et la philosophie générale de l'API.
Pour éviter d'avoir une documentation d'introduction trop pauvre dans Scalar, j'ai utilisé une technique simple : je charge le contenu Markdown depuis le site public AuditStock au moment où le document OpenAPI est généré.
Dans le transformer, la description globale du document est alimentée ici :
private static void ConfigureDocumentationInfo(
OpenApiDocument document,
IHostEnvironment environment,
string documentName)
{
document.ExternalDocs = new OpenApiExternalDocs
{
Description = "AuditStock Documentation",
Url = new Uri("https://www.auditstock.pro/documentation")
};
document.Info = new OpenApiInfo
{
Version = documentName,
Description = "L'authentification est obligatoire via un header dont la clé est X-Api-Key",
Contact = new OpenApiContact
{
Email = "support@auditstock.pro",
Name = "Support Api",
Url = new Uri("https://www.auditstock.pro"),
}
};
document.Info.Description = GetApiDescription();
}
Et la méthode GetApiDescription() va chercher le contenu distant :
private static string GetApiDescription()
{
if (System.Diagnostics.Debugger.IsAttached)
{
return "debug mode : documentation non chargée depuis le serveur distant";
}
try
{
var client = new HttpClient();
using var response = client.Send(
new HttpRequestMessage(
HttpMethod.Get,
"https://www.auditstock.pro/api-docs/documentationapi.md"));
using var stream = response.Content.ReadAsStream();
var content = new StreamReader(stream).ReadToEnd();
return content;
}
catch (Exception ex)
{
return $"# API AuditStock\n\nErreur lors du chargement de la documentation : {ex.Message}";
}
}
Le résultat est très pratique : Scalar affiche une vraie introduction, écrite en Markdown, avant de plonger l'utilisateur dans la liste des endpoints. Je peux donc donner du contexte sans dupliquer cette introduction dans le code C#.
flowchart LR
Site[Site auditstock.pro] -->|documentationapi.md| Transformer[ConfigureDocumentTransformer]
Transformer --> InfoDescription[OpenApiInfo.Description]
InfoDescription --> OpenApi["/openapi/v12.json"]
OpenApi --> Scalar[Scalar /api]
Scalar --> User[Utilisateur avec contexte métier]J'appelle ça du scraping dans le sens pragmatique du terme : je récupère un contenu externe déjà publié pour l'injecter dans la documentation technique. Ce n'est pas du scraping sauvage de pages HTML fragiles. Ici, le contenu est un fichier Markdown prévu pour ça :
https://www.auditstock.pro/api-docs/documentationapi.md
Cette nuance compte. Si je dépendais d'un DOM HTML, d'une classe CSS ou d'une structure de page marketing, la documentation pourrait casser au moindre changement visuel. Avec un fichier Markdown dédié, je garde un contrat beaucoup plus stable.
Pourquoi cette technique améliore vraiment Scalar
Sans cette introduction, l'utilisateur voit surtout une collection d'opérations :
start-import-session;import-product-list;import-delivery;process-import;report;- les routes
catalog.
Pour quelqu'un qui connaît déjà AuditStock, c'est suffisant. Pour quelqu'un qui arrive pour la première fois, il manque le récit : dans quel ordre appeler les routes, ce que représente une session d'import, pourquoi certains endpoints retournent 202 Accepted, comment interpréter les statuts métier ou pourquoi la v12 a déplacé certaines responsabilités.
En injectant une introduction riche dans document.Info.Description, je transforme la page Scalar en point d'entrée complet :
- en haut, l'utilisateur lit le contexte métier ;
- ensuite, il voit l'authentification
X-Api-Key; - puis il explore les endpoints versionnés ;
- enfin, il peut tester ou générer son client depuis le document OpenAPI.
J'aime cette approche parce qu'elle évite le syndrome de la double documentation. Le contenu d'introduction vit côté site, là où il est facile à relire et à améliorer. L'API le récupère pour que Scalar reste toujours accueillant.
Bien sûr, il y a quelques précautions.
Prévoir un mode développement
En debug, le code ne charge pas la documentation distante :
if (System.Diagnostics.Debugger.IsAttached)
{
return "debug mode : documentation non chargée depuis le serveur distant";
}
C'est utile pour ne pas dépendre du réseau à chaque lancement local. Je ne veux pas qu'un développeur perde du temps parce que sa documentation interactive attend un site externe pendant qu'il essaie simplement de tester une route.
Prévoir un fallback lisible
Si le chargement échoue, la description reste exploitable :
catch (Exception ex)
{
return $"# API AuditStock\n\nErreur lors du chargement de la documentation : {ex.Message}";
}
Ce n'est pas aussi confortable que la documentation complète, mais c'est mieux qu'une page cassée. L'utilisateur comprend que le contrat OpenAPI existe encore, mais que l'introduction distante n'a pas pu être récupérée.
Garder le contenu distant orienté utilisateur
Je ne mets pas dans ce fichier Markdown des détails qui doivent absolument suivre le code ligne par ligne. Pour ça, les attributs OpenAPI restent meilleurs. Le Markdown distant sert plutôt à expliquer :
- le contexte général ;
- les concepts métier ;
- le déroulé recommandé ;
- les conventions ;
- les erreurs fréquentes ;
- les conseils d'intégration.
Une anecdote : avant d'avoir ce genre d'introduction, j'ai déjà vu des utilisateurs ouvrir une documentation API et commencer directement par le troisième endpoint, parce qu'il avait le nom le plus rassurant. C'est un peu comme entrer dans une cuisine professionnelle et appuyer sur le plus gros bouton rouge parce qu'il est bien placé. Une bonne introduction évite ce genre d'expérimentation créative.
Documenter l'authentification par clé API
AuditStock utilise un header obligatoire :
X-Api-Key: votre-cle-api
La documentation OpenAPI l'annonce avec un schéma de sécurité :
private static void ConfigureSecurityScheme(OpenApiDocument document)
{
var scheme = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Name = "X-Api-Key",
Description = "Toutes les requêtes nécessitent obligatoirement une clé API."
};
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes["ApiKeyScheme"] = scheme;
document.Security = new List<OpenApiSecurityRequirement>();
var requirement = new OpenApiSecurityRequirement();
requirement.Add(new OpenApiSecuritySchemeReference("ApiKeyScheme", document), []);
document.Security.Add(requirement);
}
Le nom ApiKeyScheme fait le lien avec l'authentification ASP.NET Core configurée dans Program.cs :
builder.Services.AddAuthentication(...)
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
"ApiKeyScheme",
null);
Puis les contrôleurs publics imposent ce schéma :
[Authorize(AuthenticationSchemes = "ApiKeyScheme")]
public class CatalogController : ControllerBase
{
}
Je vois souvent des documentations qui expliquent l'authentification dans un paragraphe, mais qui ne la modélisent pas dans OpenAPI. C'est dommage : si le schéma est bien déclaré, l'interface interactive peut aider l'utilisateur à fournir la clé, et les générateurs de clients savent qu'une authentification est attendue.
Voici une copie d'écran pour montrer l'interface avec l'obligation d'indiquer la clé api :
Enrichir les endpoints avec les attributs modernes
Les actions utilisent plusieurs attributs très utiles :
[Tags("Catalog")]
[EndpointSummary("Produit par code interne")]
[EndpointDescription("Retourne un produit à partir de son code interne exact, ou 404 s'il n'existe pas.")]
[ProducesResponseType<Product>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductByCode(string code, CancellationToken cancellationToken = default)
{
var product = await mediator.GetProductByCode(code, cancellationToken: cancellationToken);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
Je m'en sers comme d'une mini-documentation au plus près du comportement :
Tagsorganise l'interface Scalar par domaine.EndpointSummarydonne un titre court.EndpointDescriptionexplique le comportement métier.ProducesResponseTypeévite les suppositions sur les statuts HTTP et les types retournés.
Ce n'est pas seulement joli. Quand un endpoint retourne 202 Accepted parce qu'un import est traité en tâche longue, je veux que ce soit visible dans OpenAPI. Sinon, quelqu'un finira par chercher un résultat immédiat là où le contrat promet seulement une acceptation.
Les contrats peuvent aussi porter de la documentation
Dans AuditStock.Contracts, il existe un attribut maison :
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class OpenApiAttribute : Attribute
{
public OpenApiAttribute(string group, string path)
{
Group = group;
Path = path;
}
public string Group { get; set; }
public string Path { get; set; }
public string? Summary { get; set; }
public string? Description { get; set; }
public string? RequestDescription { get; set; }
}
On le retrouve sur certains modèles de requête, par exemple les imports. Cette approche est intéressante quand le contrat est partagé avec des clients ou des générateurs. Elle permet de conserver une partie de la connaissance documentaire avec le modèle lui-même.
Personnellement, je sépare les responsabilités ainsi :
- le contrôleur décrit la route, les statuts HTTP et le scénario d'appel ;
- le DTO décrit la forme et l'intention de la charge utile ;
- le transformer OpenAPI décrit les règles globales, comme l'authentification, les contacts et les informations communes.
Diagnostiquer les appels client avec ApiRequestLogMiddleware
La documentation aide à éviter les erreurs, mais elle ne les empêche pas toutes. Dans la vraie vie, un client enverra tôt ou tard une date dans un format inattendu, un JSON mal formé, une route avec une mauvaise version, ou une requête qui échoue avant même d'arriver dans mon action de contrôleur.
C'est exactement pour cela que j'aime avoir un middleware dédié au diagnostic des appels API :
app.UseRouting();
app.UseMiddleware<AuditStock.AdminWebApp.Middlewares.ApiRequestLogMiddleware>();
app.UseHealthChecks("/health");
app.MapSupportChatEndpoint();
app.MapStaticAssets();
app.UseAntiforgery();
app.UseAuthorization();
app.MapControllers();
Le point important est sa position dans le pipeline. Le middleware est placé avant MapControllers(). Il intercepte donc les requêtes /api, appelle next(context), puis observe la réponse produite par ASP.NET Core, MVC, le model binding, l'authentification ou l'action elle-même.
Cela couvre un cas très précieux pour le support : les erreurs qui sont rejetées par la pile .NET avant que mon endpoint métier ne s'exécute.
Par exemple, si un client poste une date invalide dans un modèle :
{
"creationDate": "31/31/2026",
"delivery": {
"code": "BL-42"
}
}
il est possible que l'action ImportDeliveryAsync ne soit jamais appelée. Le model binding ou la validation automatique de [ApiController] peut produire une réponse 400 Bad Request avant le code métier. Sans middleware transversal, je peux me retrouver avec un client qui dit "l'API ne marche pas" et aucun log applicatif dans le contrôleur, parce que le contrôleur n'a tout simplement pas été atteint. C'est toujours un grand moment de théâtre technique : tout le monde cherche une trace dans la méthode, alors que la requête est restée à la porte.
Le middleware règle ce problème en se plaçant autour du pipeline API.
sequenceDiagram
participant Client as Client ERP
participant Middleware as ApiRequestLogMiddleware
participant Routing as Pipeline ASP.NET Core
participant MVC as MVC / Model Binding
participant Action as Action API
participant Logs as apilogs/api-yyyy-MM-dd.log
Client->>Middleware: POST /api/v12.0/imports/import-delivery
Middleware->>Routing: next(context)
Routing->>MVC: Selection endpoint + binding du modèle
alt Requête valide
MVC->>Action: Appelle l'action
Action-->>MVC: Résultat métier
else Date invalide ou JSON incorrect
MVC-->>Routing: 400 Bad Request
end
Routing-->>Middleware: Réponse HTTP
Middleware->>Logs: Méthode, path, status, durée, IP, user-agent, réponse 4xx
Middleware-->>Client: Réponse originaleLe middleware commence par filtrer strictement le périmètre :
if (!context.Request.Path.StartsWithSegments("/api"))
{
await next(context);
return;
}
Je ne veux pas polluer ces logs avec les assets Blazor, la page de support ou les ressources statiques. Ici, je veux un journal exploitable pour le diagnostic API.
Ensuite, il capture les informations utiles :
var request = context.Request;
var method = request.Method;
var path = $"{request.Path}{request.QueryString}";
var userAgent = request.Headers.UserAgent.ToString();
var ip = request.Headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? context.Connection.RemoteIpAddress?.ToString()
?? "-";
J'apprécie particulièrement la lecture de X-Forwarded-For, parce qu'en production l'application est souvent derrière un proxy ou un load balancer. Si je ne récupère que RemoteIpAddress, je risque de logger l'adresse de l'infrastructure au lieu de l'appelant réel.
Logger aussi la réponse quand elle explique l'erreur
Le middleware remplace temporairement le body de réponse par un buffer :
var originalBody = context.Response.Body;
using var responseBuffer = new MemoryStream();
context.Response.Body = responseBuffer;
Puis il exécute le reste du pipeline :
await next(context);
Enfin, pour les statuts 3xx et 4xx, il lit le corps de réponse :
var statusCode = context.Response.StatusCode;
if (statusCode is >= 300 and < 500)
{
responseBuffer.Seek(0, SeekOrigin.Begin);
responseBody = await new StreamReader(responseBuffer).ReadToEndAsync();
}
C'est précisément ce qui rend le log utile pour les erreurs de binding ou de validation. Un simple 400 ne suffit pas. Ce qui m'intéresse, c'est la réponse qui contient le détail : champ invalide, JSON impossible à convertir, date non parsable, route absente, méthode non autorisée, et ainsi de suite.
Après lecture, le middleware remet le flux original et renvoie la réponse au client :
responseBuffer.Seek(0, SeekOrigin.Begin);
context.Response.Body = originalBody;
await responseBuffer.CopyToAsync(originalBody);
Ce détail est important : le logging ne doit pas consommer la réponse. Il doit observer, enregistrer, puis laisser le client recevoir exactement ce qu'il aurait reçu sans le middleware.
Un format de log simple et exploitable
Le middleware délègue l'écriture à ApiRequestLogService :
logService.Log(
method,
path,
userAgent,
ip,
statusCode,
stopwatch.Elapsed,
exception,
responseBody);
Le service écrit dans un fichier journal quotidien :
var fileName = $"api-{now:yyyy-MM-dd}.log";
var logsDir = Path.Combine(settings.RepositoryFolder, "apilogs");
var filePath = Path.Combine(logsDir, fileName);
await File.AppendAllTextAsync(filePath, entry, Encoding.UTF8);
Le format est volontairement lisible par un humain et filtrable par un outil texte :
2026-06-09 14:32:18.125 | POST | 400 | /api/v12.0/imports/import-delivery | 37ms | ERP-Connector/2.4 | 203.0.113.42
RESPONSE: {"errors":{"$.creationDate":["The JSON value could not be converted to System.DateTime."]}}
Dans un diagnostic client, ce genre de ligne vaut de l'or. Je peux répondre sans deviner :
- quelle route a été appelée ;
- avec quelle méthode HTTP ;
- depuis quelle IP ;
- avec quel user-agent ;
- combien de temps la requête a duré ;
- quel statut HTTP a été renvoyé ;
- quelle réponse d'erreur ASP.NET Core a produite.
Et comme l'écriture passe par un Channel<string>, la requête n'attend pas une écriture disque synchrone longue à chaque appel :
private readonly Channel<string> channel =
Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true });
public void Log(...)
{
var entry = BuildEntry(...);
channel.Writer.TryWrite(entry);
}
Je garde aussi une règle simple : le service de log ne doit jamais faire tomber l'application. Si l'écriture échoue, l'exception est absorbée dans la boucle d'écriture. On peut améliorer l'observabilité de cette erreur plus tard, mais une panne de disque de logs ne doit pas casser les imports.
Transformer les exceptions en réponse exploitable
Le middleware capture également les exceptions non gérées :
catch (Exception ex)
{
exception = ex;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await WriteImportResultAsync(context, ImportStatus.Failed, "exception", ex.Message);
logger.LogError(ex, ex.Message);
}
Puis il produit un ImportResult JSON :
internal static async Task WriteImportResultAsync(
HttpContext context,
ImportStatus status,
string errorKey,
string errorMessage)
{
var result = new ImportResult { Status = status };
result.Errors.Add(errorKey, errorMessage);
var json = JsonSerializer.Serialize(result, jsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
context.Response.ContentType = "application/json";
context.Response.ContentLength = bytes.Length;
await context.Response.Body.WriteAsync(bytes);
}
Pour une API d'import, c'est cohérent : même en cas d'erreur inattendue, le client reçoit une réponse JSON dans un format proche de ce qu'il sait déjà traiter.
Le flux complet ressemble à ceci :
flowchart TD
Request[Requête /api] --> Middleware[ApiRequestLogMiddleware]
Middleware --> Buffer[Bufferise la réponse]
Buffer --> Next[Pipeline ASP.NET Core]
Next --> ModelBinding{Model binding OK ?}
ModelBinding -->|Non| BadRequest[400 généré avant l'action]
ModelBinding -->|Oui| Controller[Action Controller]
Controller --> Response[Réponse métier]
BadRequest --> Log[Log avec body d'erreur]
Response --> Log
Log --> File[apilogs/api-yyyy-MM-dd.log]
Log --> Client[Réponse renvoyée au client]Depuis que j'utilise ce type de middleware, les échanges avec les intégrateurs sont beaucoup plus factuels. Au lieu de "ça ne passe pas", on peut dire "l'appel du 9 juin à 14:32 a envoyé une date non convertible dans creationDate". C'est moins spectaculaire qu'une réunion de crise, mais nettement plus efficace.
Masquer les APIs privées
Toutes les routes de l'application ne méritent pas d'apparaître dans Scalar. Certaines routes sont techniques, internes, liées à l'UI, à la supervision ou à un composant support. Si je les laisse dans OpenAPI, je crée de la confusion.
AuditStock a un exemple simple avec le support chat :
public static IEndpointRouteBuilder MapSupportChatEndpoint(
this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/support-chat", (HttpContext context) =>
{
context.Response.Headers.CacheControl = "no-store";
return Results.Content(Html, "text/html; charset=utf-8");
})
.ExcludeFromDescription();
return endpoints;
}
Le point important est ici :
.ExcludeFromDescription();
La route continue d'exister. Elle répond toujours. Mais elle est exclue de la description d'API générée par ApiExplorer, donc elle ne remonte pas dans OpenAPI ni dans Scalar.
flowchart TD
Endpoint[Endpoint ASP.NET Core] --> Public{Fait partie du contrat public ?}
Public -->|Oui| ApiExplorer[ApiExplorer]
ApiExplorer --> OpenApi[Document OpenAPI]
OpenApi --> Scalar[Scalar]
Public -->|Non| Exclude[ExcludeFromDescription]
Exclude --> Hidden[Route active mais non documentée]J'insiste sur une nuance : masquer une route de la documentation n'est pas une mesure de sécurité. C'est une mesure de clarté. Si la route est sensible, je dois aussi protéger l'accès avec l'authentification, l'autorisation, le réseau, ou une combinaison de ces mécanismes.
Une anecdote : j'ai déjà vu une documentation d'API afficher une route interne de diagnostic nommée presque comme une route métier. En moins d'une journée, quelqu'un l'avait utilisée dans un script, parce qu'elle "avait l'air pratique". Depuis, je considère la documentation publique comme une vitrine : si je pose un bouton dessus, quelqu'un va appuyer.
Trois niveaux pour cacher ou filtrer une API
Selon le besoin, je choisis l'un de ces niveaux.
1. Masquer une minimal API avec ExcludeFromDescription
C'est parfait pour une route technique :
app.MapGet("/internal/status", () => Results.Ok("ok"))
.ExcludeFromDescription();
2. Masquer une action MVC avec ApiExplorer
Pour un contrôleur ou une action MVC, je peux utiliser :
[ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("private-health")]
public IActionResult PrivateHealth()
{
return Ok();
}
Cela permet de garder l'action dans le runtime MVC tout en l'excluant de la documentation.
3. Filtrer dans un transformer OpenAPI
Quand la règle est globale, je préfère un transformer. Par exemple, si je décide que toutes les routes contenant /private/ ne doivent jamais sortir dans OpenAPI :
private sealed class HidePrivateOperationsTransformer : IOpenApiDocumentTransformer
{
public Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
var privatePaths = document.Paths
.Where(path => path.Key.Contains("/private/", StringComparison.OrdinalIgnoreCase))
.Select(path => path.Key)
.ToList();
foreach (var path in privatePaths)
{
document.Paths.Remove(path);
}
return Task.CompletedTask;
}
}
Puis je l'ajoute à chaque document :
services.AddOpenApi("v12", options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
options.AddDocumentTransformer(new HidePrivateOperationsTransformer());
options.AddDocumentTransformer(new ConfigureDocumentTransformer(environment));
});
Cette approche est utile, mais je la garde pour les règles vraiment transverses. Si une route précise doit être cachée, un attribut ou ExcludeFromDescription() est plus explicite.
Le cycle complet d'une route documentée
Quand j'ajoute une route publique, je veux qu'elle passe par ce cycle :
stateDiagram-v2
[*] --> RouteCode: Ecriture du controller
RouteCode --> VersionMapping: ApiVersion et MapToApiVersion
VersionMapping --> Metadata: Tags, Summary, Description, ProducesResponseType
Metadata --> Auth: Authorize ApiKeyScheme
Auth --> OpenApi: Generation /openapi/vX.json
OpenApi --> Scalar: Affichage /api
Scalar --> Client: Integration par le client
Client --> Logs: ApiRequestLogMiddleware et appels reels
Logs --> Evolution: Nouvelle version si breaking change
Evolution --> RouteCodeCe cycle m'évite une erreur classique : considérer OpenAPI comme une étape finale. En réalité, OpenAPI est une sortie du code. Si le code est clair, la documentation devient naturellement meilleure.
Exemple complet : ajouter une route publique en v12
Imaginons que je veuille ajouter une route pour retourner le stock courant d'un produit dans un dépôt.
Je partirais sur une action de ce style :
[HttpGet("products/{id:guid}/warehouses/{warehouseId:guid}/stock")]
[MapToApiVersion("12.0")]
[Tags("Catalog")]
[EndpointSummary("Stock courant d'un produit")]
[EndpointDescription("Retourne le stock courant du produit dans le dépôt demandé.")]
[ProducesResponseType<ProductStockInfo>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetCurrentStock(
Guid id,
Guid warehouseId,
CancellationToken cancellationToken = default)
{
var product = await mediator.GetProductById(id, cancellationToken: cancellationToken);
if (product is null)
{
return NotFound();
}
var stock = await mediator.GetCurrentStockInfo(id, warehouseId, cancellationToken);
return Ok(stock);
}
Comme CatalogController est déjà limité à la v12 :
[Route("api/v{version:apiVersion}/catalog")]
[ApiVersion("12.0")]
public class CatalogController : ControllerBase
{
}
je n'ai pas besoin de gérer les anciennes versions pour ce contrôleur. C'est propre : le catalogue est une surface moderne, disponible uniquement dans le document v12.
Exemple complet : faire évoluer une route existante en v13
Pour un breaking change, je ne modifie pas l'action existante en silence. Je crée une nouvelle action mappée à la nouvelle version.
[HttpPost("process-import")]
[MapToApiVersion("12.0")]
public async Task<IActionResult> ProcessImportV12Async(
[FromBody] ProcessImportRequest request,
CancellationToken cancellationToken = default)
{
var importInfo = await mediator.Send(request, cancellationToken);
return Ok(importInfo);
}
[HttpPost("process-import")]
[MapToApiVersion("13.0")]
public async Task<IActionResult> ProcessImportV13Async(
[FromBody] ProcessImportV13Request request,
CancellationToken cancellationToken = default)
{
var importInfo = await mediator.Send(request, cancellationToken);
return Ok(importInfo);
}
Le chemin peut rester le même :
POST /api/v12.0/imports/process-import
POST /api/v13.0/imports/process-import
Mais le contrat est différent parce que la version est différente. C'est exactement ce que je veux.
Les erreurs que j'essaie d'éviter
Erreur 1 : documenter une route non supportée par la version
Si j'oublie MapToApiVersion, je peux exposer une action dans un document où elle n'a rien à faire. C'est le genre de bug qui ne casse pas le build, mais qui casse la confiance dans la documentation.
Erreur 2 : marquer une méthode [Obsolete] sans prévenir à l'exécution
[Obsolete] aide les développeurs C#, mais un client HTTP ne lit pas les attributs .NET. Les headers Warning et Deprecated sont donc très utiles :
Response.Headers.Append("Warning", "299 - \"this method is deprecated\"");
Response.Headers.Append("Deprecated", "true");
Erreur 3 : confondre route cachée et route protégée
ExcludeFromDescription() ne remplace jamais Authorize.
app.MapGet("/admin/rebuild-cache", RebuildCache)
.RequireAuthorization("AdminOnly")
.ExcludeFromDescription();
La première ligne protège. La seconde nettoie la documentation.
Erreur 4 : oublier le client généré
Une documentation OpenAPI n'est pas toujours lue par un humain. Elle peut être avalée par NSwag, Kiota, AutoRest ou un autre générateur. Une description imprécise, un type de réponse manquant ou un endpoint mal versionné peut finir dans un SDK généré.
Ma règle pratique pour décider public ou privé
Je me pose une question simple : est-ce que je suis prêt à supporter cette route comme contrat externe ?
Si oui :
- je la versionne ;
- je l'authentifie ;
- je documente les statuts ;
- je l'ajoute au bon tag ;
- je vérifie son apparition dans le bon document OpenAPI.
Si non :
- je la masque avec
ExcludeFromDescription()ouApiExplorerSettings; - je la protège quand elle est sensible ;
- je lui donne un nom qui ne ressemble pas à un endpoint métier public ;
- je la garde hors du contrat client.
Conclusion
Dans AuditStock, le versioning, OpenAPI et Scalar ne sont pas trois sujets séparés. Ils forment une chaîne :
flowchart LR
Versioning[Versioning ASP.NET Core] --> ApiExplorer[ApiExplorer]
ApiExplorer --> OpenApi[OpenAPI 3.1]
OpenApi --> Transformer[Document Transformer]
Transformer --> Scalar[Scalar]
Scalar --> Integrateur[Integrateur]Le versioning protège les intégrateurs des changements cassants. OpenAPI transforme les contrôleurs en contrat exploitable. Scalar rend ce contrat lisible et testable. ApiRequestLogMiddleware donne une trace exploitable des appels réels, y compris ceux rejetés avant l'action métier. Et ExcludeFromDescription() évite de publier des routes qui n'ont rien à faire dans la vitrine.
Ce que j'aime dans cette approche, c'est qu'elle reste pragmatique. Je peux continuer à faire évoluer l'API, ajouter de nouveaux endpoints comme le catalogue en v12, garder des imports historiques pour les clients existants, et afficher une documentation propre sans devoir maintenir un document parallèle à la main. Et franchement, chaque fois que je peux éviter un document parallèle maintenu à la main, je gagne quelques minutes de vie et probablement une meilleure humeur au prochain déploiement.
Aucun commentaire publié pour le moment.
Ajouter un commentaire