Page chargée : Article

Blog

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 POST sur /api/business-settings/payment-terms (le préfixe api est ajouté automatiquement) ;
  • range l’endpoint sous le tag OpenAPI business-settings, déduit du premier segment de la route ;
  • documente un 201 Created avec le type de réponse, et un 400 de validation ;
  • lie la request depuis la route, la query et le body, puis l’envoie à ton mediator ;
  • renvoie un 201 avec 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.QuickApi

Puis 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.