Перейти до вмісту

Web API

Як будувати HTTP API на ASP.NET Core: контролери чи Minimal API, звідки беруться параметри і як вони перевіряються, як проєктувати ресурси й статус-коди, віддавати помилки, версіонувати й описувати API та тримати навантаження. Це ядро співбесіди на бекенд-позицію в .NET.

Перед цією темою: Хостинг і конвеєр, Впровадження залежностей.

Junior+

Чим контролери відрізняються від 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).

Як влаштовано

  1. Обидва підходи реєструють ендпоінти в спільній маршрутизації: MapControllers() будує їх з атрибутів, MapGet — з делегатів.
  2. Ендпоінт контролера запускає MVC: створення контролера через DI, фільтри, прив’язку моделі, валідацію, action і форматування результату.
  3. Ендпоінт 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);
Junior+

Як працюють прив’язка моделі та валідація?

Section titled “Як працюють прив’язка моделі та валідація?”
Відповідь

Що це

  • Прив’язка моделі (model binding) — фреймворк сам дістає значення із запиту й передає їх у параметри методу.
  • Валідація — перевірка заповненої моделі за правилами, зазвичай за атрибутами з System.ComponentModel.DataAnnotations.

Звідки беруться значення

Атрибут Джерело
[FromRoute] сегмент шляху: /orders/{id}
[FromQuery] рядок запиту: ?page=2
[FromHeader] заголовок запиту
[FromBody] JSON-тіло запиту

Як користуватися

  1. Повісьте атрибути на властивості DTO: [Required], [StringLength(50)], [Range(1, 100)].
  2. У контролері з [ApiController] невалідна модель одразу дає відповідь 400, перевіряти ModelState.IsValid не потрібно.
  3. У 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] читає тіло в пам’ять повністю; межу задає MaxRequestBodySize Kestrel, за замовчуванням близько 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 API
var 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);
Middle+

Як спроєктувати 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-пагінація за Id
orders.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 і заголовок Location
orders.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 ідемпотентний: повторний виклик теж поверне 204
orders.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);
Middle+

Як обробляти помилки в API: ProblemDetails, UseExceptionHandler і IExceptionHandler?

Section titled “Як обробляти помилки в API: ProblemDetails, UseExceptionHandler і IExceptionHandler?”
Відповідь

Що це

ProblemDetails — стандартне тіло відповіді з помилкою (RFC 9457, раніше RFC 7807) з типом вмісту application/problem+json:

  • type — посилання-ідентифікатор виду помилки;
  • title — короткий опис;
  • status — HTTP-код;
  • detail — подробиці цього випадку.

Навіщо

Клієнт розбирає всі помилки одним кодом, а не вгадує формат кожного ендпоінта.

Як користуватися

  1. builder.Services.AddProblemDetails();
  2. app.UseExceptionHandler(); — виняток перетворюється на 500 з ProblemDetails.
  3. app.UseStatusCodePages(); — порожні відповіді 4xx і 5xx отримують тіло ProblemDetails.
  4. Очікувані помилки повертайте явно: TypedResults.NotFound(), TypedResults.Problem(...).

Типова помилка

Віддавати клієнту exception.ToString(). Stack trace розкриває будову сервісу й допомагає зловмиснику, а подробицям місце в лозі.

Як влаштовано

  1. UseExceptionHandler стоїть на початку конвеєра й обгортає решту middleware в try/catch.
  2. У разі винятку він очищає відповідь, якщо надсилання ще не почалося, і ставить код 500.
  3. Потім по черзі викликає зареєстровані IExceptionHandler, доки один не поверне true.
  4. Якщо ніхто не обробив, пише 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 trace
app.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 }
});
}
}
Middle+

Чим фільтри відрізняються від middleware і які вони бувають?

Section titled “Чим фільтри відрізняються від middleware і які вони бувають?”
Відповідь

Що це

Фільтр — клас або атрибут, який ASP.NET Core викликає навколо action контролера. Він може виконати код до action, після нього або взагалі не дати action виконатися.

Навіщо

Щоб прибрати повторюваний код з action: вимірювання часу, перевірку заголовка, аудит, єдиний формат відповіді.

Як користуватися

  1. Реалізуйте інтерфейс, наприклад IAsyncActionFilter з методом OnActionExecutionAsync.
  2. Підключіть фільтр глобально (options.Filters.Add<T>() в AddControllers) або атрибутом на контролер чи action.
  3. Фільтру із залежностями з DI потрібен [ServiceFilter(typeof(...))] або [TypeFilter(typeof(...))].

Типова помилка

Писати у фільтрі те, що має працювати для всіх запитів, наприклад CORS. Фільтр не викличеться для статичних файлів, Minimal API і запитів, які не дійшли до action.

Як влаштовано

Порядок усередині MVC:

  1. Authorization — першими, можуть одразу повернути 401 або 403.
  2. Resource — обгортають усе інше й виконуються до прив’язки моделі.
  3. Прив’язка моделі й валідація.
  4. Action — до й після методу action, бачать аргументи й результат.
  5. Exception — ловлять винятки зі створення контролера, прив’язки, action-фільтрів і action.
  6. 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-фільтр: після прив'язки моделі, навколо методу action
public 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);
}
}
Middle+

Як версіонувати API та описувати його в OpenAPI?

Section titled “Як версіонувати API та описувати його в OpenAPI?”
Відповідь

Що це

  • Версія API — номер контракту. Клієнт обирає її в запиті, і сервер відповідає за правилами цієї версії.
  • OpenAPI (раніше Swagger) — документ JSON або YAML зі списком шляхів, параметрів, тіл запитів, відповідей і кодів.

Навіщо

Мобільний застосунок старої версії не можна оновити миттєво. Поки він живе, /v1 має працювати як раніше, а нові клієнти переходять на /v2.

Як користуватися

  1. Додайте пакет Microsoft.AspNetCore.OpenApi, викличте builder.Services.AddOpenApi() і app.MapOpenApi(). Документ з’явиться за адресою /openapi/v1.json.
  2. Вбудованого UI немає: сторінку документації підключають окремо, наприклад Swagger UI або Scalar.
  3. Найпростіше версіонування — група маршрутів на версію: 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 дозволяє перевіряти її автоматично, а не на рев’ю.

Приклад
Microsoft.AspNetCore.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-документ без атрибутів.

Senior+

Як зробити 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 секунд, який клієнт повторить.

Як перевірити в продакшені

  1. Навантажувальний тест (k6, NBomber, bombardier) зі зростанням RPS до деградації: дивляться p95 і p99 та частку помилок, а не середнє.
  2. dotnet-counters monitor --counters Microsoft.AspNetCore.Hosting,System.Runtime — час запитів, активні запити, черга пулу потоків, алокації.
  3. 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; необроблене скасування → 504
app.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);