Web API
Як будувати HTTP API на ASP.NET Core: контролери чи Minimal API, звідки беруться параметри і як вони перевіряються, як проєктувати ресурси й статус-коди, віддавати помилки, версіонувати й описувати API та тримати навантаження. Це ядро співбесіди на бекенд-позицію в .NET.
Перед цією темою: Хостинг і конвеєр, Впровадження залежностей.
Чим контролери відрізняються від Minimal API і що коли обирати?
Section titled “Чим контролери відрізняються від Minimal API і що коли обирати?”Що це
- Контролер — клас-нащадок
ControllerBaseз атрибутом[ApiController]. Кожен публічний метод-action обслуговує один ендпоінт, маршрут задають атрибути[Route]і[HttpGet]. - Minimal API — делегат, зареєстрований на маршрут у
Program.cs:app.MapGet("/products/{id}", (int id) => ...).
Як користуватися
- Контролери підключають двома рядками:
builder.Services.AddControllers()іapp.MapControllers(). - У Minimal API ендпоінти задають через
MapGet,MapPost,MapPut,MapDelete. Спільний префікс виносять у групу:var orders = app.MapGroup("/api/orders");. - Відповідь повертають через
TypedResults.Ok(...)абоTypedResults.NotFound(): тип результату видно в сигнатурі.
Типова помилка
Складати всі обробники Minimal API в один Program.cs на тисячу рядків. Ендпоінти групують за ресурсами в методи-розширення на кшталт MapOrders(this IEndpointRouteBuilder app).
Як влаштовано
- Обидва підходи реєструють ендпоінти в спільній маршрутизації:
MapControllers()будує їх з атрибутів,MapGet— з делегатів. - Ендпоінт контролера запускає MVC: створення контролера через DI, фільтри, прив’язку моделі, валідацію, action і форматування результату.
- Ендпоінт Minimal API — один
RequestDelegate: параметри читає код, побудований під час старту, а відповідь серіалізується в JSON.
Коли що обирати
| Потрібно | Підхід |
|---|---|
| Новий сервіс, мікросервіс, Native AOT | Minimal API |
Власні IModelBinder, IModelValidator, application parts, OData |
контролери |
Відповідь у XML чи іншому форматі за Accept |
контролери (форматери) |
Пастки
- Без структури Minimal API перетворюється на величезний
Program.cs. Група маршрутів задає спільний префікс, фільтри, авторизацію й метадані для набору ендпоінтів. Results.Ok(...)повертаєIResultі не повідомляє тип відповіді в опис API.TypedResultsіResults<Ok<T>, NotFound>перелічують усі варіанти відповіді просто в сигнатурі.- Фільтри MVC на Minimal API не діють: там свої фільтри ендпоінтів.
Правило
Новий API — Minimal API з групами за ресурсами й TypedResults. Контролери — коли без можливостей MVC не обійтися або коли це стандарт наявного коду.
Під капотом
- Обробник Minimal API перетворюється на
RequestDelegateодин раз під час старту. Зазвичай це робитьRequestDelegateFactoryчерез дерева виразів, а зPublishAot— Request Delegate Generator під час компіляції. - Action контролера на кожен запит проходить через
ControllerActionInvoker: створення контролера, ланцюжок фільтрів,ModelStateDictionary, вибір форматера. - Типи
TypedResultsреалізуютьIEndpointMetadataProvider, тому коди й типи відповідей потрапляють у метадані ендпоінта без атрибутів.
Ціна
- Накладні витрати MVC — алокації контекстів і фільтрів на кожен запит. Їх видно в бенчмарках, але зазвичай вони менші за один похід у базу.
- Native AOT працює лише з Minimal API і вимагає source-generated JSON (
JsonSerializerContext): рефлексію під час публікації відрізає trimming.
Компроміси
- Контролери дають єдиний стиль з коробки: фільтри, conventions,
[ApiController]. У Minimal API ту саму дисципліну задають групи, методи-розширення й фільтри ендпоінтів — це треба описати в правилах команди. - Підходи змішуються в одному застосунку: маршрутизація спільна, а
AddEndpointFilterпрацює і наMapControllers(). - Переписувати контролери заради швидкості рідко окупається: виграш — мікросекунди на запит.
Як перевірити в продакшені
Порівнюють не підходи, а свій ендпоінт з однаковою логікою: BenchmarkDotNet і метрика http.server.request.duration до й після переписування.
Підсумок: Minimal API — менше інфраструктури на запит і шлях до Native AOT, контролери — багатий розширюваний конвеєр MVC; обирають за можливостями, а не за модою.
using Microsoft.AspNetCore.Http.HttpResults;using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();var app = builder.Build();
// Minimal API: група зі спільним префіксом, усі варіанти відповіді — у сигнатуріvar products = app.MapGroup("/api/v2/products").WithTags("Products");products.MapGet("/{id:int}", Results<Ok<ProductDto>, NotFound> (int id) => id == 1 ? TypedResults.Ok(new ProductDto(1, "Laptop")) : TypedResults.NotFound());
app.MapControllers(); // маршрути контролерів — з атрибутівapp.Run();
// Той самий ендпоінт на контролері[ApiController][Route("api/v1/products")]public class ProductsController : ControllerBase{ [HttpGet("{id:int}")] public ActionResult<ProductDto> Get(int id) => id == 1 ? new ProductDto(1, "Laptop") : NotFound();}
public record ProductDto(int Id, string Name);- Як винести ендпоінти Minimal API з
Program.cs? — Методи-розширення надIEndpointRouteBuilderіMapGroupна кожен ресурс. - Навіщо
Results<Ok<T>, NotFound>, якщо єIResult? — Усі варіанти відповіді видно в сигнатурі, в описі API та в unit-тестах. - Чи працюють фільтри MVC у Minimal API? — Ні, там фільтри ендпоінтів (
AddEndpointFilter, .NET 7+). - Чи можна опублікувати контролери в Native AOT? — Ні, MVC з Native AOT несумісний, підтримується лише Minimal API.
- Чим
ControllerBaseвідрізняється відController? —Controllerдодає підтримку представлень; для API доситьControllerBase.
Як працюють прив’язка моделі та валідація?
Section titled “Як працюють прив’язка моделі та валідація?”Що це
- Прив’язка моделі (model binding) — фреймворк сам дістає значення із запиту й передає їх у параметри методу.
- Валідація — перевірка заповненої моделі за правилами, зазвичай за атрибутами з
System.ComponentModel.DataAnnotations.
Звідки беруться значення
| Атрибут | Джерело |
|---|---|
[FromRoute] |
сегмент шляху: /orders/{id} |
[FromQuery] |
рядок запиту: ?page=2 |
[FromHeader] |
заголовок запиту |
[FromBody] |
JSON-тіло запиту |
Як користуватися
- Повісьте атрибути на властивості DTO:
[Required],[StringLength(50)],[Range(1, 100)]. - У контролері з
[ApiController]невалідна модель одразу дає відповідь 400, перевірятиModelState.IsValidне потрібно. - У Minimal API на .NET 10 викличте
builder.Services.AddValidation().
Типова помилка
Чекати, що [Required] у int спіймає пропущене поле. Значущий тип без значення отримує 0, і атрибут вважає його заповненим. Потрібен int? з [Required] або [Range].
Як влаштовано
| Параметр | [ApiController] |
Minimal API |
|---|---|---|
| Ім’я збігається із сегментом маршруту | [FromRoute] |
з маршруту |
| Інший простий тип | [FromQuery] |
з рядка запиту |
| Тип зареєстровано в DI | [FromServices] |
з DI |
| Складний тип | [FromBody] |
з JSON-тіла |
IFormFile |
[FromForm] |
з форми |
Валідація
- У контролерах перевірка йде одразу після прив’язки, помилки накопичуються в
ModelState.[ApiController]додає фільтр, який у разі помилок повертає 400 зValidationProblemDetails— словником «поле → повідомлення». - Без
[ApiController]ModelState.IsValidперевіряють у кожному action вручну. - У Minimal API з .NET 10
AddValidation()перевіряє DataAnnotations таIValidatableObjectу параметрів і вкладених об’єктів і теж відповідає 400. Вимкнути перевірку для ендпоінта —DisableValidation().
Пастки
- Помилка розбору (
abcзамістьint) у Minimal API дає 400 ще до обробника й без списку помилок за полями. - У MVC з увімкненим nullable-контекстом ненульова посилальна властивість (
string Name) вважається[Required]. - Параметр із тіла в action один: тіло запиту читається один раз.
Правило
DataAnnotations — для форми даних: обов’язковість, довжини, діапазони. Міжпольові правила — IValidatableObject або FluentValidation, бізнес-перевірки — у сервісі.
Під капотом
- Порядок у MVC: resource-фільтри → прив’язка моделі (
IModelBinderна параметр, значення з value providers) → валідація (IObjectModelValidatorобходить граф) → action-фільтри. - Автоматичний 400 — action-фільтр
ModelStateInvalidFilterз дуже раннім порядком (-2000), тому до нього не доходять навіть ваші action-фільтри. - Minimal API будує код прив’язки один раз на ендпоінт. Прості типи розбираються через
TryParseабоIParsable<T>, власні — через статичнийBindAsync. - Валідацію .NET 10 будує source generator, і він бачить лише типи своєї збірки. Якщо ендпоінти лежать в іншій збірці,
AddValidation()викликають там.
Ціна
- Валідатор MVC обходить увесь граф моделі, зокрема колекції: великий JSON-масив у тілі помітно навантажує CPU.
[FromBody]читає тіло в пам’ять повністю; межу задаєMaxRequestBodySizeKestrel, за замовчуванням близько 28,6 МБ.
Компроміси
- DataAnnotations декларативні й потрапляють в опис API, але умовні правила на них незручні. FluentValidation гнучкіший, а його автоматичну інтеграцію з MVC автори більше не рекомендують — валідатор викликають явно або з фільтра.
- За замовчуванням помилки форми — 400. Порушення бізнес-правил часто віддають як 422 — це рішення фіксують у контракті API.
Як перевірити в продакшені
Автоматичні 400 не доходять до action і губляться з поля зору. Їх логують через InvalidModelStateResponseFactory і дивляться частку 400 у http.server.request.duration у розрізі http.route.
Підсумок: прив’язка й валідація — окремі етапи перед обробником; атрибути описують форму даних, а правила домену живуть у коді сервісу.
using System.ComponentModel.DataAnnotations;using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();builder.Services.AddValidation(); // .NET 10: валідація в Minimal APIvar app = builder.Build();
// customerId — з маршруту, requestId — із заголовка, order — з JSON-тілаapp.MapPost("/api/customers/{customerId:int}/orders", (int customerId, [FromHeader(Name = "X-Request-Id")] string? requestId, CreateOrderRequest order) => TypedResults.Created($"/api/customers/{customerId}/orders/1", order));// тіло { "sku": "", "quantity": 0 } → 400, обробник не викликається
app.MapControllers();app.Run();
[ApiController] // невалідні параметри → автоматичний 400[Route("api/products")]public class ProductsController : ControllerBase{ [HttpGet] // ?page=2&name=phone public IActionResult Search([FromQuery, Range(1, 1000)] int page = 1, [FromQuery] string? name = null) => Ok(new { page, name });}
public record CreateOrderRequest([Required, StringLength(50)] string Sku, [Range(1, 100)] int Quantity);- Як зібрати кілька параметрів із query в один об’єкт у Minimal API? —
[AsParameters](.NET 7+): властивості об’єкта прив’язуються як окремі параметри. - Чому
[Required]уintне спрацьовує? — Значущий тип завжди має значення0; потрібенint?. - Як вимкнути автоматичний 400 у
[ApiController]? —ApiBehaviorOptions.SuppressModelStateInvalidFilter = true. - Що буде в MVC, якщо в JSON немає ненульової властивості
string? — Помилка валідації: з nullable-контекстом вона вважається[Required]. - Де перевіряти, що товар існує? — У сервісі чи обробнику: це бізнес-правило, відповідь 404 або 409, а не 400.
Як спроєктувати REST API: ресурси, методи, статус-коди, ідемпотентність, пагінація?
Section titled “Як спроєктувати REST API: ресурси, методи, статус-коди, ідемпотентність, пагінація?”Що це
REST описує API як набір ресурсів. Ресурс має адресу, а операції над ним виражаються стандартними HTTP-методами.
Як користуватися
| Запит | Дія й відповідь |
|---|---|
GET /orders |
список замовлень, 200 |
GET /orders/42 |
одне замовлення, 200 або 404 |
POST /orders |
створити замовлення, 201 і заголовок Location |
PUT /orders/42 |
замінити повністю, 200 або 204 |
DELETE /orders/42 |
видалити, 204 |
Помилки клієнта — 4xx: 400 (невалідний запит), 401 (не автентифікований), 403 (немає прав), 404 (немає ресурсу), 409 (конфлікт). Помилки сервера — 5xx.
Типова помилка
Дієслова в URL (/getOrders, /createOrder) і відповідь 200 з { "error": "..." } усередині. Дію вже виражено методом, а помилку — статус-кодом.
Як влаштовано
| Метод | Безпечний | Ідемпотентний |
|---|---|---|
| GET, HEAD | так | так |
| PUT, DELETE | ні | так |
| POST | ні | ні |
| PATCH | ні | не гарантовано |
- Безпечний метод не змінює стан сервера.
- Ідемпотентний — повтор запиту залишає сервер у тому самому стані, що й один виклик. Повторний DELETE нічого не ламає, навіть якщо поверне 404.
Коли що обирати
- Повна заміна — PUT, часткова зміна — PATCH (JSON Merge Patch або JSON Patch).
- Дію поза CRUD, наприклад скасування замовлення, оформлюють підресурсом:
POST /orders/42/cancellation. - Довга операція — 202 Accepted і адреса, де перевірити її статус.
Пагінація
?offset=40&limit=20проста й дає номери сторінок, але повільнішає на далеких сторінках і пропускає рядки під час вставок.- Keyset:
?after=40&limit=20, деafter— ключ останнього рядка. Стабільна й швидка за індексом, але не вміє стрибати на довільну сторінку.
Пастки
- 401 — «хто ви?», 403 — «знаю, хто ви, але не можна».
- Без серверного максимуму запит з
limit=1000000покладе базу.
Правило
Ресурс — іменник, дія — метод, результат — код. Будь-який список — з лімітом на сервері та вказівником на наступну сторінку.
Під капотом
- Мережа ненадійна: якщо відповідь загубилася, клієнт не знає, чи виконався запит. Повтор PUT безпечний, повтор POST створює дубль.
Idempotency-Key— поширена практика (чернетка IETF, не RFC). Клієнт надсилає унікальний ключ, сервер зберігає результат першої обробки й на повтор із тим самим ключем повертає його без повторного виконання.- ETag — версія представлення ресурсу.
GETвіддаєETag, аPUTзIf-Matchпроходить, лише якщо версія не змінилася, інакше 412 Precondition Failed. Це оптимістичне блокування поверх HTTP; в EF Core йому відповідає concurrency token.
Ціна
- Ключі ідемпотентності потребують сховища з TTL, атомарного запису ключа разом з операцією й відповіді на «ключ ще в обробці», зазвичай 409.
OFFSET Nзмушує базу прочитати й викинути N рядків: час зростає з номером сторінки. ЗапитWHERE id > @after ORDER BY idз лімітом іде за індексом за однаковий час.COUNT(*)для «всього сторінок» на великих таблицях дорогий, його часто прибирають.
Компроміси
- Курсор роблять непрозорим, наприклад base64 від ключа сортування: реалізацію можна змінювати без зміни контракту.
- HATEOAS — посилання на можливі дії у відповіді — повна модель REST, але на практиці обходяться посиланням на наступну сторінку й машинозчитуваним описом API.
- Строгий REST не догма: пошук зі складними фільтрами допустимо оформити як
POST /orders/search.
Як перевірити в продакшені
Шукають дублі сутностей від повторів і стежать за часткою 409 та 412 за http.route. Повільні далекі сторінки видно в плані запиту й у журналі повільних запитів бази.
Підсумок: надійний API розрахований на повтори й конкуренцію: ідемпотентність для повторів, ETag для паралельних правок, keyset для великих колекцій.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();var db = new List<Order>(); // замість бази данихvar orders = app.MapGroup("/api/orders");
// GET /api/orders?after=40&limit=20 — keyset-пагінація за Idorders.MapGet("/", (int? after, int? limit) =>{ int size = Math.Clamp(limit ?? 20, 1, 100); // серверний максимум var items = db.Where(o => o.Id > (after ?? 0)).OrderBy(o => o.Id).Take(size).ToList(); return TypedResults.Ok(new Page<Order>(items, items.Count == size ? items[^1].Id : null));});
// POST створює ресурс: 201 і заголовок Locationorders.MapPost("/", (NewOrder dto) =>{ var order = new Order(db.Count + 1, dto.Sku, dto.Quantity); db.Add(order); return TypedResults.Created($"/api/orders/{order.Id}", order);});
// DELETE ідемпотентний: повторний виклик теж поверне 204orders.MapDelete("/{id:int}", (int id) =>{ db.RemoveAll(o => o.Id == id); return TypedResults.NoContent();});
app.Run();
record Order(int Id, string Sku, int Quantity);record NewOrder(string Sku, int Quantity);record Page<T>(List<T> Items, int? NextAfter);- Чим PUT відрізняється від PATCH? — PUT замінює ресурс повністю й ідемпотентний, PATCH змінює частину, і ідемпотентність не гарантована.
- Що повернути на повторний DELETE? — 204 або 404 — допустимі обидва; стан сервера не змінюється, це і є ідемпотентність.
- Як зробити POST безпечним для повторів? — Заголовок
Idempotency-Keyі збережений результат першої обробки. - Чому OFFSET-пагінація повільна? — База читає й відкидає всі пропущені рядки; keyset іде за індексом.
- Коли відповідати 202 Accepted? — Операцію прийнято, але виконається вона пізніше; у відповіді — адреса для перевірки статусу.
Як обробляти помилки в API: ProblemDetails, UseExceptionHandler і IExceptionHandler?
Section titled “Як обробляти помилки в API: ProblemDetails, UseExceptionHandler і IExceptionHandler?”Що це
ProblemDetails — стандартне тіло відповіді з помилкою (RFC 9457, раніше RFC 7807) з типом вмісту application/problem+json:
type— посилання-ідентифікатор виду помилки;title— короткий опис;status— HTTP-код;detail— подробиці цього випадку.
Навіщо
Клієнт розбирає всі помилки одним кодом, а не вгадує формат кожного ендпоінта.
Як користуватися
builder.Services.AddProblemDetails();app.UseExceptionHandler();— виняток перетворюється на 500 з ProblemDetails.app.UseStatusCodePages();— порожні відповіді 4xx і 5xx отримують тіло ProblemDetails.- Очікувані помилки повертайте явно:
TypedResults.NotFound(),TypedResults.Problem(...).
Типова помилка
Віддавати клієнту exception.ToString(). Stack trace розкриває будову сервісу й допомагає зловмиснику, а подробицям місце в лозі.
Як влаштовано
UseExceptionHandlerстоїть на початку конвеєра й обгортає решту middleware вtry/catch.- У разі винятку він очищає відповідь, якщо надсилання ще не почалося, і ставить код 500.
- Потім по черзі викликає зареєстровані
IExceptionHandler, доки один не повернеtrue. - Якщо ніхто не обробив, пише ProblemDetails через
IProblemDetailsServiceабо виконує запит заново за шляхом на кшталт/error.
Коли що обирати
| Ситуація | Інструмент |
|---|---|
| Очікуваний результат: немає ресурсу, конфлікт | TypedResults.NotFound(), TypedResults.Conflict() |
| Власний виняток → статус-код і тіло | IExceptionHandler (.NET 8+) |
| Просте зіставлення «тип → код» | ExceptionHandlerOptions.StatusCodeSelector (.NET 9+) |
| Спільні поля в усіх відповідях | ProblemDetailsOptions.CustomizeProblemDetails |
Пастки
IExceptionHandlerбезapp.UseExceptionHandler()ніколи не викликається.app.UseExceptionHandler()без аргументів і безAddProblemDetails()падає під час старту зInvalidOperationException.- Якщо відповідь уже почала надсилатися, наприклад під час стримінгу, код не змінити: з’єднання просто обірветься.
- У Development необроблений виняток показує Developer Exception Page зі стеком; у продакшені її бути не повинно.
Правило
Винятки — для неочікуваного, очікуване — результатом. Один формат помилок на весь API і traceId у кожній відповіді.
Під капотом
DefaultProblemDetailsWriterпише відповідь, лише якщо заголовокAcceptдопускаєapplication/jsonабоapplication/problem+json. ІнакшеTryWriteAsyncповернеfalse, і потрібна запасна відповідь.- Writer додає
traceIdзActivity.Current: це W3C trace id, той самий, що в логах і розподіленому трасуванні. - Контролери з
[ApiController]самі перетворюють результати з кодом від 400 без тіла, наприкладNotFound(), на ProblemDetails черезProblemDetailsFactory.
Ціна
- Виняток — захоплення стека й розмотування кадрів, на порядки дорожче за
return. Потік винятків на звичайних шляхах (валідація, «не знайдено») помітний у профілі CPU. - Тому доменні результати виражають типом результату:
Results<Ok<T>, NotFound, Conflict>або власнимResult.
Компроміси
- Із .NET 10 middleware не пише логи й метрики для винятків, оброблених
IExceptionHandler. Важливі для алертів помилки логують у самому обробнику або повертають стару поведінку:SuppressDiagnosticsCallback = _ => false. - Стабільний
type, наприклад URI вашої документації, — частина контракту: клієнти розгалужуються за ним, аtitleіdetailможна змінювати. - Деталі винятку у відповіді допустимі лише в Development.
Як перевірити в продакшені
- Метрика
aspnetcore.diagnostics.exceptions(.NET 8+) рахує винятки в middleware за типом; обробленіIExceptionHandlerіз .NET 10 за замовчуванням у неї не потрапляють. http.server.request.durationз атрибутомerror.typeпоказує, на якихhttp.routeзростає частка 5xx.- За
traceIdз відповіді клієнта помилку знаходять у логах і трасах.
Підсумок: ProblemDetails — контракт помилок: стабільний type для клієнта, traceId для оператора й жодних нутрощів назовні.
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddProblemDetails();builder.Services.AddExceptionHandler<NotFoundExceptionHandler>(); // .NET 8+var app = builder.Build();
app.UseExceptionHandler(); // виняток → 500 і ProblemDetails без stack traceapp.UseStatusCodePages(); // порожні 4xx і 5xx → ProblemDetails
app.MapGet("/api/orders/{id:int}", (int id) => id == 1 ? TypedResults.Ok(new { Id = 1 }) : throw new KeyNotFoundException($"Order {id} not found"));// /api/orders/2 → 404, у тілі: type, title, status, detail, traceId
app.MapGet("/api/crash", () => { throw new InvalidOperationException("Connection failed: password=secret"); });// → 500, тексту винятку у відповіді немає
app.Run();
sealed class NotFoundExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct) { if (exception is not KeyNotFoundException) return false; // не наш — далі ланцюжком context.Response.StatusCode = StatusCodes.Status404NotFound; return await problemDetails.TryWriteAsync(new() { HttpContext = context, Exception = exception, ProblemDetails = { Title = "Resource not found", Detail = exception.Message } }); }}- Чим
IExceptionHandlerкращий за власний middleware зtry/catch? — Вбудований вUseExceptionHandler: очищення відповіді, ланцюжок обробників, ProblemDetails і DI з коробки. - Що буде, якщо виняток стався після початку надсилання відповіді? — Код і заголовки не змінити; middleware прокине виняток, і з’єднання обірветься.
- Навіщо
UseStatusCodePages? — Порожні 4xx і 5xx, наприклад 404 маршрутизації, отримують тіло ProblemDetails. - Чому очікувані помилки не виражають винятками? — Винятки дорогі й ховають потік керування; краще
Results<...>або власний Result-тип. - Що змінилося в .NET 10 для оброблених винятків? — Вони більше не логуються за замовчуванням; повернути поведінку —
SuppressDiagnosticsCallback.
Чим фільтри відрізняються від middleware і які вони бувають?
Section titled “Чим фільтри відрізняються від middleware і які вони бувають?”Що це
Фільтр — клас або атрибут, який ASP.NET Core викликає навколо action контролера. Він може виконати код до action, після нього або взагалі не дати action виконатися.
Навіщо
Щоб прибрати повторюваний код з action: вимірювання часу, перевірку заголовка, аудит, єдиний формат відповіді.
Як користуватися
- Реалізуйте інтерфейс, наприклад
IAsyncActionFilterз методомOnActionExecutionAsync. - Підключіть фільтр глобально (
options.Filters.Add<T>()вAddControllers) або атрибутом на контролер чи action. - Фільтру із залежностями з DI потрібен
[ServiceFilter(typeof(...))]або[TypeFilter(typeof(...))].
Типова помилка
Писати у фільтрі те, що має працювати для всіх запитів, наприклад CORS. Фільтр не викличеться для статичних файлів, Minimal API і запитів, які не дійшли до action.
Як влаштовано
Порядок усередині MVC:
- Authorization — першими, можуть одразу повернути 401 або 403.
- Resource — обгортають усе інше й виконуються до прив’язки моделі.
- Прив’язка моделі й валідація.
- Action — до й після методу action, бачать аргументи й результат.
- Exception — ловлять винятки зі створення контролера, прив’язки, action-фільтрів і action.
- Result — до й після виконання
IActionResult.
Коли що обирати
| Задача | Де |
|---|---|
| Стиснення, CORS, логування HTTP | middleware |
| Перевірка аргументів, аудит action | action-фільтр або фільтр ендпоінта |
| Винятки одного контролера → особлива відповідь | exception-фільтр |
| Глобальна обробка помилок | UseExceptionHandler |
Пастки
- Глобальні фільтри обгортають фільтри контролера, а ті — фільтри action. Змінити порядок можна через
IOrderedFilter.Order. - Result-фільтри не викликаються, якщо authorization- або resource-фільтр перервав конвеєр. Для цього випадку є
IAlwaysRunResultFilter. - Екземпляр фільтра-атрибута перевикористовується між запитами: поле зі станом у ньому — гонка, а залежності в конструктор атрибута не передати.
Правило
Усе, що не залежить від обраного action, — у middleware. Усе, що працює з аргументами, ModelState або результатом action, — у фільтр.
Під капотом
- Ендпоінт контролера запускає
ControllerActionInvoker. Список фільтрів для action будується один раз і кешується, а на запит invoker проходить його як машину станів. - Фільтр ендпоінта отримує
EndpointFilterInvocationContextз аргументами обробника:GetArgument<T>(index). Код доnextвиконується в порядку реєстрації, післяnext— у зворотному. AddEndpointFilterFactoryотримуєMethodInfoобробника під час побудови ендпоінта й може не додавати фільтр, якщо сигнатура не підходить.
Ціна
- Кожен фільтр — зайвий виклик на запит, а
TypeFilterі transient-фільтр черезServiceFilterще й створюють об’єкт. Десятки глобальних фільтрів помітні на гарячих ендпоінтах. - Resource-фільтр стоїть до прив’язки моделі: він може відхилити запит, не читаючи тіла, і заощадити роботу.
Компроміси
- Exception-фільтр знає контролер і action, але не ловить помилки middleware, resource- і result-фільтрів та серіалізації відповіді. Глобальна сітка —
UseExceptionHandler. - У змішаному застосунку спільну логіку (аудит, перевірку версії клієнта) пишуть фільтром ендпоінта й вішають і на
MapGroup, і наMapControllers(). - Фільтрів не видно в
Program.cs: багато атрибутів на контролерах ховають поведінку. Глобальну реєстрацію в одному місці читати простіше.
Як перевірити в продакшені
На рівні логування Debug для Microsoft.AspNetCore.Mvc MVC пише план виконання фільтрів кожного типу. Час окремих фільтрів видно в трасі, якщо обгорнути їх в Activity.
Підсумок: фільтри дають контекст action ціною прив’язки до MVC; наскрізну логіку тримають у middleware, глобальні помилки — в UseExceptionHandler.
using System.Diagnostics;using Microsoft.AspNetCore.Mvc.Filters;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers(o => o.Filters.Add<TimingFilter>()); // глобальний action-фільтрvar app = builder.Build();
app.MapControllers();
// Minimal API: фільтр ендпоінта бачить аргументи обробникаapp.MapGet("/api/items/{id:int}", (int id) => TypedResults.Ok(id)) .AddEndpointFilter(async (context, next) => { int id = context.GetArgument<int>(0); if (id <= 0) return TypedResults.Problem("Id must be positive", statusCode: 400); // обробник не викликано return await next(context); });
app.Run();
// Action-фільтр: після прив'язки моделі, навколо методу actionpublic class TimingFilter(ILogger<TimingFilter> logger) : IAsyncActionFilter{ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var sw = Stopwatch.StartNew(); ActionExecutedContext executed = await next(); // виконується сам action logger.LogInformation("{Action}: {Ms} ms, failed: {Failed}", context.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds, executed.Exception is not null); }}- У якому порядку виконуються фільтри MVC? — Authorization → resource → прив’язка моделі → action → exception → result.
- Чи спіймає exception-фільтр помилку серіалізації відповіді? — Ні: винятків result-фільтрів і виконання результату він не бачить.
- Як впровадити залежність у фільтр-атрибут? —
[ServiceFilter]бере фільтр із DI,[TypeFilter]створює його черезObjectFactory. - Як перервати конвеєр у фільтрі? — Присвоїти
context.Resultі не викликатиnext. - Чи є фільтри в Minimal API? — Так,
AddEndpointFilterтаIEndpointFilterіз .NET 7; їх можна повісити на групу маршрутів.
Як версіонувати API та описувати його в OpenAPI?
Section titled “Як версіонувати API та описувати його в OpenAPI?”Що це
- Версія API — номер контракту. Клієнт обирає її в запиті, і сервер відповідає за правилами цієї версії.
- OpenAPI (раніше Swagger) — документ JSON або YAML зі списком шляхів, параметрів, тіл запитів, відповідей і кодів.
Навіщо
Мобільний застосунок старої версії не можна оновити миттєво. Поки він живе, /v1 має працювати як раніше, а нові клієнти переходять на /v2.
Як користуватися
- Додайте пакет
Microsoft.AspNetCore.OpenApi, викличтеbuilder.Services.AddOpenApi()іapp.MapOpenApi(). Документ з’явиться за адресою/openapi/v1.json. - Вбудованого UI немає: сторінку документації підключають окремо, наприклад Swagger UI або Scalar.
- Найпростіше версіонування — група маршрутів на версію:
app.MapGroup("/api/v1").
Типова помилка
Змінювати формат відповіді всередині наявної версії: перейменувати поле або зробити його обов’язковим. Старі клієнти перестають розбирати відповідь.
Як влаштовано
| Де передати версію | Приклад |
|---|---|
| Сегмент шляху | /api/v2/orders |
| Query-параметр | /api/orders?api-version=2.0 |
| Заголовок | X-Api-Version: 2.0 |
| Media type | Accept: application/json; v=2.0 |
- Asp.Versioning (
Asp.Versioning.Httpдля Minimal API,Asp.Versioning.Mvcдля контролерів) читає версію із запиту, обирає ендпоінт і з опцієюReportApiVersionsпише підтримувані версії в заголовокapi-supported-versions. Microsoft.AspNetCore.OpenApiбудує документ з метаданих ендпоінтів: маршруту, параметрів,TypedResults,Produces,WithSummary, а з .NET 10 — і XML-коментарів.
Коли що обирати
- Сегмент шляху наочний, легко маршрутизується на шлюзі й видний у логах — його обирають найчастіше.
- Query й заголовок залишають URL ресурсу незмінним, але версію легко забути передати, тому потрібна версія за замовчуванням.
Пастки
- Без налаштування всі версії потрапляють в один документ. Потрібен окремий документ на версію:
AddOpenApi("v2")іWithGroupName("v2")в ендпоінтів; Asp.Versioning 10 з пакетомAsp.Versioning.OpenApiробить це сам. WithOpenApi()у .NET 10 застарів (попередження ASPDEPR002): опис правлять трансформерами документа й операцій.- Документ у продакшені розкриває поверхню атаки, тому шаблон віддає його лише в Development.
Правило
Версію змінюють лише через несумісну зміну. Зворотно сумісне — нове необов’язкове поле, новий ендпоінт — додають у поточну версію.
Під капотом
- Asp.Versioning додає ендпоінтам метадані версій і свою політику зіставлення: ендпоінти з одним шаблоном різняться версією із запиту, а непідтримувана версія отримує помилку у форматі ProblemDetails.
Microsoft.AspNetCore.OpenApiчитає метадані з API Explorer. Із .NET 10 він працює на Microsoft.OpenApi 2.0: OpenAPI 3.1 зі схемами JSON Schema 2020-12 і YAML черезMapOpenApi("/openapi/{documentName}.yaml").- Пакет
Microsoft.Extensions.ApiDescription.ServerзOpenApiGenerateDocumentsOnBuildгенерує документ під час збирання, без розгорнутого сервісу.
Ціна
- Кожна жива версія — код, тести й моніторинг. Зазвичай підтримують поточну й попередню, решту виводять за графіком.
- Документ будується на запит до
/openapi/...; у продакшені його кешують (CacheOutput()) або віддають готовим файлом.
Компроміси
- Code-first (документ із коду) зручний одній .NET-команді. Contract-first (спочатку YAML, потім код) — коли контракт погоджують кілька команд; тоді згенерований документ звіряють з еталоном у CI.
- Застарівання оголошують заздалегідь: Asp.Versioning позначає версії як deprecated і публікує політику вимкнення, клієнт бачить її в заголовках.
- Одна версія на весь API простіша для клієнтів, окремі версії ресурсів дають менше дублювання коду.
Як перевірити в продакшені
- Рахуйте запити за версіями, наприклад за
http.routeіз сегментом шляху: без цих даних стару версію не вимкнути. - У CI порівнюйте OpenAPI-документ з минулим релізом утилітою на кшталт oasdiff і валіть збирання на несумісних змінах.
Підсумок: версіонування — політика життя контрактів, а OpenAPI дозволяє перевіряти її автоматично, а не на рев’ю.
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddOpenApi();var app = builder.Build();app.MapOpenApi(); // документ: GET /openapi/v1.json
// v1: старі клієнти отримують попередній контрактvar v1 = app.MapGroup("/api/v1/orders").WithTags("Orders");v1.MapGet("/{id:int}", (int id) => TypedResults.Ok(new OrderV1(id, 100m))) .WithSummary("Get order (v1)");
// v2: несумісна зміна — сума стала об'єктом із валютоюvar v2 = app.MapGroup("/api/v2/orders").WithTags("Orders");v2.MapGet("/{id:int}", Results<Ok<OrderV2>, NotFound> (int id) => id > 0 ? TypedResults.Ok(new OrderV2(id, new Money(100m, "EUR"))) : TypedResults.NotFound()) .WithSummary("Get order (v2)") .ProducesProblem(StatusCodes.Status500InternalServerError);
app.Run();
record OrderV1(int Id, decimal Total);record OrderV2(int Id, Money Total);record Money(decimal Amount, string Currency);Метадані WithTags, WithSummary, ProducesProblem і типи TypedResults потрапляють в OpenAPI-документ без атрибутів.
- Чи потрібна нова версія, якщо у відповідь додали поле? — Ні, це зворотно сумісно, якщо клієнти ігнорують незнайомі поля.
- Чим вбудований OpenAPI відрізняється від Swashbuckle? — Пакет Microsoft, у шаблонах із .NET 9, трансформери замість фільтрів, без вбудованого UI.
- Як отримати документ без запуску сервісу? —
Microsoft.Extensions.ApiDescription.ServerіOpenApiGenerateDocumentsOnBuildгенерують його під час збирання. - Як повідомити клієнтам, що версія застаріває? — Позначити її deprecated в Asp.Versioning, віддавати
DeprecationіSunset, назвати дату вимкнення. - Де версію зручніше розбирати на API Gateway? — У сегменті шляху: маршрутизація за префіксом без розбору заголовків.
Як зробити API швидким і стійким під навантаженням і як це виміряти?
Section titled “Як зробити API швидким і стійким під навантаженням і як це виміряти?”Що це
Швидкість API — час відповіді на один запит. Стійкість — здатність тримати багато запитів і не падати від піків.
Як користуватися
- Async до кінця. База й мережа — лише через
await:ToListAsync,SaveChangesAsync,GetAsyncуHttpClient. - Менше даних. Списки — посторінково з максимальним розміром сторінки; з бази — лише потрібні поля через
Selectу DTO. - Стиснення.
AddResponseCompression()зменшує JSON у кілька разів. - Захист.
AddRateLimiter()обмежує кількість запитів від клієнта,AddRequestTimeouts()скасовує надто довгі.
Типова помилка
Викликати .Result або .Wait() у задачі в обробнику. Потік пулу блокується, і під навантаженням сервіс зависає, хоча процесор вільний.
Як влаштовано
| Прийом | Що дає |
|---|---|
| Rate limiting (.NET 7+) | відмова зайвим запитам до обробки |
| Request timeouts (.NET 8+) | скасування RequestAborted за тайм-аутом, інакше відповідь 504 |
| Стиснення відповідей | менше трафіку ціною CPU (Brotli, Gzip) |
IAsyncEnumerable<T> у відповіді |
JSON-масив іде в міру читання, без буфера |
Коли що обирати
- Fixed window, sliding window і token bucket обмежують кількість запитів за період, concurrency limiter — кількість одночасних. Політику вішають через
RequireRateLimitingі ділять за користувачем або ключем API. - Тайм-аут — для ендпоінтів з непередбачувано довгою роботою: звіти, зовнішні виклики.
- Стиснення в застосунку — якщо перед ним немає проксі, який стискає сам.
Пастки
RejectionStatusCodeза замовчуванням — 503, а клієнту правильніше 429 Too Many Requests.- Тайм-аут лише скасовує токен: код, який не передає
CancellationTokenу базу, продовжить працювати. Під налагоджувачем тайм-аути не спрацьовують. EnableForHttpsу стиснення за замовчуванням вимкнено через атаки CRIME і BREACH: не стискайте відповіді, де секрети змішано з даними користувача.
Правило
Спочатку виміряйте, куди йде час: база, мережа, серіалізація. Оптимізуйте вузьке місце, а ліміти й тайм-аути ставте завжди.
Під капотом
- Коли обробники чекають на базу, зростають
http.server.active_requestsі черга пулу потоків, і p99 іде в секунди раніше, ніж завантажиться CPU. - Буферизована відповідь на десятки мегабайтів — це об’єкти в LOH і збирання Gen 2. Стримінг
IAsyncEnumerable<T>іAsAsyncEnumerable()в EF Core тримають у пам’яті елемент, а не весь список. - Синхронне введення-виведення з тілом запиту заборонене з ASP.NET Core 3.0 (
AllowSynchronousIO = false): воно блокувало б потік пулу. - HTTP/2 мультиплексує запити в одному з’єднанні, HTTP/3 прибирає блокування черги під час втрати пакетів. У Kestrel HTTP/3 вмикають явно (
Http1AndHttp2AndHttp3), і він вимагає TLS.
Ціна
- Rate limiter у пам’яті рахує запити одного екземпляра: на п’яти подах ліміт фактично вп’ятеро вищий. Спільний ліміт — на шлюзі або в розподіленому сховищі.
- Партиція за IP за проксі бачить адресу проксі, доки не налаштовано forwarded headers, і вразлива до підміни адрес.
- Стримінг фіксує статус 200 до кінця відповіді: помилка посередині дає обірваний JSON, а не ProblemDetails.
Компроміси
- Кеш — окремий шар: output caching для цілих відповідей і
HybridCacheдля даних розбирають у темі про кешування. - Рання відмова дешевша: 429 за мілісекунди краща за тайм-аут через 30 секунд, який клієнт повторить.
Як перевірити в продакшені
- Навантажувальний тест (k6, NBomber, bombardier) зі зростанням RPS до деградації: дивляться p95 і p99 та частку помилок, а не середнє.
dotnet-counters monitor --counters Microsoft.AspNetCore.Hosting,System.Runtime— час запитів, активні запити, черга пулу потоків, алокації.aspnetcore.rate_limiting.requestsпоказує відмови лімітера,kestrel.queued_connections— упор у прийом з’єднань.
Підсумок: стійкість — це шари захисту й вимірювання за хвостовими затримками, а не одна оптимізація.
using System.Runtime.CompilerServices;using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddRequestTimeouts();builder.Services.AddRateLimiter(o =>{ o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // за замовчуванням 503 o.AddPolicy("per-ip", ctx => RateLimitPartition.GetFixedWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromSeconds(1) }));});var app = builder.Build();app.UseRateLimiter();app.UseRequestTimeouts();
// Через 2 с скасовується RequestAborted; необроблене скасування → 504app.MapGet("/api/report", async (CancellationToken ct) =>{ await Task.Delay(TimeSpan.FromSeconds(5), ct); return TypedResults.Ok("done");}).WithRequestTimeout(TimeSpan.FromSeconds(2)).RequireRateLimiting("per-ip");
// Стримінг: елементи йдуть клієнту в міру готовності, без буфера на всю відповідьapp.MapGet("/api/events", (CancellationToken ct) => ReadEventsAsync(ct)).RequireRateLimiting("per-ip");
app.Run();
static async IAsyncEnumerable<EventDto> ReadEventsAsync([EnumeratorCancellation] CancellationToken ct){ for (int i = 0; i < 1_000; i++) { await Task.Delay(10, ct); // імітація читання з бази yield return new EventDto(i, DateTimeOffset.UtcNow); }}
record EventDto(int Id, DateTimeOffset At);- Який код поверне rate limiter за замовчуванням? — 503; зазвичай ставлять 429 і заголовок
Retry-After. - Чи зупинить request timeout запит до бази? — Лише якщо токен
RequestAbortedпередано у виклик; сам тайм-аут лише скасовує токен. - Чому лімітер у пам’яті не працює на кількох екземплярах? — Кожен рахує свої запити; спільний ліміт — на шлюзі або в розподіленому сховищі.
- Навіщо стримити
IAsyncEnumerable<T>? — Відповідь іде в міру читання, у пам’яті не весь список; але статус 200 зафіксовано до кінця. - Чому стиснення краще віддати проксі? — Менше CPU в застосунку, а серверні модулі стиснення швидші за middleware ASP.NET Core.