Пропустити навігацію EPAM

Мінімальний код, максимальний контроль: фільтри запитів та відображення параметрів у ASP.NET Core 7

Лайфхаки
  • .NET

Minimal API в ASP.NET Core дозволяють нам створювати REST API кінцеві точки без необхідності визначення контролерів. Починаючи з .NET 6, цей підхід значно спрощує розробку вебсервісів, дозволяючи уникнути зайвої багатослівності класичних вебконтролерів. Однією з головних переваг використання Minimal API є те, що вони працюють швидше порівняно з традиційними контролерами.

У .NET 7 з'явилися нові захопливі можливості для Minimal API. Перш за все, ми отримали підтримку прив'язки масивів та StringValues з URL-параметрів запиту (наприклад, GET /strings?values=one&values=two&values=three або альтернативно GET /strings/one,two,three). Крім того, відображення параметрів у Minimal API ASP.NET Core 7 спрощує процес обробки HTTP-запитів і дозволяє розробникам зосередитися на побудові логіки своїх кінцевих точок API. Цей підхід пропонує кілька типів прив'язки параметрів, включаючи FromQuery, FromRoute, FromHeader та FromBody, і може бути особливо корисним у сценаріях перевірки даних, забезпечуючи цілісність даних як від клієнта, так і до клієнта.

У цій статті ми розглянемо основи Minimal API у .NET 7, дослідимо фільтри запитів через AddEndpointFilter, детально вивчимо відображення параметрів та розглянемо розширені сценарії використання, як-от робота з файлами та валідація.

ПРИЄДНУЙСЯ ДО НАШОЇ КОМАНДИ

Основи Minimal API у .NET 7

У .NET 7 архітектура Minimal API стала ще потужнішою, пропонуючи спрощений підхід для створення швидких HTTP API з мінімальними залежностями. Ця архітектура ідеально підходить для мікросервісів та додатків, які потребують лише необхідного мінімуму файлів, функцій та залежностей.

MapGet(), MapPost() і структура Program.cs

Основа будь-якого Minimal API додатку — файл Program.cs із характерною структурою.

Розглянемо базовий приклад:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

Цей простий код створює повноцінний API з кінцевою точкою, яка повертає текст "Hello World!". Налаштований WebApplication підтримує методи Map{Verb}, де {Verb} — це HTTP-метод у camelCase, наприклад MapGet, MapPost, MapPut або MapDelete:

app.MapGet("/", () => "Це GET запит");

app.MapPost("/", () => "Це POST запит");

app.MapPut("/", () => "Це PUT запит");

app.MapDelete("/", () => "Це DELETE запит");

Делегати, які передаються цим методам, називаються «обробниками маршрутів» (route handlers). Вони виконуються, коли маршрут відповідає запиту.

Обробники можуть бути представлені у різних формах:

  • лямбда-виразами;
  • локальними функціями;
  • методами екземпляра класу;
  • статичними методами.

Важливо зазначити, що обробники маршрутів можуть бути як синхронними, так і асинхронними.

Minimal API також чудово працює з параметрами маршруту:

 

app.MapGet("/users/{userId}/books/{bookId}", (int userId, int bookId) =>

    $"ID користувача: {userId}, ID книги: {bookId}");

Порівняння з контролерами Web API

Традиційний підхід із вебконтролерами передбачає створення класів, що успадковують ControllerBase.

Наприклад:

[ApiController]

[Route("[controller]")]

public class HelloController : ControllerBase

{

    private readonly IHelloService _helloService;

   

    public HelloController(IHelloService helloService)

    {

        _helloService = helloService;

    }

   

    public IActionResult Get()

    {

        return Ok(_helloService.FetchHello());

    }

}

Натомість аналогічний функціонал із Minimal API виглядає так:

app.MapGet("/hello", (IHelloService helloService) => helloService.FetchHello());

Основні відмінності між Minimal API та контролерами:

  • у Minimal API немає класу контролера як контейнера для кінцевих точок;
  • маршрути та HTTP-дієслова визначаються явно та безпосередньо;
  • синтаксис набагато лаконічніший, що робить код більш читабельним.

Переваги меншого коду і швидшого запуску

Minimal API пропонує кілька суттєвих переваг порівняно з традиційним підходом:

  • зменшення загального розміру та витрат ресурсів додатка, що призводить до швидшого запуску та покращеної продуктивності;
  • спрощений синтаксис, який дозволяє розробникам визначати маршрути, обробляти запити та повертати відповіді з мінімальними церемоніями;
  • покращена читабельність коду завдяки меншій кількості шаблонного коду;
  • менша кількість залежностей та конфігурацій для управління, що дозволяє розробникам зосередитися на основній функціональності;
  • видалення непотрібних абстракцій та зменшення шарів проміжного програмного забезпечення, що призводить до підвищення продуктивності.

У порівняльних тестах було виявлено, що Minimal API працює приблизно на 40% швидше під час повернення простої відповіді зі статусом 200, а споживання пам'яті при цьому приблизно на 15 МБ менше. Крім того, при використанні AOT-компіляції (Ahead-of-Time) у .NET 8, запуск програми з Minimal API може бути прискорений до 7 разів у порівнянні з традиційним підходом.

Для організації складніших API, Minimal API підтримує групування кінцевих точок за допомогою методу MapGroup:

var todosApi = app.MapGroup("/todos");

todosApi.MapGet("/", () => /* отримати всі справи */);

todosApi.MapGet("/{id}", (int id) => /* отримати справу за id */);

Це дозволяє об'єднувати пов'язані кінцеві точки та застосовувати до них спільні налаштування, такі як авторизація або документація API.

Хоча Minimal API найкраще підходять для простих сценаріїв і невеликих проєктів, вони можуть масштабуватися для підтримки складніших вимог завдяки можливості організації коду у вигляді розширень:

public static class TodoEndpoints

{

    public static void Map(WebApplication app)

    {

        app.MapGet("/", async context =>

        {

            // Отримати всі справи

            await context.Response.WriteAsJsonAsync(new { Message = "All todo items" });

        });

    }

}

Таким чином, Minimal API дозволяють швидко розпочати розробку з мінімальною кількістю коду, зберігаючи при цьому можливість розширення та налаштування в міру зростання вашого API.

Фільтри запитів через AddEndpointFilter

Фільтри запитів у Minimal API з'явились у .NET 7 і стали потужним інструментом для застосування додаткових етапів обробки вхідних запитів. Вони дозволяють нам виконувати код до і після виконання кінцевої точки, а також перевіряти та змінювати параметри під час виклику обробника маршруту. Фільтри особливо корисні для валідації запитів, логування та коротких замикань.

AddEndpointFilter() з делегатом

Найпростіший спосіб додати фільтр до кінцевої точки — викликати метод розширення AddEndpointFilter(). Цей метод реєструє фільтр маршруту і приймає делегат, який містить логіку фільтрації:

app.MapGet("/colorSelector/{color}", ColorName)

    .AddEndpointFilter(async (invocationContext, next) => {

        var color = invocationContext.GetArgument<string>(0);

        if (color == "Red") {

            return Results.Problem("Red not allowed!");

        }

        return await next(invocationContext);

    });

У цьому прикладі ми створюємо кінцеву точку, яка повертає назву кольору, проте якщо колір «Red», запит блокується і повертається помилка. Важливо відзначити, що делегат фільтра приймає два параметри: контекст виклику та посилання на наступний крок у ланцюжку обробки.

Контекст запиту: EndpointFilterInvocationContext

Об'єкт EndpointFilterInvocationContext — це серце фільтрів у Minimal API. Він надає доступ до:

  • HttpContext поточного запиту через властивість HttpContext;
  • списку аргументів, переданих обробнику, через властивість Arguments;
  • зручного методу GetArgument<T>(int) для отримання типізованих аргументів за їхньою позицією.

Наприклад:

var httpContext = invocationContext.HttpContext;

var requestHeaders = httpContext.Request.Headers;

var firstParameter = invocationContext.GetArgument<string>(0);

Через цей контекст ми можемо отримати доступ до всіх даних запиту, включаючи заголовки, параметри маршруту, параметри запиту та навіть сервіси через RequestServices. Це дозволяє реалізовувати комплексну логіку фільтрації, яка залежить від різних аспектів запиту.

Коротке замикання запиту через Results.Problem()

Одна з найпотужніших функцій фільтрів — можливість «короткого замикання» ланцюга обробки запиту.

Якщо фільтр виявляє, що запит не повинен досягти кінцевої точки, він може перервати подальшу обробку:

 

.AddEndpointFilter(async (efiContext, next) => {

    var todo = efiContext.GetArgument<Todo>(0);

    var validationError = Utilities.IsValid(todo!);

    if (!string.IsNullOrEmpty(validationError)) {

        return Results.Problem(validationError);

    }

    return await next(efiContext);

});

Для короткого замикання фільтр має повернути об'єкт IResult, який конвеєр ASP.NET Core може обробити. Статичний клас Results надає багато методів для створення стандартних відповідей, як-от Problem(), NotFound(), BadRequest() тощо. При короткому замиканні виклик делегата next пропускається, а відповідь одразу повертається клієнту.

Фільтри до і після виклику next()

Коли ми додаємо кілька викликів AddEndpointFilter() до одного обробника, фільтри утворюють послідовність, яка виконується за певними правилами:

  • код до виклику next(invocationContext) виконується у порядку First In, First Out (FIFO) – перший доданий, перший виконаний;
  • код після виклику next(invocationContext) виконується у порядку First In, Last Out (FILO) – перший доданий, останній виконаний.

Розглянемо приклад з кількома фільтрами:

 

app.MapGet("/", () => "Приклад кількох фільтрів")

    .AddEndpointFilter(async (context, next) => {

        app.Logger.LogInformation("Перед першим фільтром");

        var result = await next(context);

        app.Logger.LogInformation("Після першого фільтру");

        return result;

    })

    .AddEndpointFilter(async (context, next) => {

        app.Logger.LogInformation("Перед другим фільтром");

        var result = await next(context);

        app.Logger.LogInformation("Після другого фільтру");

        return result;

    });

При виконанні запиту логи з'являться в такому порядку:

  1. «Перед першим фільтром»
  2. «Перед другим фільтром»
  3. [Виконання кінцевої точки]
  4. «Після другого фільтру»
  5. «Після першого фільтру»

Крім використання делегатів, фільтри можна реалізувати як класи, що впроваджують інтерфейс IEndpointFilter.

Це дозволяє використовувати залежності з DI:

public class TodoIsValidFilter : IEndpointFilter

{

    private readonly ILogger _logger;

   

    public TodoIsValidFilter(ILoggerFactory loggerFactory)

    {

        _logger = loggerFactory.CreateLogger<TodoIsValidFilter>();

    }

   

    public async ValueTask<object?> InvokeAsync(

        EndpointFilterInvocationContext context,

        EndpointFilterDelegate next)

    {

        var todo = context.GetArgument<Todo>(0);

        var error = Utilities.IsValid(todo!);

        if (!string.IsNullOrEmpty(error)) {

            _logger.LogWarning(error);

            return Results.Problem(error);

        }

        return await next(context);

    }

}

 

Такий підхід робить фільтри повторно використовуваними та легкими для тестування. Хоча фільтри можуть отримувати залежності з DI, варто зазначити, що самі фільтри не можуть бути отримані з DI.

Відображення параметрів у Minimal API

Розширені сценарії: робота з файлами та валідація

Робота з файлами та перевірка валідності даних є важливими аспектами розробки будь-якого вебдодатку. Minimal API у .NET 7 пропонує елегантні підходи до цих сценаріїв, дозволяючи вирішувати складні завдання мінімальною кількістю коду. Розглянемо ці можливості детальніше.

Завантаження файлів через IFormFile

Для обробки файлів у Minimal API використовується інтерфейс IFormFile, який надає зручний доступ до даних завантажених файлів. Створення кінцевої точки для завантаження файлів можна реалізувати кількома способами.

Найпростіший підхід — використання IFormFile як параметра кінцевої точки:

app.MapPost("/upload", async Task<IResult>(IFormFile request) =>

{

    if (request.Length == 0)

        return Results.BadRequest();

       

    await using var stream = request.OpenReadStream();

    var reader = new StreamReader(stream);

    var text = await reader.ReadToEndAsync();

   

    return Results.Ok(text);

});

Однак цей підхід має певні обмеження. Річ у тім, що Minimal API за замовчуванням намагається прив'язувати атрибути з припущенням, що вміст є JSON. Через це запити з мультимедійними даними можуть повертати помилку 415 (Unsupported Media Type).

Для надійнішої роботи з файлами краще отримувати доступ до HttpRequest та працювати з формою безпосередньо:

app.MapPost("/upload", async Task<IResult>(HttpRequest request) =>

{

    if (!request.HasFormContentType)

        return Results.BadRequest();

       

    var form = await request.ReadFormAsync();

    var formFile = form.Files["file"];

   

    if (formFile is null || formFile.Length == 0)

        return Results.BadRequest();

       

    await using var stream = formFile.OpenReadStream();

    var reader = new StreamReader(stream);

    var text = await reader.ReadToEndAsync();

   

    return Results.Ok(text);

});

При роботі з файлами важливо врахувати низку факторів безпеки та ефективності:

  • обмеження типів файлів, які приймаються (наприклад, заборона виконуваних файлів та скриптів);
  • обмеження розміру файлів для запобігання атакам типу «відмова в обслуговуванні»;
  • визначення місця зберігання файлів (локальна файлова система, база даних, хмарне сховище).

Налаштування максимального розміру форми можна здійснити через конфігурацію сервісів:

builder.Services.Configure<FormOptions>(options =>

{

    // Обмеження розміру даних до 5 МБ

    options.MultipartBodyLengthLimit = 5 * 1024 * 1024;

});

Для зберігання файлів на диск зазвичай створюють спеціальний метод, який безпечно обробляє шляхи та імена файлів:

 

app.MapPost("/upload", async (IFormFile file, ILogger<Program> logger) =>

{

    // Перевірка на порожній файл

    if (file.Length == 0)

    {

        logger.LogWarning("Завантажений файл {FileName} має нульову довжину", file.FileName);

        return Results.BadRequest("Файл порожній");

    }

    // Масив дозволених розширень файлів

    string[] allowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".docx", ".xlsx"];

   

    // Перевірка розширення файлу

    var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();

    if (string.IsNullOrEmpty(fileExtension) || !allowedFileExtensions.Contains(fileExtension))

    {

        logger.LogWarning("Заблоковане розширення файлу {Extension}", fileExtension);

        return Results.BadRequest("Розширення файлу заблоковано");

    }

    // Формування імені файлу для зберігання

    var storeFileName = Path.Combine(fileStoreLocation, file.FileName);

   

    // Асинхронний запис файлу на диск

    await using (var stream = new FileStream(storeFileName, FileMode.Create))

    {

        await file.CopyToAsync(stream, CancellationToken.None);

    }

   

    logger.LogInformation("Завантаження файлу {FileName} успішне", file.FileName);

    return Results.Ok($"Файл {file.FileName} успішно завантажено");

});

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

Валідація параметрів у фільтрах

Фільтри запитів є ідеальним місцем для валідації параметрів у Minimal API. Це дозволяє відокремити логіку перевірки від бізнес-логіки та повторно використовувати її для різних кінцевих точок.

Найпростіший спосіб додати валідацію — використати делегат при реєстрації фільтра:

 

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>

{

    db.Todos.Add(todo);

    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);

})

.AddEndpointFilter(async (efiContext, next) =>

{

    var todo = efiContext.GetArgument<Todo>(0);

    var validationError = Utilities.IsValid(todo!);

    if (!string.IsNullOrEmpty(validationError))

    {

        return Results.Problem(validationError);

    }

    return await next(efiContext);

});

Крім того, можна реалізувати інтерфейс IEndpointFilter для створення фільтра валідації, який можна повторно використовувати:

public class TodoIsValidFilter : IEndpointFilter

{

    private readonly ILogger _logger;

   

    public TodoIsValidFilter(ILoggerFactory loggerFactory)

    {

        _logger = loggerFactory.CreateLogger<TodoIsValidFilter>();

    }

   

    public async ValueTask<object?> InvokeAsync(

        EndpointFilterInvocationContext efiContext,

        EndpointFilterDelegate next)

    {

        var todo = efiContext.GetArgument<Todo>(0);

        var validationError = Utilities.IsValid(todo!);

        if (!string.IsNullOrEmpty(validationError))

        {

            _logger.LogWarning(validationError);

            return Results.Problem(validationError);

        }

        return await next(efiContext);

    }

}

Додавання такого фільтра до кінцевих точок відбувається через метод AddEndpointFilter<T>:

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>

{

    db.Todos.Add(todo);

    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);

})

.AddEndpointFilter<TodoIsValidFilter>();

Окрім того, можна створити фабрику фільтрів, яка буде визначати, які фільтри слід додати до кінцевої точки на основі її сигнатури:

app.MapPost("/customers", ([FromBody] RegisterCustomerRequest customer) =>

{

    // бізнес-логіка

    return Results.Ok();

})

.AddEndpointFilterFactory((context, next) =>

{

    if (context.MethodInfo.GetParameters().Any(p => p.ParameterType == typeof(RegisterCustomerRequest)))

    {

        var filter = new ValidationFilter<RegisterCustomerRequest>();

        return invocationContext => filter.InvokeAsync(invocationContext, next);

    }

    // фільтр-пропуск

    return invocationContext => next(invocationContext);

});

Для автоматизації валідації даних можна використовувати зовнішні бібліотеки, наприклад, FlatValidator. Ця бібліотека дозволяє визначати правила валідації безпосередньо або створювати окремі класи-валідатори.

Вбудований підхід (inline):

app.MapPost("/todos", (Todo todo) =>

{

    var result = FlatValidator.Validate(todo, v =>

    {

        v.ErrorIf(m => m.Title.IsEmpty(), "Недійсна назва.", m => m.Title);

    });

   

    if (!result)

    {

        return TypedResults.ValidationProblem(result.ToDictionary());

    }

   

    // бізнес-логіка

    return Results.Ok();

});

 

Підхід із окремим класом-валідатором:

 

public class TodoValidator : FlatValidator<Todo>

{

    public TodoValidator()

    {

        v.ErrorIf(m => m.Title.IsEmpty(), "Недійсна назва.", m => m.Title);

    }

}

Використання такого валідатора:

app.MapPost("/todos", (Todo todo) =>

{

    var result = new TodoValidator().Validate(todo);

    if (!result)

    {

        return TypedResults.ValidationProblem(result.ToDictionary());

    }

   

    // бізнес-логіка

    return Results.Ok();

});

Формування відповіді з помилкою через Results.Problem()

Для повідомлення про помилки валідації найкраще використовувати метод Results.Problem(), який формує відповідь у форматі RFC 7807 (Problem Details). Ця специфікація визначає стандартний формат для відповідей про помилки в HTTP API.

Базове використання Results.Problem():

return Results.Problem(

    detail: "Недійсні дані у запиті",

    statusCode: StatusCodes.Status400BadRequest

);

Для структурованих повідомлень про помилки валідації можна використовувати TypedResults.ValidationProblem():

var validationErrors = new Dictionary<string, string[]>

{

    { "Title", new[] { "Назва не може бути порожньою" } },

    { "DueDate", new[] { "Дата має бути в майбутньому" } }

};

return TypedResults.ValidationProblem(

    validationErrors,

    statusCode: StatusCodes.Status422UnprocessableEntity

);

 

Така відповідь буде містити детальну інформацію про помилки валідації:

{

  "type": "https://tools.ietf.org/html/rfc4918#section-11.2",

  "title": "One or more validation errors occurred.",

  "status": 422,

  "errors": {

    "Title": [

      "Назва не може бути порожньою"

    ],

    "DueDate": [

      "Дата має бути в майбутньому"

    ]

  }

}

Зрештою, поєднання валідації параметрів та обробки файлів у фільтрах дозволяє створювати надійні та безпечні кінцеві точки в Minimal API. Такий підхід забезпечує повторне використання коду, чіткість та зручність обслуговування проєкту.

Основні переваги цього підходу:

  • відокремлення логіки валідації від бізнес-логіки;
  • можливість застосування однакових фільтрів до різних кінцевих точок;
  • спрощення тестування завдяки ізоляції логіки валідації;
  • покращення читабельності коду завдяки чіткому розділенню відповідальності;
  • зменшення дублювання коду через повторне використання фільтрів;
  • полегшення підтримки та розширення функціональності в майбутньому;
  • централізоване керування перевірками безпеки та валідацією;
  • можливість комбінування різних фільтрів для складних сценаріїв обробки запитів.
Висновок

Отже, Minimal API в ASP.NET Core 7 безсумнівно пропонує потужний та елегантний підхід до створення вебслужб.

Під час розробки можна відчутно оцінити переваги цього підходу:

  • менша кількість коду та вищу продуктивність порівняно з традиційними контролерами;
  • зручна фільтрація запитів через AddEndpointFilter, що дозволяє впроваджувати валідацію та інші перехресні завдання;
  • гнучке відображення параметрів з різних джерел за допомогою атрибутів FromQuery, FromRoute, FromHeader та FromBody;
  • надійна робота з файлами та ефективна валідація даних.

Варто зазначити, що хоча Minimal API спочатку було розроблено для простих сценаріїв, наразі цей підхід успішно масштабується для вирішення складніших завдань завдяки групуванню маршрутів та організації коду як розширень. Крім того, функціональність, представлена в .NET 7, значно розширює можливості розробників створювати чисті, ефективні та підтримувані API.

Зрештою, використання Minimal API відкриває шлях до створення швидких, легких вебдодатків, які містять лише необхідний мінімум коду та залежностей. Таким чином, ASP.NET Core 7 пропонує потрібний баланс між простотою та функціональністю, що робить його відмінним вибором як для мікросервісів, так і для більш складних рішень.

ДАЛІ МОЖНА ПОЧИТАТИ

Підписатися на новини

Чудово! Ми вже готуємо добірку актуальних новин для вас :)

Вибачте, щось пішло не так. Будь ласка, спробуйте ще раз.

* Обов'язкові поля

*Будь ласка, заповніть обов’язкові поля