Фильтры в приложениях с минимальным API

Авторы: Фияз Бин Хасан (Fiyaz Bin Hasan), Мартин Костелло (Martin Costello) и Рик Андерсон (Rick Anderson)

Фильтры минимальных API позволяют разработчикам реализовать бизнес-логику, которая поддерживает следующее:

  • выполнение кода до и после запуска обработчика конечной точки;
  • проверку и изменение параметров, предоставленных при вызове обработчика конечной точки;
  • перехват ответа обработчика конечной точки.

Фильтры могут быть полезны в следующих сценариях:

  • Проверка параметров и текста запросов, отправляемых в конечную точку.
  • Запись сведений о запросе и ответе в журнал.
  • Проверка того, что запрос предназначен для поддерживаемой версии API.

Фильтры можно зарегистрировать, предоставив делегат, который принимает EndpointFilterInvocationContext и возвращает EndpointFilterDelegate. EndpointFilterInvocationContext предоставляет доступ к HttpContext запроса и списку Arguments, в котором переданные обработчику аргументы указаны в том порядке, в котором они отображаются в объявлении обработчика.

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

string ColorName(string color) => $"Color specified: {color}!";

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);
    });

app.Run();

Предыдущий код:

  • Вызывает метод расширения AddEndpointFilter для добавления фильтра в конечную точку /colorSelector/{color}.
  • Возвращает указанный цвет, за исключением значения "Red".
  • Возвращает Results.Problem, если запрашивается /colorSelector/Red.
  • Использует next в качестве EndpointFilterDelegate и invocationContext в качестве EndpointFilterInvocationContext для вызова следующего фильтра в конвейере или делегата запроса, если был вызван последний фильтр.

Фильтр выполняется перед обработчиком конечной точки. При выполнении нескольких вызовов AddEndpointFilter в обработчике:

  • Код фильтра, вызываемый перед вызовом EndpointFilterDelegate (next), выполняется в порядке FIFO (прибыл первым — обслужен первым).
  • Код фильтра, вызываемый после вызова EndpointFilterDelegate (next), выполняется в порядке FILO (прибыл первым — обслужен последним).
var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () =>
    {
        app.Logger.LogInformation("             Endpoint");
        return "Test of multiple filters";
    })
    .AddEndpointFilter(async (efiContext, next) =>
    {
        app.Logger.LogInformation("Before first filter");
        var result = await next(efiContext);
        app.Logger.LogInformation("After first filter");
        return result;
    })
    .AddEndpointFilter(async (efiContext, next) =>
    {
        app.Logger.LogInformation(" Before 2nd filter");
        var result = await next(efiContext);
        app.Logger.LogInformation(" After 2nd filter");
        return result;
    })
    .AddEndpointFilter(async (efiContext, next) =>
    {
        app.Logger.LogInformation("     Before 3rd filter");
        var result = await next(efiContext);
        app.Logger.LogInformation("     After 3rd filter");
        return result;
    });

app.Run();

В приведенном выше коде фильтры и конечная точка регистрируют следующие выходные данные:

Before first filter
    Before 2nd filter
        Before 3rd filter
            Endpoint
        After 3rd filter
    After 2nd filter
After first filter

В следующем коде используются фильтры, реализующие интерфейс IEndpointFilter:

using Filters.EndpointFilters;

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () =>
    {
        app.Logger.LogInformation("Endpoint");
        return "Test of multiple filters";
    })
    .AddEndpointFilter<AEndpointFilter>()
    .AddEndpointFilter<BEndpointFilter>()
    .AddEndpointFilter<CEndpointFilter>();

app.Run();

В приведенном выше коде в журналах фильтров и обработчиков показан порядок их выполнения:

AEndpointFilter Before next
BEndpointFilter Before next
CEndpointFilter Before next
      Endpoint
CEndpointFilter After next
BEndpointFilter After next
AEndpointFilter After next

Фильтры, реализующие интерфейс IEndpointFilter, показаны в следующем примере:


namespace Filters.EndpointFilters;

public abstract class ABCEndpointFilters : IEndpointFilter
{
    protected readonly ILogger Logger;
    private readonly string _methodName;

    protected ABCEndpointFilters(ILoggerFactory loggerFactory)
    {
        Logger = loggerFactory.CreateLogger<ABCEndpointFilters>();
        _methodName = GetType().Name;
    }

    public virtual async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        Logger.LogInformation("{MethodName} Before next", _methodName);
        var result = await next(context);
        Logger.LogInformation("{MethodName} After next", _methodName);
        return result;
    }
}

class AEndpointFilter : ABCEndpointFilters
{
    public AEndpointFilter(ILoggerFactory loggerFactory) : base(loggerFactory) { }
}

class BEndpointFilter : ABCEndpointFilters
{
    public BEndpointFilter(ILoggerFactory loggerFactory) : base(loggerFactory) { }
}

class CEndpointFilter : ABCEndpointFilters
{
    public CEndpointFilter(ILoggerFactory loggerFactory) : base(loggerFactory) { }
}

Проверка объекта с помощью фильтра

Рассмотрим фильтр, проверяющий объект Todo:

app.MapPut("/todoitems/{id}", async (Todo inputTodo, int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;
    
    await db.SaveChangesAsync();

    return Results.NoContent();
}).AddEndpointFilter(async (efiContext, next) =>
{
    var tdparam = efiContext.GetArgument<Todo>(0);

    var validationError = Utilities.IsValid(tdparam);

    if (!string.IsNullOrEmpty(validationError))
    {
        return Results.Problem(validationError);
    }
    return await next(efiContext);
});

В предыдущем коде:

  • Объект EndpointFilterInvocationContext предоставляет доступ к параметрам, связанным с определенным запросом, выданным конечной точке с помощью GetArguments метода.
  • Фильтр регистрируется с помощью delegate, который принимает EndpointFilterInvocationContext и возвращает EndpointFilterDelegate.

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

public class TodoIsValidFilter : IEndpointFilter
{
    private 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);
    }
}

Фильтры, реализующие интерфейс IEndpointFilter, могут разрешать зависимости, возникшие в результате внедрения зависимостей (DI), как показано в предыдущем коде. Хотя фильтры и могут разрешать зависимости, возникшие в результате DI, сами фильтры не могут быть разрешены из внедрения зависимостей.

ToDoIsValidFilter применяется к следующим конечным точкам:

app.MapPut("/todoitems2/{id}", async (Todo inputTodo, int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
}).AddEndpointFilter<TodoIsValidFilter>();

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

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

Следующий фильтр проверяет объект Todo и изменяет свойство Name:

public class TodoIsValidUcFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext efiContext, 
        EndpointFilterDelegate next)
    {
        var todo = efiContext.GetArgument<Todo>(0);
        todo.Name = todo.Name!.ToUpper();

        var validationError = Utilities.IsValid(todo!);

        if (!string.IsNullOrEmpty(validationError))
        {
            return Results.Problem(validationError);
        }
        return await next(efiContext);
    }
}

Регистрация фильтра с помощью фабрики фильтров конечных точек

В некоторых сценариях может потребоваться кэшировать некоторые сведения, предоставленные в MethodInfo фильтре. Например, предположим, что мы хотели убедиться, что обработчик фильтра конечной точки подключен к первому параметру, который оценивается типом Todo .

app.MapPut("/todoitems/{id}", async (Todo inputTodo, int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;
    
    await db.SaveChangesAsync();

    return Results.NoContent();
}).AddEndpointFilterFactory((filterFactoryContext, next) =>
{
    var parameters = filterFactoryContext.MethodInfo.GetParameters();
    if (parameters.Length >= 1 && parameters[0].ParameterType == typeof(Todo))
    {
        return async invocationContext =>
        {
            var todoParam = invocationContext.GetArgument<Todo>(0);

            var validationError = Utilities.IsValid(todoParam);

            if (!string.IsNullOrEmpty(validationError))
            {
                return Results.Problem(validationError);
            }
            return await next(invocationContext);
        };
    }
    return invocationContext => next(invocationContext); 
});

В предыдущем коде:

  • Объект EndpointFilterFactoryContext предоставляет доступ к связанному MethodInfo обработчику конечной точки.
  • Подпись обработчика проверяется путем проверки MethodInfo ожидаемой сигнатуры типа. При обнаружении ожидаемой подписи фильтр проверки регистрируется в конечной точке. Этот шаблон фабрики полезен для регистрации фильтра, который зависит от сигнатуры целевого обработчика конечной точки.
  • Если соответствующая подпись не найдена, то регистрируется сквозной фильтр.

Регистрация фильтра для действий контроллера

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

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapController()
    .AddEndpointFilter(async (efiContext, next) =>
    {
        efiContext.HttpContext.Items["endpointFilterCalled"] = true;
        var result = await next(efiContext);
        return result;
    });

app.Run();

Дополнительные ресурсы