Минимальные API в .NET: создание облегченных HTTP API

· 11 мин чтения

Если вы какое-то время создавали API с помощью ASP.NET Core, вы, вероятно, хорошо знакомы с подходом на основе контроллера: создайте класс контроллера, украсьте его атрибутами, внедрите свои сервисы через конструктор и соедините свои маршруты. Это работает, и работает хорошо, но иногда кажется, что вы пишете много церемоний, что означает «принять этот запрос, сделать что-нибудь, вернуть ответ».

Это именно та проблема, которую призваны решить минимальные API. Минимальные API, представленные в .NET 6, позволяют определять конечные точки HTTP с помощью минимального шаблонного кода. Никаких контроллеров, никаких атрибутов, никакой манипуляции с классами запуска — только прямое сопоставление маршрутов с обработчиками в чистом, функциональном стиле.

В этом посте я хочу познакомить вас со всем, что вам нужно знать о минимальных API: от вашей первой конечной точки до организации больших приложений, обработки аутентификации, проверки, документации OpenAPI и вопросов производительности. Давайте перейдем к этому.

Почему минимальные API?

Прежде чем мы начнем, давайте поговорим о том, почему вы предпочитаете минимальные API традиционному подходу на основе контроллера.

Контроллеры отлично подходят, когда вам нужна структурированная, продуманная структура. Они предоставляют вам привязку модели, фильтры, согласование контента и четкое разделение задач прямо из коробки. Для крупных корпоративных приложений с десятками разработчиков согласованность, которую обеспечивают контроллеры, может оказаться реальным преимуществом.

С другой стороны, минимальные API сияют, когда вам нужно:

  • Меньше шаблонов — нет классов контроллера, нет атрибутов [ApiController], нет отдельной конфигурации запуска.
  • Более быстрый запуск — меньше операций, связанных с отражением, во время загрузки.
  • Упрощенная ментальная модель — маршрут сопоставляется непосредственно с обработчиком. Вот и все.
  • Удобство для микросервисов — если ваш API имеет 5–10 конечных точек, полная настройка контроллера может показаться излишним.

Хорошей новостью является то, что это не решение «или-или». Вы можете смешивать контроллеры и минимальные API в одном проекте. Но как только вы освоитесь с минимальным подходом, вы, возможно, обнаружите, что будете обращаться к нему чаще, чем ожидали.

Начало работы

Давайте создадим минимальный API с нуля. Если у вас установлен .NET SDK, это так же просто, как:

[[[ТОК_1]]]

Это дает вам Program.cs, который выглядит примерно так:

[[[ТОК_3]]]

Вот и все. Это рабочий API. Нет Startup.cs, нет класса контроллера, нет конфигурации маршрутизации. Вы запускаете dotnet run, нажимаете http://localhost:5000 и получаете «Hello World!» назад. Вся суть здесь в простоте.

Давайте сделаем его немного более полезным с помощью классического API todo:

[[[ТОК_7]]]

У нас есть полный CRUD менее чем в 30 строках. В этом сила минимального подхода.

Обработчики маршрутов

Обработчики маршрутов — это функции, которые выполняются, когда запрос соответствует маршруту. У вас есть несколько вариантов их определения.

Лямбда-выражения

Самый распространенный подход и то, что вы увидите в большинстве примеров:

[[[ТОК_8]]]

Группы методов

Для более сложной логики вы можете указать именованный метод:

[[[ТОК_9]]]Это мой предпочтительный подход ко всему, что выходит за рамки однострочника. Он сохраняет раздел сопоставления маршрутов чистым и читабельным — вы можете сразу увидеть, какие конечные точки существуют, не вдаваясь в подробности реализации.

Локальные функции

Вы также можете использовать локальные функции, что полезно, если вы хотите, чтобы обработчики были близки к определениям их маршрутов:

[[[ТОК_10]]]

Привязка параметра

Одна из вещей, которые мне очень нравятся в минимальных API, — это интуитивно понятное связывание параметров. Платформа определяет, откуда брать значения, в зависимости от контекста и типа.

Параметры маршрута

[[[ТОК_11]]]

Параметры строки запроса

[[[ТОК_12]]]

Типы, допускающие значение NULL, становятся необязательными параметрами. Значения по умолчанию работают именно так, как вы ожидаете.

Тело запроса

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

Заголовок и привязка службы

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 и HttpRequest

Когда вам нужен доступ более низкого уровня:

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

Пользовательская привязка с помощью BindAsync

Для сложных типов вы можете реализовать статический метод 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);
});

Это невероятно мощно. Вы определяете логику привязки один раз и повторно используете ее на всех своих конечных точках.

Проверка

Одна из областей, в которой минимальные API не дают вам столько возможностей по сравнению с контроллерами, — это проверка модели. Автоматического применения [Required] или [StringLength] не предусмотрено. Но есть четкие шаблоны для решения этой проблемы.

Ручная проверка

Самый простой подход — просто валидируем в обработчике:

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);
});

Использование библиотеки проверки

Для чего-то нетривиального я бы рекомендовал использовать FluentValidation или аналогичную библиотеку:

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);
    }
}

Вы можете подключить это к своим конечным точкам через фильтры конечных точек, что подводит нас к следующему разделу.

Фильтры конечных точек

Фильтры конечных точек — одна из лучших функций минимальных API. Думайте о них как о промежуточном программном обеспечении, но ограниченном конкретными конечными точками, а не всем конвейером. Они были представлены в .NET 7 и отлично подходят для решения сквозных задач.

Базовый фильтр

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);
});

Фильтр проверки с FluentValidation

Вот где он становится действительно мощным — многоразовый фильтр проверки:

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>>();

Фильтр журналов

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;
    });

Вы можете объединить несколько фильтров, и они будут выполняться в том порядке, в котором они добавлены — точно так же, как промежуточное программное обеспечение.

Интеграция OpenAPI/Swagger

Хорошая документация по API больше не является обязательной. К счастью, минимальные API имеют первоклассную поддержку OpenAPI через пакет Microsoft.AspNetCore.OpenApi.

Базовая настройка

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();

Расширенные метаданные конечной точки

Вы можете предоставить подробную информацию о ваших конечных точках:

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();

Использование WithOpenApi для настройки

Для детального контроля над созданным документом OpenAPI:

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;
    });

Это дает вам тот же уровень управления документацией, который вы получаете с XML-комментариями Swashbuckle к контроллерам, но более явным способом, ориентированным на код.

Аутентификация и авторизация

Защита конечных точек минимального API следует тем же шаблонам, что и остальная часть ASP.NET Core — вы просто применяете их по-другому.

Базовая настройка```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();


### Применение авторизации к конечным точкам

```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();

Обратите внимание, как вы можете внедрить ClaimsPrincipal непосредственно в параметры вашего обработчика — обо всем остальном позаботится инфраструктура. Это одна из тех мелочей, благодаря которым минимальные API выглядят по-настоящему элегантно.

Организация больших API

Слон в комнате с минимальными API — это организация. Когда ваш Program.cs имеет 50 конечных точек, это становится беспорядком. Вот шаблоны, которые я использую, чтобы все было управляемо.

Группы маршрутов

Группы маршрутов (представленные в .NET 7) позволяют совместно использовать конфигурацию между связанными конечными точками:

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);
});

Все конечные точки в группе имеют общий префикс /todos, тег Todos и требование авторизации. Чистый.

Методы расширения

Это модель, которая действительно масштабируется. Переместите каждую группу конечных точек в отдельный статический класс:

// 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();

Ваш Program.cs становится оглавлением вашего API. Каждая группа конечных точек находится в своем собственном файле. Именно такой подход я рекомендую для производственных приложений.

Библиотека Картера

Если вам нужно еще больше структуры, библиотека Carter предлагает подход на основе модулей:

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);
        });
    }
}

Картер автоматически обнаруживает и регистрирует все модули. Это хорошая золотая середина между необработанным подходом минимального API и полноценными контроллерами.

Типизированные результаты и типы ответов

Начиная с .NET 7, вы можете использовать TypedResults вместо Results для типобезопасных ответов. Это может показаться небольшим изменением, но оно имеет реальные преимущества для документирования и тестируемости 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();
});

Тип возвращаемого значения Results<Ok<Todo>, NotFound> явно сообщает платформе (и вашей документации OpenAPI), какие именно типы ответов может генерировать эта конечная точка. Больше никаких догадок, никаких ручных вызовов Produces<>() для основных случаев.

Для нескольких возможных результатов:

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);
});

Я начал использовать TypedResults во всех новых проектах. Компилятор выявляет несоответствия между объявленными типами возвращаемых данных и тем, что вы на самом деле возвращаете, что устраняет целый класс неожиданностей во время выполнения.

Вопросы производительности

Одним из преимуществ минимальных API является производительность, и стоит понять, почему они быстрее.

Сокращение затрат при запуске. Контроллеры в значительной степени полагаются на отражение при обнаружении конечных точек, привязке моделей и применении фильтров. Минимальные API используют генераторы исходного кода (начиная с .NET 7) для создания кода привязки во время компиляции. Это означает меньше работы при запуске и меньшее выделение памяти на запрос.

Нет конвейера MVC. API на основе контроллера проходят через полный конвейер MVC: выбор действия, привязка модели, фильтры действий, выполнение результатов. Минимальные API пропускают все это и сразу переходят от маршрутизации к вашему обработчику.

Компиляция RequestDelegate. Платформа компилирует ваши лямбда-выражения в оптимизированные экземпляры RequestDelegate . Полученный код очень близок к тому, что вы написали бы вручную, если бы работали напрямую с HttpContext.

Вот несколько практических советов по повышению производительности:

// 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));
```Также стоит отметить, что разрыв в производительности между контроллерами и минимальными API продолжает сокращаться с каждым выпуском .NET. Для большинства приложений разница будет не вашим узким местом — вашими запросами к базе данных и вызовами внешних служб. Выбирайте, основываясь на опыте разработчиков и потребностях проекта, а не на тестах.

## Заключение

Минимальные API прошли долгий путь с момента их появления в .NET 6. То, что начиналось как демонстрационная функция «привет, мир», превратилось в законный выбор для рабочих API. Благодаря фильтрам конечных точек, группам маршрутов, типизированным результатам и надежной поддержке OpenAPI у вас есть все необходимое для создания хорошо структурированных и удобных в обслуживании сервисов.

Моя рекомендация? Если вы начинаете новый проект API — особенно микросервис или специализированный внутренний API — серьезно попробуйте Minimal API. Используйте шаблон метода расширения для организации, используйте фильтры конечных точек для решения сквозных задач и используйте `TypedResults` для обеспечения безопасности типов.

Для существующих проектов на базе контроллеров мигрировать не нужно. Оба подхода работают хорошо, и вы даже можете использовать их одновременно. Но в следующий раз, когда вам понадобится добавить небольшой сервис или быстрый внутренний API, пропустите контроллеры и используйте минимум. Ты можешь не вернуться.

Приятного кодирования!