RE:NODE

Приложения11 мин чтения

Minimal API в .NET: маршруты, валидация и OpenAPI

Настоящий HTTP API на minimal API в .NET: привязка параметров, TypedResults, группы маршрутов, валидация, problem details, OpenAPI и когда лучше контроллеры.

0 прочтений

Minimal API - это ASP.NET Core без церемоний контроллеров: вы сопоставляете URL и HTTP-метод с функцией, а фреймворк привязывает параметры, сериализует результат и запускает те же middleware, аутентификацию и внедрение зависимостей, что и в любом другом приложении ASP.NET Core. Для API любого размера, вплоть до нескольких сотен эндпоинтов, в современном .NET это выбор по умолчанию. То, что делает их готовыми к продакшену, - группы маршрутов для организации эндпоинтов, TypedResults, чтобы типы возвращаемых значений документировали сами себя, встроенная валидация (новинка .NET 10), problem details для ошибок и сгенерированный документ OpenAPI, - это строчка-другая на каждое, и в этой статье показано всё это.

Примеры нацелены на .NET 10, текущий релиз с долгосрочной поддержкой. Где возможность появилась позже .NET 8, в тексте об этом сказано.

Minimal API - это целое веб-приложение#

Самое маленькое полезное приложение умещается в четыре строки:

Program.cs
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.MapGet("/healthz", () => Results.Ok(new { ok = true }));app.Run();

Это Kestrel, полная система конфигурации, логирование, внедрение зависимостей и конвейер middleware - тот же хост, на котором работает приложение MVC, только без частей, которые находят контроллеры через reflection. Никакого штрафа в производительности или возможностях за такой старт нет: аутентификацию, ограничение частоты, EF Core, фоновые сервисы и даже контроллеры можно добавить позже в то же приложение.

Обработчики эндпоинтов - обычные делегаты. Лямбды достаточно для одной строки; для всего, что длиннее, направьте маршрут на статический метод, чтобы обработчик можно было прочитать, протестировать и найти в стеке вызовов:

csharp
app.MapGet("/orders/{id:int}", OrderEndpoints.GetById);static class OrderEndpoints{    public static async Task<Results<Ok<OrderDto>, NotFound>> GetById(        int id, ShopDb db, CancellationToken ct)    {        var order = await db.Orders.AsNoTracking()            .Where(o => o.Id == id)            .Select(o => new OrderDto(o.Id, o.Total, o.Status))            .FirstOrDefaultAsync(ct);        return order is null ? TypedResults.NotFound() : TypedResults.Ok(order);    }}

JSON обрабатывает System.Text.Json с веб-настройками по умолчанию: имена свойств пишутся в camelCase, сопоставление на входе нечувствительно к регистру, а числа можно читать из строк. Две настройки стоит решить в первый же день, а не после того, как клиенты начнут зависеть от вывода. Перечисления сериализуются как числа, если не добавить конвертер, а это делает ответы трудночитаемыми и хрупкими, когда кто-то переставит элементы перечисления. И свойства со значением null выводятся, если не сказать сериализатору их пропускать. Обе настройки живут в одном месте:

csharp
builder.Services.ConfigureHttpJsonOptions(o =>{    o.SerializerOptions.Converters.Add(new JsonStringEnumConverter());    o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;});

Эти параметры применяются только к эндпоинтам minimal API. Если в том же приложении есть и контроллеры, они настраиваются отдельно через AddControllers().AddJsonOptions(...), и если об этом забыть, в одном приложении окажутся два стиля JSON.

Маршруты и привязка параметров#

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

ИсточникКогда выводитсяЯвный атрибут
Значение маршрутаИмя совпадает с {segment}[FromRoute]
Строка запросаПростой тип, нет совпадения с маршрутом[FromQuery]
ЗаголовокНикогда не выводится[FromHeader(Name = "X-Tenant")]
Тело JSONСложный тип в POST, PUT, PATCH[FromBody]
ФормаНикогда не выводится[FromForm] (.NET 8 и новее)
СервисыТип зарегистрирован в DI[FromServices]
Специальные типыHttpContext, CancellationToken, ClaimsPrincipal...не нужен

Ограничения маршрутов вроде {id:int}, {slug:alpha} и {page:int:min(1)} отклоняют несовпадающие URL с кодом 404 ещё до выполнения вашего кода. Они нужны для маршрутизации, а не для валидации: несработавшее ограничение означает «такого URL не существует», а это другой ответ, чем «ваши входные данные неверны».

Всегда принимайте CancellationToken в обработчиках, выполняющих ввод-вывод, и передавайте его дальше. Когда клиент отключается, токен отменяется, и запрос к базе данных останавливается, а не доделывается ни для кого.

Nullability - часть контракта. Параметр, объявленный как int page, обязателен: запрос без ?page= получает 400 ещё до вызова обработчика. Объявите его как int? page или задайте значение по умолчанию, int page = 1, и он станет необязательным. То же касается тела: параметр тела, не допускающий null, отклоняет пустой запрос. В Development ответ 400 объясняет, какой параметр не удалось привязать; в Production он намеренно скуп, так что логируйте ошибки привязки, если вам нужно их видеть.

Когда обработчик принимает много параметров, сгруппируйте их в тип с [AsParameters]:

csharp
public record OrderQuery(int Page = 1, int PageSize = 20, string? Status = null);app.MapGet("/orders", ([AsParameters] OrderQuery q, ShopDb db) => /* ... */);

Начиная с .NET 8, эндпоинты, которые привязывают формы или загрузки IFormFile, помечаются как требующие antiforgery-проверки. Для API, который вызывают другие программы, а не браузеры, вызовите .DisableAntiforgery() на этом эндпоинте; для формы в браузере зарегистрируйте antiforgery и отправляйте токен вместе с формой.

Возврат результатов: Results и TypedResults#

Обработчик может вернуть обычный объект (сериализуется в JSON со статусом 200), строку (пишется как текст) или IResult. Результаты создают два статических класса:

  • Results.Ok(x), Results.NotFound() и родственные возвращают IResult. Гибко, но фреймворк не видит по сигнатуре, что возвращает эндпоинт.
  • TypedResults.Ok(x) и родственные возвращают конкретные типы вроде Ok<OrderDto>. В сочетании с объединённым типом возврата, Results<Ok<OrderDto>, NotFound, ValidationProblem>, сама сигнатура перечисляет все возможные ответы, и генератор OpenAPI документирует их без дополнительных атрибутов.

Предпочитайте TypedResults с объединённым типом возврата. Его проверяет компилятор - вернуть статус, которого нет в объединении, это ошибка компиляции, - и описание API бесплатно становится точным. Ещё он упрощает модульное тестирование обработчиков, потому что результат - типизированный объект, на который можно делать утверждения.

Группы маршрутов и организация реального проекта#

MapGroup даёт набору эндпоинтов общий префикс и общую конфигурацию:

Program.cs
var api = app.MapGroup("/api");api.MapGroup("/orders")   .WithTags("Orders")   .RequireAuthorization()   .MapOrderEndpoints();api.MapGroup("/products")   .WithTags("Products")   .MapProductEndpoints();
Orders/OrderEndpoints.cs
public static class OrderEndpointsExtensions{    public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)    {        group.MapGet("/", OrderEndpoints.List);        group.MapGet("/{id:int}", OrderEndpoints.GetById);        group.MapPost("/", OrderEndpoints.Create);        group.MapDelete("/{id:int}", OrderEndpoints.Delete)             .RequireAuthorization("admin");        return group;    }}

Именно такая структура сохраняет minimal API читаемым при пятидесяти эндпоинтах: один файл на функциональность, метод расширения, который сопоставляет её маршруты, и Program.cs, сведённый к оглавлению. Всё, что применяется к группе, - авторизация, ограничение частоты, фильтры, теги OpenAPI - применяется к каждому эндпоинту в ней, а вложенные группы наследуют настройки родителей.

Тестируйте эндпоинты через HTTP, а не только вызывая обработчики напрямую, по крайней мере для важных путей. Пакет Microsoft.AspNetCore.Mvc.Testing запускает всё приложение в памяти через WebApplicationFactory<Program>, так что тест проходит через маршрутизацию, привязку, валидацию, фильтры и сериализацию ровно так, как это сделал бы клиент, не открывая порт. Подмените базу данных тестовым экземпляром в ConfigureWebHost фабрики, и несколько десятков таких тестов поймают большинство регрессий, которые вносит рефакторинг. Обработчики, написанные как статические методы, возвращающие TypedResults, можно также тестировать модульно напрямую, что быстрее для бизнес-правил.

Валидация#

Годами у minimal API не было встроенной валидации: data annotations на типе запроса просто игнорировались, и люди добавляли фильтр или библиотеку, чтобы их проверять. .NET 10 добавляет её во фреймворк:

csharp
builder.Services.AddValidation();public class CreateOrder{    [Required, EmailAddress] public string CustomerEmail { get; set; } = "";    [Range(1, 100)] public int Quantity { get; set; }    [StringLength(200)] public string? Note { get; set; }}

Когда это зарегистрировано, запрос, тело которого нарушает правило, получает ответ 400 с телом в формате problem details, где перечислено каждое неверное поле, а обработчик вообще не вызывается. Это работает для параметров, тел запросов и вложенных типов и управляется генератором исходного кода, так что не зависит от reflection во время выполнения. Сверьтесь с документацией для своей точной версии: детали менялись в ходе preview-цикла .NET 10.

На .NET 8 и 9 у вас три варианта. Фильтр эндпоинта, который выполняет Validator.TryValidateObject и возвращает TypedResults.ValidationProblem(...). Небольшая библиотека MiniValidation, которая делает то же самое с поддержкой вложенных объектов. Или FluentValidation, если вы хотите правила в коде, а не в атрибутах, - обычно её вызывают из фильтра. Все три держат валидацию вне самого обработчика.

Что бы вы ни выбрали, проверяйте на входе и дальше доверяйте типу. База данных всё равно должна обеспечивать то, что может, - столбцы not null, уникальные индексы, внешние ключи, - потому что валидация в API защищает одну точку входа, а база данных защищает их все.

Ошибки и problem details#

Необработанное исключение в minimal API в Production по умолчанию возвращает пустой 500. Включите стандартный формат ошибок:

csharp
builder.Services.AddProblemDetails();var app = builder.Build();app.UseExceptionHandler();app.UseStatusCodePages();

Теперь необработанные исключения дают ответ application/problem+json со статусом и заголовком, коды статуса без тела (404 от маршрутизации, 401 от аутентификации) тоже его получают, а формат совпадает с ошибками валидации выше. В Development ответ включает подробности исключения; в Production - нет, и это то, что нужно. Записывайте исключение в лог на сервере, возвращайте клиенту идентификатор корреляции, если позже нужно будет его найти, и никогда не кладите в ответ сообщения исключений от драйвера базы данных.

Для ожидаемых сбоев - отсутствующей записи, конфликта, бизнес-правила - возвращайте результат явно (TypedResults.NotFound(), TypedResults.Conflict(), TypedResults.Problem(...)), а не выбрасывайте исключение. Исключения дороги, и из-за них трудно следить за потоком управления; объединённый тип возврата делает ожидаемые исходы видимыми.

Распространённая золотая середина - собственный обработчик исключений для нескольких известных типов исключений: например, конфликт параллельного доступа из EF Core превращается в 409. Начиная с .NET 8 можно реализовать IExceptionHandler, зарегистрировать его через AddExceptionHandler<T>() и позволить ему писать ответ problem details для исключений, которые он узнаёт, передавая остальные поведению по умолчанию.

Документация OpenAPI#

Начиная с .NET 9, ASP.NET Core сам генерирует документы OpenAPI через пакет Microsoft.AspNetCore.OpenApi, а шаблоны больше не включают Swashbuckle.

csharp
builder.Services.AddOpenApi();var app = builder.Build();app.MapOpenApi();   // serves /openapi/v1.json

.NET 10 по умолчанию генерирует OpenAPI 3.1. Эндпоинты описываются по их маршруту, параметрам и типам возврата; добавьте .WithName("GetOrder"), .WithSummary(...) и .WithTags(...) для того, чего сигнатура сказать не может. TypedResults с объединёнными типами возврата документируют каждый ответ автоматически, и это самый сильный довод в их пользу.

Пакет производит документ, а не просмотрщик. Для интерактивной страницы добавьте его: Scalar (Scalar.AspNetCore, затем app.MapScalarApiReference()) или Swagger UI из пакета Swashbuckle.AspNetCore.SwaggerUI, направленный на /openapi/v1.json. Решите, должны ли документ и просмотрщик быть публичными. Для внутреннего API сопоставляйте их только в Development или прячьте за RequireAuthorization(); публичное описание каждого эндпоинта - подарок любому, кто прощупывает ваш API.

Фильтры, авторизация и ограничения частоты#

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

csharp
group.AddEndpointFilter(async (context, next) =>{    var started = Stopwatch.GetTimestamp();    var result = await next(context);    var ms = Stopwatch.GetElapsedTime(started).TotalMilliseconds;    context.HttpContext.Response.Headers["Server-Timing"] = $"app;dur={ms:F0}";    return result;});

Авторизация и ограничение частоты подключаются так же. RequireAuthorization() без аргумента требует аутентифицированного пользователя; с именем политики применяет эту политику. RequireRateLimiting("per-ip") применяет политику, зарегистрированную через AddRateLimiter. Ограничению частоты по IP нужен настоящий адрес клиента, а за прокси это означает правильно настроенные проксированные заголовки - как это сделать, показано в статье ASP.NET Core за обратным прокси, а cookie и JWT на стороне аутентификации сравниваются в статье основы аутентификации в ASP.NET Core.

Если API вызывает браузерное приложение с другого origin, ему нужен CORS. Зарегистрируйте именованную политику с точным списком разрешённых origin (AddCors с WithOrigins("https://app.example.com")), добавьте UseCors() в конвейер перед авторизацией и примените политику к группе через RequireCors("app"). Не сочетайте AllowAnyOrigin() с учётными данными - браузеры отвергают такую пару, и ошибка видна только в консоли браузера, но никогда в логе сервера. На preflight-запросы OPTIONS отвечает само CORS middleware, так что они никогда не доходят до ваших обработчиков, и отдельный маршрут для них не нужен.

Когда лучше использовать контроллеры#

Minimal API и контроллеры работают на одном конвейере, и в одном приложении могут быть и те и другие (AddControllers() плюс MapControllers()). Причины выбрать контроллеры для части приложения или для всего:

  • Большая существующая кодовая база или привычка команды. Переписывание работающих контроллеров мало что даёт.
  • Согласование содержимого. Контроллеры могут возвращать XML или другие форматы через форматтеры; minimal API ориентированы прежде всего на JSON.
  • Экосистемы фильтров MVC. Action-фильтры, result-фильтры и model binder-ы из библиотек, от которых вы уже зависите.
  • OData, которая исторически строилась вокруг контроллеров.
  • API, построенные на соглашениях, где маршрутизация атрибутами и наследование от базового контроллера убирают больше повторов, чем убрали бы группы маршрутов.

Производительность не является веским доводом ни в ту, ни в другую сторону. Minimal API делают меньше работы на запрос и меньше выделяют памяти, и это путь к Native AOT, но для API, который проводит время в базе данных, разницы видно не будет. Если вам важны время запуска и память, AOT описан в статье параметры dotnet publish.

Переход между ними не обязательно делать разом. Существующий API на контроллерах может добавлять новые функции как группы minimal API, пока старые контроллеры продолжают работать, и обратное столь же возможно. Таблица маршрутизации общая, так что правило только одно: два эндпоинта не должны претендовать на один и тот же маршрут и метод; если это случится, запрос упадёт с ошибкой неоднозначного совпадения, где названы оба, и это легко исправить. Команды, которые мигрируют так, обычно переводят по одной функциональности за раз, начиная с самой простой, и останавливаются, когда оставшиеся контроллеры не стоят усилий.

Деплой minimal API ничем не отличается от любого приложения ASP.NET Core: публикация, привязка Kestrel к выделенному порту на всех интерфейсах и домен на прокси. Полная последовательность - в статье деплой приложения ASP.NET Core с GitHub, а схема базы данных, на которой стоит большинство API, - в статье миграции EF Core в продакшене.

FAQ#

Minimal API - только для небольших проектов?

Нет. Название описывает количество церемоний, а не размер приложения. С группами маршрутов и одним файлом на функциональность minimal API остаётся организованным и при сотнях эндпоинтов.

Можно ли использовать внедрение зависимостей в обработчиках minimal API?

Да. Любой параметр, тип которого зарегистрирован в контейнере, разрешается на каждый запрос, включая scoped-сервисы. Конструктор не нужен.

Почему тело моего запроса всегда null?

JSON не соответствует типу параметра, заголовок Content-Type - не application/json, или метод - GET, для которого тело по умолчанию не привязывается. Посмотрите запрос в логе и сравните имена свойств; привязка нечувствительна к регистру, но написание всё равно важно.

Нужен ли мне ещё Swashbuckle?

Не для генерации документа - начиная с .NET 9 это делает Microsoft.AspNetCore.OpenApi. Пакет интерфейса нужен только для интерактивной страницы, и Scalar, и пакет Swagger UI работают со встроенным документом.

Как версионировать minimal API?

Проще всего - группа маршрутов на каждую версию, /api/v1 и /api/v2, каждая со своими эндпоинтами. Пакет Asp.Versioning.Http добавляет версионирование через заголовки и строку запроса, если это нужно.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000