Filtry w aplikacjach minimalnych interfejsów API

Przez Fiyaz Bin Hasan, Martin Costello i Rick Anderson

Minimalne filtry interfejsu API umożliwiają deweloperom implementowanie logiki biznesowej, która obsługuje:

  • Uruchamianie kodu przed programem obsługi punktu końcowego i po nim.
  • Sprawdzanie i modyfikowanie parametrów podanych podczas wywołania programu obsługi punktu końcowego.
  • Przechwytywanie zachowania odpowiedzi programu obsługi punktu końcowego.

Filtry mogą być przydatne w następujących scenariuszach:

  • Weryfikowanie parametrów żądania i treści wysyłanych do punktu końcowego.
  • Rejestrowanie informacji o żądaniu i odpowiedzi.
  • Weryfikowanie, czy żądanie dotyczy obsługiwanej wersji interfejsu API.

Filtry można zarejestrować, podając delegata, który przyjmuje EndpointFilterInvocationContext element i zwraca wartość EndpointFilterDelegate. Obiekt EndpointFilterInvocationContext zapewnia dostęp do HttpContext żądania i Arguments listy wskazującej argumenty przekazane do programu obsługi w kolejności, w której są wyświetlane w deklaracji programu obsługi.

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

Powyższy kod ma następujące działanie:

  • Wywołuje metodę AddEndpointFilter rozszerzenia, aby dodać filtr do punktu końcowego /colorSelector/{color} .
  • Zwraca określony kolor z wyjątkiem wartości "Red".
  • Zwraca wartość Results.Problem , gdy /colorSelector/Red żądanie jest żądane.
  • Używa next elementu i invocationContext jako EndpointFilterDelegateEndpointFilterInvocationContext elementu , aby wywołać następny filtr w potoku lub delegat żądania, jeśli ostatni filtr został wywołany.

Filtr jest uruchamiany przed programem obsługi punktu końcowego. Gdy na procedurze obsługi jest wykonywane wiele AddEndpointFilter wywołań:

  • Kod filtru wywoływany przed wywołaniem EndpointFilterDelegate (next) jest wykonywany w kolejności od pierwszego wyjętego wyjścia (FIFO).
  • Kod filtru wywoływany po wywołaniu EndpointFilterDelegate metody (next) jest wykonywany w kolejności od pierwszego w, ostatniego wyjścia (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();

W poprzednim kodzie filtry i dziennik punktu końcowego zawierają następujące dane wyjściowe:

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

Poniższy kod używa filtrów implementujących IEndpointFilter interfejs:

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

W poprzednim kodzie dzienniki filtrów i procedur obsługi pokazują kolejność ich uruchamiania:

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

Filtry implementowane interfejsu IEndpointFilter są wyświetlane w poniższym przykładzie:


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

Weryfikowanie obiektu za pomocą filtru

Rozważ filtr weryfikujący Todo obiekt:

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

Powyższy kod:

  • Obiekt EndpointFilterInvocationContext zapewnia dostęp do parametrów skojarzonych z określonym żądaniem wystawionym dla punktu końcowego GetArguments za pośrednictwem metody .
  • Filtr jest rejestrowany przy użyciu elementu delegate , który przyjmuje EndpointFilterInvocationContext element i zwraca wartość EndpointFilterDelegate.

Oprócz przekazywania jako delegatów filtry można zarejestrować przez zaimplementowanie interfejsu IEndpointFilter . Poniższy kod przedstawia powyższy filtr hermetyzowany w klasie, która implementuje IEndpointFilterelement :

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

Filtry, które implementują IEndpointFilter interfejs, mogą rozpoznawać zależności od wstrzykiwania zależności (DI), jak pokazano w poprzednim kodzie. Chociaż filtry mogą rozwiązywać zależności z di, same filtry nie mogą być rozpoznawane z di.

Element ToDoIsValidFilter jest stosowany do następujących punktów końcowych:

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

Następujący filtr weryfikuje Todo obiekt i modyfikuje Name właściwość:

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

Rejestrowanie filtru przy użyciu fabryki filtrów punktów końcowych

W niektórych scenariuszach może być konieczne buforowanie niektórych informacji podanych w MethodInfo filtrze. Załóżmy na przykład, że chcemy sprawdzić, czy program obsługi jest dołączony do filtru punktu końcowego Todo , ma pierwszy parametr, który ocenia typ.

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

Powyższy kod:

  • Obiekt EndpointFilterFactoryContext zapewnia dostęp do skojarzonego MethodInfo z programem obsługi punktu końcowego.
  • Podpis procedury obsługi jest badany przez sprawdzenie MethodInfo pod kątem oczekiwanego podpisu typu. Jeśli zostanie znaleziony oczekiwany podpis, filtr weryfikacji zostanie zarejestrowany w punkcie końcowym. Ten wzorzec fabryki jest przydatny do rejestrowania filtru, który zależy od podpisu docelowego programu obsługi punktów końcowych.
  • Jeśli pasujący podpis nie zostanie znaleziony, zostanie zarejestrowany filtr przekazywania.

Rejestrowanie filtru w akcjach kontrolera

W niektórych scenariuszach może być konieczne zastosowanie tej samej logiki filtru dla punktów końcowych opartych na trasach i akcji kontrolera. W tym scenariuszu można wywołać metodę AddEndpointFilter w ControllerActionEndpointConventionBuilder celu obsługi wykonywania tej samej logiki filtru w akcjach i punktach końcowych.

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

Dodatkowe zasoby