QuickApi : ma bibliothèque open source pour des Minimal API propres en .NET
QuickApi structure tes Minimal API .NET : un endpoint par classe, conventions de réponse, pagination, fichiers, SSE, avec MediatR ou Wolverine. Exemples concrets et retour d’usage.
Si tu as lu mon article sur les Minimal API, tu connais le problème : dès qu’un projet grossit, il faut sortir les endpoints de Program.cs, les ranger dans des classes, et les découvrir au démarrage. Et sur chaque projet, je réécrivais la même plomberie : le scan d’assembly, le préfixe api, les tags OpenAPI, les métadonnées de réponse, l’envoi de la request vers MediatR ou Wolverine.
Au bout de quelques projets, j’en ai fait une bibliothèque : QuickApi. Elle est open source sur GitHub, publiée sur NuGet sous le nom Walfhand.QuickApi, compatible .NET 8, 9 et 10, et elle approche les 6 000 téléchargements. Je l’utilise sur mes projets récents, dont un ERP de plus de 250 endpoints.
Je te montre comment elle marche, avec des exemples tirés de vrais projets.
L’idée : un endpoint, une ligne
Avec QuickApi, déclarer un endpoint, c’est déclarer une classe qui hérite du bon type de base. Voici un vrai endpoint de création :
public sealed class Endpoint()
: PostMinimalEndpoint<Request, PaymentTermResponse>("business-settings/payment-terms");Cette ligne suffit pour que QuickApi :
- mappe un
POSTsur/api/business-settings/payment-terms(le préfixeapiest ajouté automatiquement) ; - range l’endpoint sous le tag OpenAPI
business-settings, déduit du premier segment de la route ; - documente un
201 Createdavec le type de réponse, et un400de validation ; - lie la request depuis la route, la query et le body, puis l’envoie à ton mediator ;
- renvoie un
201avec le résultat du handler.
Pas de lambda à écrire, pas de Produces à répéter, pas de Map à appeler à la main.
Installation
dotnet add package Walfhand.QuickApiPuis deux lignes dans Program.cs :
builder.Services.AddMinimalEndpoints(options =>
{
options.SetBaseApiPath("api/v1");
});
var app = builder.Build();
app.UseMinimalEndpoints();AddMinimalEndpoints scanne tes assemblies et enregistre tous les endpoints qu’il trouve. UseMinimalEndpoints les mappe. Tu peux changer le préfixe global avec SetBaseApiPath, et limiter le scan à certaines assemblies avec options.AddAssemblies(...) si tu veux un démarrage plus déterministe.
Une feature complète dans un seul fichier
C’est là que QuickApi prend tout son sens. Sur mes projets, j’organise le code en vertical slices : une feature, un fichier, qui contient la request, l’endpoint et le handler. Voici à quoi ressemble la création d’une condition de paiement dans un ERP sur lequel je travaille, avec Wolverine comme mediator :
public sealed class CreatePaymentTerm
{
public sealed record Request
{
[FromBody] public RequestBody Body { get; set; } = null!;
public sealed record RequestBody(string Name, string? Description, int? DueInDays);
}
public sealed class Endpoint()
: PostMinimalEndpoint<Request, PaymentTermResponse>("business-settings/payment-terms");
public sealed class Handler(AppDbContext db)
{
public async Task<PaymentTermResponse> Handle(Request request, CancellationToken ct)
{
var paymentTerm = PaymentTerm.Create(
PaymentTermName.From(request.Body.Name),
PaymentTermDescription.From(request.Body.Description),
DueInDays.From(request.Body.DueInDays));
await db.Set<PaymentTerm>().AddAsync(paymentTerm, ct);
return paymentTerm.ToResponse();
}
}
}Tout est là : le contrat HTTP, la route et la logique métier. Quand quelqu’un de l’équipe cherche comment on crée une condition de paiement, il ouvre CreatePaymentTerm.cs et il a la réponse.
La request est un simple record lié avec [AsParameters]. Les attributs [FromRoute], [FromQuery] et [FromBody] disent d’où vient chaque propriété. Pour une suppression, par exemple :
public sealed record Request
{
[FromRoute] public Guid PaymentTermId { get; set; }
}
public sealed class Endpoint()
: DeleteMinimalEndpoint<Request>("business-settings/payment-terms/{paymentTermId:guid}");Des conventions de réponse cohérentes
Chaque type d’endpoint applique les métadonnées OpenAPI qui vont avec. Ton API est cohérente sans que tu y penses, et ta doc Scalar ou Swagger est juste.
| Type d’endpoint | Méthode | Réponses documentées |
|---|---|---|
PostMinimalEndpoint<TRequest, TResult> |
POST | 201 avec le résultat, 400 de validation |
GetMinimalEndpoint<TRequest, TResult> |
GET | 200 avec le résultat, 404 |
PutMinimalEndpoint<TRequest> |
PUT | 204, 404, 400 |
PatchMinimalEndpoint<TRequest> |
PATCH | 204, 404, 400 |
DeleteMinimalEndpoint<TRequest> |
DELETE | 204, 404, 400 |
FilterMinimalEndpoint<TRequest, TResult> |
GET | 200 avec une liste |
FilterPaginateMinimalEndpoint<TRequest, TResult> |
GET | 200 avec un résultat paginé |
Pagination et fichiers
Pour une liste paginée, QuickApi fournit le type de retour PaginatedResult<T> (éléments, total, page, taille de page) et l’endpoint qui va avec. Voici l’exemple du dépôt, branché sur MediatR :
// Paramètres de page et de tri communs à toutes les listes (défini dans l'exemple, pas dans la bibliothèque)
public record FilterRequest(int PageIndex = 1, int PageSize = 20, string? SortBy = null, bool SortAscending = true);
public record FilterTodosPaginatedRequest : FilterRequest, IRequest<PaginatedResult<Todo>>
{
public bool? IsCompleted { get; set; }
}
public class FilterTodosPaginatedEndpoint()
: FilterPaginateMinimalEndpoint<FilterTodosPaginatedRequest, Todo>("todos/paginated");Les paramètres de page et de tri arrivent en query string, ton handler renvoie un PaginatedResult<Todo>, et ton front a toujours le même format de réponse pour toutes les listes.
Pour un téléchargement, GetFileMinimalEndpoint attend un FileResult (contenu, type MIME, nom de fichier) et renvoie directement le fichier au navigateur. Pratique pour un export CSV ou un PDF généré.
Du temps réel avec les Server-Sent Events
La dernière version ajoute SseMinimalEndpoint<TRequest, TEvent> pour pousser des events au navigateur en text/event-stream. Tu écris un IAsyncEnumerable, QuickApi s’occupe des headers, de la sérialisation et des déconnexions. Imaginons un endpoint qui suit la progression d’un import :
public class ImportProgressEndpoint() : SseMinimalEndpoint<ImportProgressRequest, ImportProgress>("imports/{importId:guid}/progress")
{
protected override async IAsyncEnumerable<ImportProgress> Stream(
ImportProgressRequest request,
HttpContext httpContext,
[EnumeratorCancellation] CancellationToken ct)
{
var tracker = httpContext.RequestServices.GetRequiredService<IImportTracker>();
// Chaque élément produit est envoyé au navigateur sous forme d'event SSE
await foreach (var progress in tracker.WatchAsync(request.ImportId, ct))
yield return progress;
}
}Côté navigateur, un simple new EventSource('/api/imports/.../progress') suffit pour recevoir les mises à jour. Pas de WebSocket, pas de SignalR, pour du push serveur vers client c’est souvent largement assez.
Brancher MediatR ou Wolverine
QuickApi ne dépend d’aucun mediator. Les endpoints envoient la request à une petite interface, IMessage, et c’est toi qui choisis l’implémentation.
Avec MediatR, un package d’extension fait tout :
builder.Services.AddMinimalEndpoints(options => options.AddMediatR());Avec Wolverine, que j’utilise sur mes projets (je t’explique pourquoi dans mon article sur Wolverine), c’est une classe de trois lignes :
internal sealed class MessageService(IMessageBus bus) : IMessage
{
public Task<TResult> InvokeAsync<TResult>(object message, CancellationToken ct = default)
=> bus.InvokeAsync<TResult>(message, ct);
public Task InvokeAsync(object message, CancellationToken ct = default)
=> bus.InvokeAsync(message, ct);
}
builder.Services.AddScoped<IMessage, MessageService>();Bonus : tes modules ne dépendent que de IMessage, pas du mediator. Le jour où tu en changes, tu modifies une seule classe.
Sécurité et cas particuliers
Les policies d’autorisation se passent directement dans le constructeur de l’endpoint :
public sealed class Endpoint()
: DeleteMinimalEndpoint<Request>("business-settings/payment-terms/{paymentTermId:guid}", "Admin");Pour sortir de la convention, la méthode Configure est virtuelle. Tu récupères le RouteHandlerBuilder et tu ajoutes ce que tu veux, un résumé OpenAPI, du rate limiting ou un endpoint filter :
protected override RouteHandlerBuilder Configure(IEndpointRouteBuilder builder)
=> base.Configure(builder)
.WithSummary("Crée une condition de paiement")
.RequireRateLimiting("api");Tu gardes toute la puissance des Minimal API, QuickApi ne fait que t’éviter le boilerplate.
Ce que QuickApi n’est pas
Je préfère être clair sur ses limites.
- C’est une bibliothèque opinionated. Un endpoint par classe, une request par endpoint, un mediator derrière. Si tu préfères écrire tes lambdas directement, elle ne t’apportera pas grand-chose.
- Le scan d’assembly a un coût au démarrage. Il reste faible, mais sur une très grosse solution, pense à limiter les assemblies scannées.
- Ce n’est pas un framework. Elle s’appuie entièrement sur les Minimal API d’ASP.NET Core. Tout ce que tu sais faire avec elles reste valable.
Le mot de la fin
QuickApi, c’est le code que je n’avais plus envie d’écrire, mis en commun. Elle me fait gagner du temps à chaque endpoint, elle rend mes API cohérentes, et elle pousse naturellement vers une architecture en vertical slices où chaque feature tient dans un fichier.
Si tu veux l’essayer, tout est sur GitHub et sur NuGet. Les issues et les PR sont les bienvenues, et si tu l’utilises sur un projet, dis-le-moi : ça me fait toujours plaisir de savoir où elle tourne.