Filter in Minimal-API-Apps

Von Fiyaz Bin Hasan, Martin Costello und Rick Anderson

Minimal-API-Filter ermöglichen Entwicklern die Implementierung von Geschäftslogik, die Folgendes unterstützt:

  • Ausführen von Code vor und nach dem Endpunkthandler
  • Untersuchen und Ändern von Parametern, die während des Aufrufs eines Endpunkthandlers bereitgestellt werden
  • Abfangen des Antwortverhaltens eines Endpunkthandlers

Filter können in den folgenden Szenarien hilfreich sein:

  • Überprüfen der Anforderungsparameter und des Texts, die an einen Endpunkt gesendet werden.
  • Protokollierung von Informationen über die Anforderung und Antwort.
  • Überprüfen, ob eine Anforderung auf eine unterstützte API-Version ausgerichtet ist.

Filter können registriert werden, indem ein Delegat bereitgestellt wird, der einen EndpointFilterInvocationContext entgegennimmt und einen EndpointFilterDelegate zurückgibt. Der EndpointFilterInvocationContext bietet Zugriff auf den HttpContext der Anforderung und eine Arguments-Liste, die die an den Handler übergebenen Argumente in der Reihenfolge angibt, in der sie in der Deklaration des Handlers auftreten.

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

Der vorangehende Code:

  • Ruft die AddEndpointFilter-Erweiterungsmethode auf, um dem /colorSelector/{color}-Endpunkt einen Filter hinzuzufügen.
  • Gibt die angegebene Farbe außer dem Wert "Red" zurück.
  • Gibt Results.Problem zurück, wenn /colorSelector/Red angefordert wird.
  • Verwendet next als EndpointFilterDelegate und invocationContext als EndpointFilterInvocationContext, um den nächsten Filter in der Pipeline oder dem Anforderungsdelegaten aufzurufen, wenn der letzte Filter aufgerufen wurde.

Der Filter wird vor dem Endpunkthandler ausgeführt. Wenn mehrere AddEndpointFilter-Aufrufe auf einem Handler vorgenommen werden:

  • Filtercode, der aufgerufen wird, bevor EndpointFilterDelegate (next) aufgerufen wird, wird in der Reihenfolge First In, First Out (FIFO) ausgeführt.
  • Filtercode, der aufgerufen wird, nachdem EndpointFilterDelegate (next) aufgerufen wird, wird in der Reihenfolge First In, Last Out (FILO) ausgeführt.
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();

Im vorherigen Code protokollieren die Filter und Endpunkte folgende Ausgabe:

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

Der folgende Code verwendet Filter, die die IEndpointFilter-Schnittstelle implementieren:

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

Im vorherigen Code zeigen die Filter- und Handlerprotokolle die Reihenfolge an, in der sie ausgeführt werden:

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

Filter, die die IEndpointFilter-Schnittstelle implementieren, werden im folgenden Beispiel gezeigt:


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

Überprüfen eines Objekts mit einem Filter

Betrachten Sie einen Filter, der ein Todo-Objekt überprüft:

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

Für den Code oben gilt:

  • Das EndpointFilterInvocationContext-Objekt bietet Zugriff auf die Parameter, die einer bestimmten Anforderung zugeordnet sind, die über die GetArguments-Methode an den Endpunkt ausgegeben wurde.
  • Der Filter wird mithilfe eines delegate registriert, der einen EndpointFilterInvocationContext entgegennimmt und einen EndpointFilterDelegate zurückgibt.

Zusätzlich zum Übergeben als Delegaten können Filter durch Implementieren der IEndpointFilter-Schnittstelle registriert werden. Der folgende Code zeigt den vorherigen Filter, der in einer Klasse gekapselt ist, die IEndpointFilter implementiert:

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

Filter, die die IEndpointFilter-Schnittstelle implementieren, können Abhängigkeiten von Dependency Injection (DI) auflösen, wie im vorherigen Code gezeigt. Obwohl Filter Abhängigkeiten aus DI auflösen können, können Filter selbst nicht aus DI aufgelöst werden.

Der ToDoIsValidFilter wird auf die folgenden Endpunkte angewendet:

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

Der folgende Filter überprüft das Todo-Objekt und ändert die Name-Eigenschaft:

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

Registrieren eines Filters mit einer Endpunktfilter-Factory

In einigen Szenarien ist es möglicherweise erforderlich, einige der Informationen zwischenzuspeichern, die einem Filter in MethodInfo bereitgestellt werden. Angenommen, wir möchten überprüfen, ob der Handler, an den ein Endpunktfilter angefügt ist, einen ersten Parameter hat, der zu einem Todo-Typ ausgewertet wird.

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

Für den Code oben gilt:

  • Das EndpointFilterFactoryContext-Objekt bietet Zugriff auf die MethodInfo, die dem Handler des Endpunkts zugeordnet ist.
  • Die Signatur des Handlers wird geprüft, indem MethodInfo auf die erwartete Typsignatur untersucht wird. Wenn die erwartete Signatur gefunden wird, wird der Validierungsfilter auf dem Endpunkt registriert. Dieses Factorymuster ist nützlich, um einen Filter zu registrieren, der von der Signatur des Zielendpunkthandlers abhängt.
  • Wird keine übereinstimmende Signatur gefunden, dann wird ein Passthrough-Filter registriert.

Registrieren eines Filters für Controlleraktionen

In einigen Szenarien kann es erforderlich sein, sowohl für routenhandlerbasierte Endpunkte als auch für Controlleraktionen dieselbe Filterlogik anzuwenden. Für dieses Szenario kann AddEndpointFilter auf ControllerActionEndpointConventionBuilder aufgerufen werden, um die Ausführung derselben Filterlogik auf Aktionen und Endpunkten zu unterstützen.

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

Weitere Ressourcen