API minimales dans .NET : création d'API HTTP légères

· 14 min de lecture

Si vous créez des API avec ASP.NET Core depuis un certain temps, vous connaissez probablement très bien l’approche basée sur un contrôleur : créez une classe de contrôleur, décorez-la avec des attributs, injectez vos services via le constructeur et connectez vos routes. Cela fonctionne, et cela fonctionne bien – mais parfois, vous avez l’impression d’écrire beaucoup de cérémonies pour ce qui revient à « accepter cette demande, faire quelque chose, renvoyer une réponse ».

C’est exactement le problème que les API minimales tentent de résoudre. Introduites dans .NET 6, les API minimales vous permettent de définir des points de terminaison HTTP avec très peu de passe-partout. Pas de contrôleurs, pas d’attributs, pas de jonglerie entre les classes de démarrage : il suffit de mapper directement la route vers le gestionnaire dans un style épuré et fonctionnel.

Dans cet article, je souhaite vous expliquer tout ce que vous devez savoir sur les API minimales : depuis votre premier point de terminaison jusqu’à l’organisation des applications volumineuses, en passant par la gestion de l’authentification, de la validation, de la documentation OpenAPI et des considérations de performances. Allons-y.

Pourquoi des API minimales ?

Avant de nous lancer, parlons de pourquoi vous choisiriez les API minimales plutôt que l’approche traditionnelle basée sur les contrôleurs.

Les contrôleurs sont parfaits lorsque vous avez besoin d’un cadre structuré et avisé. Ils vous offrent une liaison de modèle, des filtres, une négociation de contenu et une séparation claire des préoccupations dès le départ. Pour les applications de grande entreprise comptant des dizaines de développeurs, la cohérence assurée par les contrôleurs peut être un réel avantage.

Les API minimales, en revanche, brillent quand vous le souhaitez :

  • Moins de passe-partout — pas de classes de contrôleur, pas d’attributs [ApiController], pas de configuration de démarrage séparée.
  • Démarrage plus rapide : moins d’opérations basées sur la réflexion au moment du démarrage.
  • Modèle mental plus simple — un itinéraire correspond directement à un gestionnaire. C’est ça.
  • Adapté aux microservices : lorsque votre API comporte 5 à 10 points de terminaison, une configuration complète du contrôleur peut sembler excessive.

La bonne nouvelle est qu’il ne s’agit pas d’une décision à choix. Vous pouvez mélanger des contrôleurs et des API Minimal dans le même projet. Mais une fois que vous serez à l’aise avec l’approche minimale, vous pourriez vous retrouver à l’utiliser plus souvent que prévu.

Pour commencer

Créons une API minimale à partir de zéro. Si le SDK .NET est installé, c’est aussi simple que :

dotnet new web -n MyMinimalApi
cd MyMinimalApi

Cela vous donne un Program.cs qui ressemble à ceci :

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

C’est tout. C’est une API fonctionnelle. Non Startup.cs, pas de classe de contrôleur, pas de configuration de routage. Vous exécutez dotnet run, appuyez sur http://localhost:5000 et vous obtenez « Hello World ! » dos. La simplicité ici est tout l’intérêt.

Rendons-le un peu plus utile avec une API todo classique :

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var todos = new List<Todo>();

app.MapGet("/todos", () => todos);

app.MapGet("/todos/{id:int}", (int id) =>
    todos.FirstOrDefault(t => t.Id == id) is { } todo
        ? Results.Ok(todo)
        : Results.NotFound());

app.MapPost("/todos", (Todo todo) =>
{
    todos.Add(todo);
    return Results.Created($"/todos/{todo.Id}", todo);
});

app.MapDelete("/todos/{id:int}", (int id) =>
{
    var todo = todos.FirstOrDefault(t => t.Id == id);
    if (todo is null) return Results.NotFound();
    todos.Remove(todo);
    return Results.NoContent();
});

app.Run();

record Todo(int Id, string Title, bool IsComplete);

Nous avons un CRUD complet en moins de 30 lignes. C’est le pouvoir de l’approche minimale.

Gestionnaires de routes

Les gestionnaires de routes sont les fonctions qui s’exécutent lorsqu’une requête correspond à une route. Vous disposez de plusieurs options pour les définir.

Expressions Lambda

L’approche la plus courante et ce que vous verrez dans la plupart des exemples :

app.MapGet("/hello", () => "Hello!");
app.MapGet("/hello/{name}", (string name) => $"Hello, {name}!");

Groupes de méthodes

Pour une logique plus complexe, vous pouvez pointer vers une méthode nommée :

app.MapGet("/products", GetProducts);
app.MapPost("/products", CreateProduct);

static IResult GetProducts(AppDbContext db)
    => Results.Ok(db.Products.ToList());

static IResult CreateProduct(Product product, AppDbContext db)
{
    db.Products.Add(product);
    db.SaveChanges();
    return Results.Created($"/products/{product.Id}", product);
}
```Cest mon approche préférée pour tout ce qui va au-delà dune seule ligne. Il maintient votre section de mappage d'itinéraire propre et lisible : vous pouvez voir en un coup dil quels points de terminaison existent sans parcourir les détails de mise en œuvre.

### Fonctions locales

Vous pouvez également utiliser des fonctions locales, ce qui est utile lorsque vous souhaitez garder les gestionnaires proches de leurs définitions de route :

```csharp
app.MapGet("/health", CheckHealth);

IResult CheckHealth()
{
    // Some health check logic
    return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}

Liaison de paramètres

L’une des choses que j’apprécie vraiment à propos des API Minimal est la façon dont la liaison des paramètres est intuitive. Le cadre détermine d’où extraire les valeurs en fonction du contexte et du type.

Paramètres d’itinéraire

app.MapGet("/users/{id:int}", (int id) => $"User {id}");
app.MapGet("/files/{*path}", (string path) => $"File: {path}"); // catch-all

Paramètres de chaîne de requête

app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
    Results.Ok(new { query, page, pageSize }));

Les types nullables deviennent des paramètres facultatifs. Les valeurs par défaut fonctionnent exactement comme prévu.

Corps de la demande

app.MapPost("/orders", (Order order) =>
{
    // 'order' is automatically deserialized from the JSON body
    return Results.Created($"/orders/{order.Id}", order);
});

Liaison d’en-tête et de service

app.MapGet("/protected", (
    [FromHeader(Name = "X-Api-Key")] string apiKey,
    [FromServices] ILogger<Program> logger) =>
{
    logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
    return Results.Ok("Authorized");
});

HttpContext et HttpRequest

Lorsque vous avez besoin d’un accès de niveau inférieur :

app.MapGet("/info", (HttpContext context) =>
{
    var userAgent = context.Request.Headers.UserAgent.ToString();
    var ip = context.Connection.RemoteIpAddress?.ToString();
    return Results.Ok(new { userAgent, ip });
});

Liaison personnalisée avec BindAsync

Pour les types complexes, vous pouvez implémenter une méthode statique BindAsync :

public record PaginationParams(int Page, int PageSize)
{
    public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
    {
        int.TryParse(context.Request.Query["page"], out var page);
        int.TryParse(context.Request.Query["pageSize"], out var pageSize);

        var result = new PaginationParams(
            Page: page > 0 ? page : 1,
            PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
        );

        return ValueTask.FromResult<PaginationParams?>(result);
    }
}

app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
    var items = db.Items
        .Skip((pagination.Page - 1) * pagination.PageSize)
        .Take(pagination.PageSize)
        .ToList();

    return Results.Ok(items);
});

C’est incroyablement puissant. Vous définissez la logique de liaison une fois et la réutilisez sur tous vos points de terminaison.

Validation

Un domaine dans lequel les API minimales ne vous apportent pas autant de choses prêtes à l’emploi que les contrôleurs est la validation des modèles. Il n’y a pas d’application automatique des [Required] ou des [StringLength]. Mais il existe des modèles simples pour le gérer.

Validation manuelle

L’approche la plus simple — il suffit de valider dans le gestionnaire :

app.MapPost("/users", (CreateUserRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.Email))
        return Results.BadRequest(new { Error = "Email is required" });

    if (request.Name?.Length > 100)
        return Results.BadRequest(new { Error = "Name must be 100 characters or less" });

    // Create user...
    return Results.Created($"/users/{request.Email}", request);
});

Utilisation d’une bibliothèque de validation

Pour tout ce qui n’est pas trivial, je vous recommande de recourir à FluentValidation ou à une bibliothèque similaire :

public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Age).InclusiveBetween(0, 150);
    }
}

Vous pouvez connecter cela à vos points de terminaison via des filtres de points de terminaison, ce qui nous amène à la section suivante.

Filtres de point de terminaison

Les filtres de point de terminaison sont l’une des meilleures fonctionnalités des API Minimal. Considérez-les comme un middleware, mais limités à des points de terminaison spécifiques plutôt qu’à l’ensemble du pipeline. Ils ont été introduits dans .NET 7 et conviennent parfaitement aux problèmes transversaux.

Filtre de base

app.MapPost("/todos", (Todo todo) =>
{
    // Handle the request
    return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
    var todo = context.GetArgument<Todo>(0);

    if (string.IsNullOrEmpty(todo.Title))
    {
        return Results.BadRequest(new { Error = "Title is required" });
    }

    return await next(context);
});

Filtre de validation avec FluentValidation

Voici où cela devient vraiment puissant : un filtre de validation réutilisable :

public class ValidationFilter<T> : IEndpointFilter where T : class
{
    private readonly IValidator<T> _validator;

    public ValidationFilter(IValidator<T> validator)
    {
        _validator = validator;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var model = context.Arguments
            .OfType<T>()
            .FirstOrDefault();

        if (model is null)
            return Results.BadRequest(new { Error = "Request body is required" });

        var result = await _validator.ValidateAsync(model);

        if (!result.IsValid)
        {
            var errors = result.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

            return Results.ValidationProblem(errors);
        }

        return await next(context);
    }
}

// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
    // Only reached if validation passes
    return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();

Filtre de journalisation

app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
    .AddEndpointFilter(async (context, next) =>
    {
        var logger = context.HttpContext
            .RequestServices.GetRequiredService<ILogger<Program>>();

        var sw = Stopwatch.StartNew();
        var result = await next(context);
        sw.Stop();

        logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);

        return result;
    });

Vous pouvez enchaîner plusieurs filtres et ils s’exécutent dans l’ordre dans lequel ils sont ajoutés, tout comme le middleware.

Intégration OpenAPI / Swagger

Une bonne documentation API n’est plus facultative. Heureusement, les API Minimal bénéficient d’un support de première classe pour OpenAPI via le package Microsoft.AspNetCore.OpenApi.

Configuration de base

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapOpenApi();

app.MapGet("/todos", () => new List<Todo>())
    .WithName("GetTodos")
    .WithDescription("Retrieves all todo items")
    .WithTags("Todos");

app.Run();

Métadonnées riches des points de terminaison

Vous pouvez fournir des informations détaillées sur vos points de terminaison :

app.MapPost("/todos", (Todo todo) =>
{
    return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();

Utilisation de WithOpenApi pour la personnalisation

Pour un contrôle précis sur le document OpenAPI généré :

app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
    .WithOpenApi(operation =>
    {
        operation.Summary = "Get a specific todo";
        operation.Description = "Retrieves a single todo item by its unique identifier.";
        operation.Parameters[0].Description = "The unique identifier of the todo item";
        return operation;
    });

Cela vous donne le même niveau de contrôle de la documentation que vous obtiendriez avec les commentaires XML Swashbuckle sur les contrôleurs, mais d’une manière plus explicite, axée d’abord sur le code.

Authentification et autorisation

La sécurisation des points de terminaison de l’API Minimal suit les mêmes modèles que le reste d’ASP.NET Core : vous les appliquez simplement différemment.

Configuration de base```csharp

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });

builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));

var app = builder.Build();

app.UseAuthentication(); app.UseAuthorization();


### Application de l'autorisation aux points de terminaison

```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
    return Results.Ok(new
    {
        Name = user.FindFirstValue(ClaimTypes.Name),
        Email = user.FindFirstValue(ClaimTypes.Email)
    });
}).RequireAuthorization();

// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
    // Delete user logic
    return Results.NoContent();
}).RequireAuthorization("AdminOnly");

// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
    .AllowAnonymous();

Remarquez comment vous pouvez injecter ClaimsPrincipal directement dans les paramètres de votre gestionnaire – le framework s’occupe du reste. C’est l’une de ces petites choses qui rendent les API Minimal vraiment élégantes.

Organisation des grandes API

L’éléphant dans la pièce avec les API minimales est l’organisation. Lorsque votre Program.cs a 50 points de terminaison, cela devient un gâchis. Voici les modèles que j’utilise pour garder les choses gérables.

Groupes de routes

Les groupes de routage (introduits dans .NET 7) vous permettent de partager la configuration entre les points de terminaison associés :

var todos = app.MapGroup("/todos")
    .WithTags("Todos")
    .RequireAuthorization();

todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
    db.Todos.Add(todo);
    db.SaveChangesAsync();
    return Results.Created($"/todos/{todo.Id}", todo);
});

Tous les points de terminaison du groupe partagent le préfixe /todos, la balise Todos et l’exigence d’autorisation. Faire le ménage.

Méthodes d’extension

C’est le modèle qui évolue vraiment. Déplacez chaque groupe de points de terminaison dans sa propre classe statique :

// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
    public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/todos").WithTags("Todos");

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);
        group.MapPut("/{id:int}", Update);
        group.MapDelete("/{id:int}", Delete);

        return group;
    }

    private static async Task<IResult> GetAll(AppDbContext db)
        => Results.Ok(await db.Todos.ToListAsync());

    private static async Task<IResult> GetById(int id, AppDbContext db)
        => await db.Todos.FindAsync(id) is { } todo
            ? Results.Ok(todo)
            : Results.NotFound();

    private static async Task<IResult> Create(Todo todo, AppDbContext db)
    {
        db.Todos.Add(todo);
        await db.SaveChangesAsync();
        return Results.Created($"/todos/{todo.Id}", todo);
    }

    private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

        todo.Title = updated.Title;
        todo.IsComplete = updated.IsComplete;
        await db.SaveChangesAsync();

        return Results.Ok(todo);
    }

    private static async Task<IResult> Delete(int id, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

        db.Todos.Remove(todo);
        await db.SaveChangesAsync();

        return Results.NoContent();
    }
}

// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();

app.Run();

Votre Program.cs devient une table des matières pour votre API. Chaque groupe de points de terminaison réside dans son propre fichier. C’est l’approche que je recommande pour les applications de production.

La bibliothèque Carter

Si vous souhaitez encore plus de structure, la bibliothèque Carter propose une approche basée sur des modules :

public class TodoModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/todos", async (AppDbContext db) =>
            await db.Todos.ToListAsync());

        app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
        {
            db.Todos.Add(todo);
            await db.SaveChangesAsync();
            return Results.Created($"/todos/{todo.Id}", todo);
        });
    }
}

Carter découvre et enregistre automatiquement tous les modules. C’est un bon compromis entre l’approche API minimale brute et les contrôleurs complets.

TypedResults et types de réponses

À partir de .NET 7, vous pouvez utiliser TypedResults au lieu de Results pour les réponses de type sécurisé. Cela peut sembler un petit changement, mais il présente de réels avantages pour la documentation et la testabilité d’OpenAPI.

app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is not null
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound();
});

Le type de retour Results<Ok<Todo>, NotFound> indique explicitement au framework (et à vos documents OpenAPI) exactement quels types de réponse ce point de terminaison peut produire. Plus de devinettes, plus d’appels manuels Produces<>() pour les cas de base.

Pour plusieurs résultats possibles :

app.MapPost("/todos",
    async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
    (Todo todo, AppDbContext db) =>
{
    if (string.IsNullOrEmpty(todo.Title))
        return TypedResults.ValidationProblem(
            new Dictionary<string, string[]>
            {
                { "Title", new[] { "Title is required" } }
            });

    if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
        return TypedResults.Conflict();

    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todos/{todo.Id}", todo);
});

J’ai commencé à utiliser TypedResults dans tous les nouveaux projets. Le compilateur détecte les inadéquations entre vos types de retour déclarés et ce que vous retournez réellement, ce qui élimine toute une classe de surprises d’exécution.

Considérations sur les performances

L’un des arguments de vente des API Minimal est la performance, et il vaut la peine de comprendre pourquoi elles sont plus rapides.

Réduction des frais de démarrage. Les contrôleurs s’appuient fortement sur la réflexion pour découvrir les points de terminaison, lier les modèles et appliquer des filtres. Les API minimales utilisent des générateurs de sources (à partir de .NET 7) pour générer du code de liaison au moment de la compilation. Cela signifie moins de travail au démarrage et moins d’allocation de mémoire par requête.

Aucun pipeline MVC. Les API basées sur un contrôleur passent par le pipeline MVC complet : sélection d’actions, liaison de modèle, filtres d’actions, exécution des résultats. Les API minimales ignorent tout cela et passent directement du routage à votre gestionnaire.

Compilation RequestDelegate. Le framework compile vos expressions lambda en instances RequestDelegate optimisées. Le code résultant est très proche de ce que vous écririez à la main si vous travailliez directement avec HttpContext.

Voici quelques conseils pratiques pour maximiser les performances :

// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
    await db.Products.AsNoTracking().ToListAsync());

// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
    await db.Products.CountAsync());

// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
    await db.Orders
        .AsNoTracking()
        .GroupBy(o => o.Status)
        .Select(g => new { Status = g.Key, Count = g.Count() })
        .ToListAsync(ct));
```Il convient également de mentionner que lcart de performances entre les contrôleurs et les API Minimal ne cesse de se réduire à chaque version de .NET. Pour la plupart des applications, la différence ne sera pas votre goulot dtranglement : vos requêtes de base de données et vos appels de service externe le seront. Choisissez en fonction de l'expérience du développeur et des besoins du projet, et non de critères de référence.

## Conclusion

Les API minimales ont parcouru un long chemin depuis leur introduction dans .NET 6. Ce qui a commencé comme une fonctionnalité de démonstration « hello world » est devenu un choix légitime pour les API de production. Avec des filtres de points de terminaison, des groupes de routage, des résultats saisis et une solide prise en charge d'OpenAPI, vous disposez de tout ce dont vous avez besoin pour créer des services bien structurés et maintenables.

Ma recommandation ? Si vous démarrez un nouveau projet d'API, en particulier un microservice ou une API interne ciblée, essayez sérieusement les API minimales. Utilisez le modèle de méthode d'extension pour l'organisation, appuyez-vous sur les filtres de points de terminaison pour les problèmes transversaux et exploitez `TypedResults` pour la sécurité des types.

Pour les projets existants basés sur un contrôleur, il n'y a pas d'urgence à migrer. Les deux approches fonctionnent bien et vous pouvez même les utiliser côte à côte. Mais la prochaine fois que vous aurez besoin dajouter un petit service ou une API interne rapide, ignorez les contrôleurs et optez pour le minimum. Vous pourriez ne pas y retourner.

Bon codage !