Filtres dans les applications d’API minimales

Par Fiyaz Bin Hasan, Martin Costello et Rick Anderson

Les filtres d’API minimaux permettent aux développeurs d’implémenter une logique métier qui prend en charge :

  • Exécution du code avant et après le gestionnaire de point de terminaison.
  • Inspection et modification des paramètres fournis lors d’un appel de gestionnaire de point de terminaison.
  • Interception du comportement de réponse d’un gestionnaire de point de terminaison.

Les filtres peuvent être utiles dans les scénarios suivants :

  • Validation des paramètres et du corps de la requête envoyés à un point de terminaison.
  • Journalisation des informations sur la requête et la réponse.
  • Validation qu’une requête cible une version d’API prise en charge.

Les filtres peuvent être inscrits en fournissant un délégué qui prend un EndpointFilterInvocationContext et retourne un EndpointFilterDelegate. Le EndpointFilterInvocationContext fournit l’accès au HttpContext de la requête et à une liste Arguments indiquant les arguments passés au gestionnaire dans l’ordre dans lequel ils apparaissent dans la déclaration du gestionnaire.

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

Le code précédent :

  • Appelle la méthode d’extension AddEndpointFilter pour ajouter un filtre au point de terminaison /colorSelector/{color}.
  • Retourne la couleur spécifiée à l’exception de la valeur "Red".
  • Retourne Results.Problem lorsque /colorSelector/Red est demandé.
  • Utilise next comme EndpointFilterDelegate et invocationContext comme EndpointFilterInvocationContext pour appeler le filtre suivant dans le pipeline ou le délégué de requête si le dernier filtre a été appelé.

Le filtre est exécuté avant le gestionnaire de point de terminaison. Lorsque plusieurs appels AddEndpointFilter sont effectués sur un gestionnaire :

  • Le code de filtre appelé avant l’appel de EndpointFilterDelegate (next) est exécuté dans l’ordre First In, First Out (FIFO).
  • Le code de filtre appelé après l’appel de EndpointFilterDelegate (next) est exécuté dans l’ordre Premier entrant, Dernier sorti (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();

Dans le code précédent, les filtres et le point de terminaison consignent la sortie suivante :

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

Le code suivant utilise des filtres qui implémentent l’interface 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();

Dans le code précédent, les journaux des filtres et des gestionnaires indiquent l’ordre dans lequel ils sont exécutés :

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

Des filtres implémentant l’interface IEndpointFilter sont illustrés dans l’exemple suivant :


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

Valider un objet avec un filtre

Considérez un filtre qui valide un objet 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);
});

Dans le code précédent :

  • L’objet EndpointFilterInvocationContext fournit l’accès aux paramètres associés à une requête particulière émise au point de terminaison via la méthode GetArguments.
  • Le filtre est inscrit à l’aide d’un delegate qui prend un EndpointFilterInvocationContext et retourne un EndpointFilterDelegate.

En plus d’être passés en tant que délégués, les filtres peuvent être inscrits en implémentant l’interface IEndpointFilter. Le code suivant montre le filtre précédent encapsulé dans une classe qui implémente 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);
    }
}

Les filtres qui implémentent l’interface IEndpointFilter peuvent résoudre les dépendances à partir de l’injection de dépendances (DI), comme indiqué dans le code précédent. Bien que les filtres puissent résoudre les dépendances à partir de la DI, les filtres eux-mêmes ne peuvent pas être résolus à partir de la DI.

Le ToDoIsValidFilter est appliqué aux points de terminaison suivants :

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

Le filtre suivant valide l’objet Todo et modifie la propriété 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);
    }
}

Inscrire un filtre à l’aide d’une fabrique de filtres de point de terminaison

Dans certains scénarios, il peut être nécessaire de mettre en cache certaines informations fournies dans le MethodInfo dans un filtre. Par exemple, supposons que nous voulions vérifier que le gestionnaire auquel un filtre de point de terminaison est attaché a un premier paramètre qui prend la valeur d’un type 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); 
});

Dans le code précédent :

  • L’objet EndpointFilterFactoryContext fournit l’accès au MethodInfo associé au gestionnaire du point de terminaison.
  • La signature du gestionnaire est examinée en inspectant MethodInfo pour la signature de type attendue. Si la signature attendue est trouvée, le filtre de validation est inscrit sur le point de terminaison. Ce modèle de fabrique est utile pour inscrire un filtre qui dépend de la signature du gestionnaire de point de terminaison cible.
  • Si aucune signature correspondante n’est trouvée, un filtre pass-through est inscrit.

Inscrire un filtre sur les actions du contrôleur

Dans certains scénarios, il peut être nécessaire d’appliquer la même logique de filtre pour les points de terminaison basés sur le gestionnaire de routes et les actions du contrôleur. Pour ce scénario, il est possible d’appeler AddEndpointFilter sur ControllerActionEndpointConventionBuilder pour prendre en charge l’exécution de la même logique de filtre sur les actions et les points de terminaison.

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

Ressources complémentaires