Валидация входных данных в фильтрах Minimal API .NET, просто и без затей

Начну с риторического вопроса — что может быть увлекательнее процесса изучения новой технологии, когда понимание происходит «на лету», а клеточки мозга воспринимают новые знания как нечто знакомое, но слегка подзабытое?

Ответ — да, в общем-то, много чего! Хотя, технология, несложная в своём освоении, определённо вызывает позитив.

Как уже очевидно из названия, быстренько разберёмся, как можно просто и эффективно реализовать качественную проверку входных данных, используя фильтры Minimal API. Разумеется, мы не станем здесь уподобляться изобретателям колеса и используем существующие наработки, к примеру, пакет FlatValidator. Погружаться в детали пакета у нас задача не стоит, мы сконцентрируемся в первую очередь на интеграции.

Шаг в прошлое. Minimal API впервые был заявлен в выпуске .NET 6, это гибкая программная техника, предназначенная для обслуживания HTTP-маршрутизации. Появление Minimal API подвесило в воздух ощущение, что время контроллеров неумолимо истекает. И, как всегда, новые времена создают новые вызовы.

Если вы обо всём этом ничего не слышали, не суть, дальнейшее обсуждение внесёт определённую ясность, просто обозначим границы исследуемого вопроса.

Фильтры конечных точек известны главным образом именно в рамках техники Minimal API, хотя сейчас они доступны в том числе и для MVC, и для Razor Pages.

На мой взгляд, фильтры — это нечто среднее между самим обработчиком запроса к конечной точке и middleware. Термин 'middleware' труднопереводим на русский язык, скажем так — это программный модуль, встраиваемый в цепочку обработки HTTP-запроса. Если таких модулей несколько, они выполняются последовательно, один за другим. К чему я это рассказываю? Раньше для валидации данных часто использовали middleware. Теперь появился более удобный инструмент — фильтры, как реализация интерфейса `IEndpointFilter`.

Самый простой код реализации `IEndpointFilter` выглядит примерно так:

public class MyFilter: IEndpointFilter  
{  
    public async ValueTask InvokeAsync(  
        EndpointFilterInvocationContext context,  
        EndpointFilterDelegate next)  
    {        
        var result = await next(context);  
        if (result is string s)
        {
            result = s.Replace("vodka", "pineapple juice");
        }
        return result;  
    }
}

Что здесь происходит? Фильтр пропускает обработку запроса к конечной точке (вызывая `next ()`) и на выходе заменяет в response все фрагменты `vodka` на `pineapple juice`. Как видите, всё предельно просто.

Давайте же подключим этот фильтр к приложению Minimal API.

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

var group = app  
    .MapGroup(string.Empty)  
    .AddEndpointFilter();  // <==
  
group.MapGet("/", () => "Hello World");  
group.MapGet("/hi", () => "I like to drink vodka!");  
  
app.Run(); 

Очевидно, что результатом работы нашего фильтра будет замена на выходе фразы `I like to drink vodka! ` на `I like to drink pineapple juice! `. Такой вот нежданчик для любителя `vodka`. «А что делать? Пьянству бой.»

Итак, теперь, когда с фильтрами для Minimal API немного разобрались, давайте перейдём к более реальному примеру. Через NuGet инсталлируем основной пакет FlatValidator, он поможет проверять наши данные профессионально.

❯ dotnet add package FlatValidator

В документации к пакету написано, что использование валидатора предусмотрено в двух режимах, ориентировочно названных inline и derived.

В inline-режиме правила проверки задаются прямо в точке валидации. Это может быть удобно, поскольку оставляет возможность видеть логику проверки «по месту».

С другой стороны, в солидных проектах такой подход скорее всего не будет согласовываться с концептуальными требованиями, обычно бизнес-логику всё же вычленяют и располагают в строго отведённых местах. Тогда проще использовать классический подход с наследованием класса `FlatValidator`.

Позволю себе взять несколько изменённый пример из документации, здесь inline-режим :

// use asynchronous version
var result = await FlatValidator.ValidateAsync(model, v => 
{
    // m == model, IsEmail() is one of funcs for typical data formats
    v.ValidIf(m => m.Email.IsEmail(), "Invalid email", m => m.Email);

    // involve custom userService for specific logic
    v.ErrorIf(async m => await userService.IsUserExistAsync(m.Email),
              "User already registered", m => m.Email);
});
if (!result) // check the validation result
    return TypedResults.ValidationProblem(result.ToDictionary());

Функции `ValidIf` и `ErrorIf` позволяют задавать правила валидации. Их может быть сколько угодно много в одном валидаторе. `TypedResults.ValidationProblem` — это часть .NET 6+, упрощающая возврат ошибок в HTTP-response.

Само приложение в концепции inline-режима мы могли бы написать так:

var builder = WebApplication.CreateBuilder(args);  
var app = builder.Build();  
  
// Endpoint aka https://localhost:5000/todos/
app.MapPost("/todos", (Todo todo) => 
{
    var result = FlatValidator.Validate(todo, v => 
    {
        v.ErrorIf(m => m.Title.IsEmpty(), "Title can not be empty.", m => m.Title);
    });
    if (!result)
        return TypedResults.ValidationProblem(result.ToDictionary()) 

    // ....
    return Results.Ok();
});  

app.Run(); 

// Model to test validation functionality
public record Todo(string Title, bool IsComplete = false);

Если inline-стиль вам не подходит, используйте вариант с наследованием.

var builder = WebApplication.CreateBuilder(args);  
var app = builder.Build();  
  
app.MapPost("/todos", (Todo todo) => 
{
    if (new TodoValidator().Validate(todo) == false)
        return TypedResults.ValidationProblem(result.ToDictionary()) 

    // ....
    return Results.Ok();
});  

app.Run(); 

// Model to test validation functionality
public record Todo(string Title, bool IsComplete = false);

// Implement custom validator for the model Todo
public class TodoValidator : FlatValidator
{
    public TodoValidator()
    {
        v.ErrorIf(m => m.Title.IsEmpty(), "Title can not be empty.", m => m.Title);
    }
}

Эта запись выглядит опрятнее. Ясно, что классы `Todo` и `TodoValidator` должны бы лежать каждый в своём файле.

Что ж, до реализации фильтра валидации, заявленного в заголовке статьи, нам остался один шаг. Логику вызова самого валидатора перенесём непосредственно внутрь фильтра.

app.MapPost("/todos", (Todo todo) => 
{
    return Results.Ok();

}).AddEndpointFilter>();  // <==


public class ValidationFilter(IServiceProvider serviceProvider) : IEndpointFilter
{
    public async ValueTask InvokeAsync(
        EndpointFilterInvocationContext context, 
        EndpointFilterDelegate next)
    {
        var validators = serviceProvider.GetServices>();
        foreach (var validator in validators)
        {
            if (context.Arguments.FirstOrDefault(x => 
                    x?.GetType() == typeof(T)) is not T model)
            {
                return TypedResults.Problem(
                            detail: "No approptiate parameter.", 
                            statusCode: StatusCodes.Status500InternalServerError);
            }

            if (!await validator.ValidateAsync(model))
            {
                return TypedResults.ValidationProblem(result.ToDictionary());
            }
        }

        // call next filter in the chain
        return await next(context);
    }
}

Заметили? Тело конечной точки `.MapPost (»/todos»)` вообще избавилось от каких-либо проверок, логика теперь в фильтре. Но не забываем про `.AddEndpointFilter>`. А, поскольку фильтр у нас generic, он подойдёт для модели любого типа, достаточно реализовать сам класс валидатора и зарегистрировать его в `IServiceCollection`.

// register a validator for the Todo model
builder.Services.AddScoped, TodoValidator>();

Впрочем, с этим тоже проблем нет. Если вы хотите автоматизировать регистрацию всех ваших валидаторов, используйте вспомогательный пакет FlatValidator.DependencyInjection.

❯ dotnet add package FlatValidator.DependencyInjection
var builder = WebApplication.CreateBuilder(args);  

builder.Services.AddFlatValidatorsFromAssembly(Assembly.GetExecutingAssembly());

Вообще же, для того, чтобы ближе познакомиться с возможностями пакета FlatValidator, рекомендую в первую очередь заглянуть на страничку проекта, там найдётся документация и лежат вполне годные примеры.

Не секрет, что писать производительный код довольно сложно. Не менее сложно его потом читать и разбираться в том, что написано, ибо производительность и понятность кода — порой два совершенно разных понятия. Тем не менее в пакете удалось получить фантастическую производительность при вполне читаемом коде.

Ну и напоследок, как обещано, пара слайд о сравнительной производительности пакета. Сами benchmark-и также обретаются на страничке проекта, их можно скачать и потестировать.

Сравнение производительности FlatValidator и FluentValidation

Сравнение производительности FlatValidator и FluentValidation

© Habrahabr.ru