Minimal API

Маршрутизація в ASP.NET Core: Розширені можливості

У попередньому матеріалі ми дізналися, як створювати базові маршрути та приймати параметри. Однак реальні проєкти вимагають значно більшого контролю над тим, які запити має обробляти конкретна кінцева точка.

Маршрутизація в ASP.NET Core: Розширені можливості

У попередньому матеріалі ми дізналися, як створювати базові маршрути та приймати параметри. Однак реальні проєкти вимагають значно більшого контролю над тим, які запити має обробляти конкретна кінцева точка.

У цьому розділі ми розглянемо обмеження параметрів, перехоплення гнучких маршрутів (catch-all), отримання даних з рядка запиту (Query Strings), введення залежностей (Dependency Injection) безпосередньо в маршрути та взаємодію з Middleware.

Нагадування: Ми будемо багато працювати з кодом. Звертайте особливу увагу на "Анатомію коду" після кожного прикладу.

Обмеження маршрутів (Route Constraints)

Коли ми створюємо маршрут на кшталт /users/{id}, ми очікуємо, що id буде числом. Якщо користувач перейде за адресою /users/manager, фреймворк спробує передати "manager" в int id, що призведе до помилки конвертації (400 Bad Request).

Замість того, щоб ловити помилку конвертації, ми можемо сказати роутеру: "Цей маршрут працює лише якщо id є цілим числом". Це і є обмеження (Constraints).

Використання вбудованих обмежень

Обмеження додаються безпосередньо в шаблон маршруту після двокрапки :.

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

// Маршрут 1: Лише для числових ID
app.MapGet("/users/{id:int}", (int id) => $"Користувач з ID {id}");

// Маршрут 2: Будь-який інший текст (спрацює лише якщо Маршрут 1 не підійшов)
app.MapGet("/users/{name:alpha}", (string name) => $"Профіль: {name}");

app.Run();

Анатомія коду:

  • Якщо запит GET /users/5, спрацьовує Маршрут 1 (:int), і роутер знає, що 5 — це точно int.
  • Якщо запит GET /users/tom, Маршрут 1 "каже", що /tom не є числом, тому ігнорує його. Роутер йде далі й знаходить Маршрут 2 (:alpha — дозволяє лише літери від A до Z), який успішно обробляє запит.
  • Якщо запит GET /users/123tom — обидва маршрути відмовлять, і користувач отримає 404 Not Found.

Таблиця найпопулярніших обмежень:

ОбмеженняОписПриклад шаблону
intЦілі числа{id:int}
booltrue або false{active:bool}
datetimeДати{date:datetime}
alphaЛітери A-Z{name:alpha}
minlength(v)Мінімальна довжина{name:minlength(3)}
max(v)Максимальне значення{age:max(120)}

Комбінування обмежень

Обмеження можна об'єднувати (chaining) за допомогою тої самої двокрапки :. Роутер перевірятиме їх по черзі зліва направо.

Program.cs
// id: ціле число, мінімум 1, максимум 1000
app.MapGet("/products/{id:int:range(1,1000)}", (int id) =>
{
    return $"Продукт #{id} знайдено.";
});

// login: лише букви, від 4 до 15 символів
app.MapGet("/profile/{login:alpha:length(4,15)}", (string login) =>
{
    return $"Ласкаво просимо, {login}";
});

Регулярні вирази (Regex)

Якщо вбудованих обмежень замало, ви можете використовувати потужність регулярних виразів за допомогою :regex().

Program.cs
// slug для постів блогу: маленькі літери, цифри та дефіс.
app.MapGet("/posts/{slug:regex(^[a-z0-9-]+$)}", (string slug) =>
{
    return $"Ви читаєте статтю: {slug}";
});
Будьте обережні з регулярними виразами. Вони обробляються при кожному запиті. Якщо написати неефективний вираз (Catastrophic Backtracking), зловмисники зможуть "покласти" ваш сервер (ReDoS атака).

Кастомні обмеження (Custom Route Constraints)

Якщо у вас є бізнес-правило, яке важко описати існуючими обмеженнями (наприклад, перевірка чи слово знаходиться у списку "заборонених"), ви можете створити власне обмеження.

Крок 1: Створення класу обмеження

Вам потрібно створити клас, який реалізує інтерфейс IRouteConstraint.

Constraints/NotReservedWordConstraint.cs
using System.Text.RegularExpressions;

public class NotReservedWordConstraint : IRouteConstraint
{
    private static readonly string[] Reserved = { "admin", "root", "system" };

    public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,
                      RouteValueDictionary values, RouteDirection routeDirection)
    {
        // 1. Отримуємо значення, яке прийшло в URL
        if (values.TryGetValue(routeKey, out var value) && value != null)
        {
            var word = value.ToString()?.ToLower();

            // 2. Повертаємо true, якщо слово НЕ в списку зарезервованих (маршрут підходить)
            // Повертаємо false, якщо слово у списку (маршрут відмовляє)
            return !Reserved.Contains(word);
        }

        return false;
    }
}

Крок 2: Реєстрація обмеження

Тепер потрібно пояснити ASP.NET Core, як "називати" це обмеження в шаблоні URL. Це робиться при налаштуванні сервісів маршрутизації.

Program.cs
var builder = WebApplication.CreateBuilder(args);

// Реєструємо кастомне обмеження з ім'ям "notreserved"
builder.Services.Configure<RouteOptions>(options =>
{
    options.ConstraintMap.Add("notreserved", typeof(NotReservedWordConstraint));
});

var app = builder.Build();

// Використовуємо його!
app.MapGet("/signup/{username:notreserved}", (string username) =>
{
    return $"Користувача {username} успішно створено!";
});

app.Run();

Анатомія коду:

  • Якщо користувач зробить GET /signup/johndoe, обмеження перевірить масив Reserved, не знайде його هناك і поверне true. Виконається лямбда і поверне статус 200.
  • Якщо користувач зробить GET /signup/admin, обмеження знайде його в Reserved і поверне false. Маршрут вважатиметься невідповідним, і користувач отримає 404 Not Found.

Спеціальні (Catch-All) маршрути

Іноді ви не знаєте заздалегідь, скільки сегментів (/a/b/c/d) буде у вашому URL. Наприклад, ви хочете обробляти всі запити до файлів у вкладених папках. Для цього використовуються Catch-All параметри, які починаються з * або **.

Program.cs
// *path "збереже" в собі всю решту URL після /files/
app.MapGet("/files/{*path}", (string path) =>
{
    return $"Ви шукаєте файл за шляхом: {path}";
});

Анатомія коду:

  • Якщо запит: GET /files/images/2023/summer/sea.jpg
  • Значення змінної path буде: "images/2023/summer/sea.jpg" (без початкового слеша).

Різниця між * та **: У сучасному роутингу вони працюють майже ідентично в контексті отримання значення, проте \*_використовується як інструмент сумісності, якщо ви також працюєте з компонентами, які генерують URL (наприклад, Razor Pages). За замовчуванням достатньо використовувати просто_.

Параметри рядка запиту (Query Strings)

Ми навчилися отримувати дані з URL за допомогою Route Parameters (шляху /{id}). Але є другий спосіб передачі даних — Query Strings, тобто все, що йде після знаку питання ? в URL (/search?q=apple&sort=asc&page=2).

У Minimal APIs вам нічого не потрібно робити, щоб їх отримати. Якщо ім'я параметра делегата збігається з іменем в Query String, воно підставиться автоматично!

Program.cs
var app = WebApplication.Create();

// URL: /search?query=laptop&page=2
app.MapGet("/search", (string query, int? page) =>
{
    int currentPage = page ?? 1; // Якщо page немає, використовуємо 1
    return $"Пошук: {query}, Сторінка: {currentPage}";
});

app.Run();

Групування параметрів [AsParameters] (ASP.NET Core 7+)

Якщо у вас є endpoint, який приймає 5-6 параметрів (id, page, sort, order, filter), сигнатура методу стає дуже довгою. Ви можете згрупувати їх в об'єкт за допомогою спеціального атрибута [AsParameters].

Program.cs
// 1. Створюємо рекорд для наших параметрів
public record SearchRequest(string Query, int Page = 1, string Sort = "desc");

var app = WebApplication.Create();

// 2. Вказуємо фреймворку "розібрати" цей об'єкт
app.MapGet("/api/search", ([AsParameters] SearchRequest req) =>
{
    return $"Шукаємо: {req.Query}, Сторінка: {req.Page}, Сортування: {req.Sort}";
});

app.Run();

Анатомія коду:

  • При запиті GET /api/search?query=phone&sort=asc, фреймворк автоматично створить об'єкт SearchRequest, заповнивши Query = "phone", Sort = "asc", а Page залишиться дефолтним = 1.

Dependency Injection (Введення залежностей) прямо в роути

Однією з найкрутіших фішок Minimal APIs є те, що параметри вашого маршрутного делегата можуть мати різне походження:

  • Вони можуть прийти з URL-шляху (Route Data)
  • Вони можуть прийти з URL-фільтрів (Query String)
  • Вони можуть прийти з Тіла запиту (Request Body)
  • Або вони можуть прийти з вашого DI контейнера (Services)!

ASP.NET розумний. Якщо тип вашого параметра не є простим (наприклад, це не int чи string), він сам перевірить, чи зареєстрований цей сервіс в контейнері, і якщо так — створить його та інжектує.

Program.cs
var builder = WebApplication.CreateBuilder(args);

// 1. Створюємо сервіс та додаємо його в DI
builder.Services.AddSingleton<ITimeService, SystemTimeService>();

var app = builder.Build();

// 2. Просто додаємо його як аргумент до лямбди!
app.MapGet("/time", (ITimeService time) =>
{
    return $"Поточний час: {time.Now.ToShortTimeString()}";
});

app.Run();

// --------- Код сервісу ---------
interface ITimeService { DateTime Now { get; } }
class SystemTimeService  : ITimeService { public DateTime Now => DateTime.Now; }

Анатомія коду:

  • У рядку app.MapGet ми передаємо інтерфейс ITimeService. Роутер не може знайти це в URL (бо це не число і не рядок). Тоді він дивиться у builder.Services. Знаходить там SystemTimeService і передає його всередину функції!
  • Мінімалізм і краса: ніяких конструкторів і громіздких класів контролерів.
Якщо виникає конфлікт імен (наприклад, у вас є Query параметр з іменем типу сервісу), ви можете явно сказати фреймворку: "Бери це саме з сервісів" за допомогою атрибуту [FromServices]. app.MapGet("/a", ([FromServices] ITimeService time) => ...)

Спеціальні маршрути: взаємодія з Middleware

У великих додатках маршрути часто працюють разом з Middleware. Наприклад, ви можете хотіти, щоб певний Middleware перехоплював помилку, коли маршрут не знайдено (404), замість стандартної порожньої сторінки.

Program.cs
var app = WebApplication.Create();

// 1. Middleware для обробки 404 (використовуємо Use)
app.Use(async (context, next) =>
{
    // Відпускаємо запит далі по конвеєру
    await next();

    // Якщо конвеєр повернув 404 (маршрут не підійшов)
    if (context.Response.StatusCode == 404)
    {
        // Перехоплюємо і повертаємо кастомну відповідь
        await context.Response.WriteAsync("На жаль, такої сторінки не існує :(");
    }
});

// 2. Звичайні маршрути
app.MapGet("/", () => "Welcome");
app.MapGet("/about", () => "About us");

app.Run();

Анатомія коду:

  • Запит іде спочатку в app.Use. Там ми кажемо await next().
  • Запит іде в роутинг. Якщо ми зайшли на /about, роутинг знайде маршрут і запише відповідь "About us". Статус код стане 200. Ми повертаємось в app.Use, StatusCode == 200, отже блок if не виконується.
  • Якщо ми зайшли на /secret, роутинг не знайде маршрут. Статус код стане 404. Ми повертаємось в app.Use, блок if спрацьовує, і ми перезаписуємо відповідь красивим повідомленням.

Практичні завдання

Виправлення помилок маршрутизації Уявіть, що у вас є код: app.MapGet("/user/{id}", (string id) => id); Змініть його так, щоб він приймав ID лише у форматі guid (глобальний ідентифікатор). Коли користувач намагатиметься передати текст або число, він повинен автоматично отримувати 404.
Copyright © 2026