FlatValidator. Эффективная проверка входных данных в фильтрах Minimal API

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

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

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

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

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

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

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

public class MyFilter: IEndpointFilter { public async ValueTask<object?> 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<MyFilter>(); // <== 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 немного разобрались, давайте перейдём к более реальному примеру и подключим пакет FlatValidator, он поможет проверять наши данные профессионально.

Через NuGet инсталлируем основной пакет:

❯ dotnet add package FlatValidator

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

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

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

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

// use asynchronous version var result = await FlatValidator.ValidateAsync(model, v => { // 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<Todo> { 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<ValidationFilter<Todo>>(); // <== public class ValidationFilter<T>(IServiceProvider serviceProvider) : IEndpointFilter { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var validators = serviceProvider.GetServices<IFlatValidator<T>>(); 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<ValidationFilter<Todo>>`. А, поскольку фильтр у нас generic, он подойдёт для модели любого типа, достаточно реализовать сам класс валидатора и зарегистрировать его в `IServiceCollection`.

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

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

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

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

11
Начать дискуссию