Filtros en las aplicaciones de API mínimas

Por Fiyaz Bin Hasan, Martin Costelloy Rick Anderson

Los filtros de API mínimas permiten a los desarrolladores implementar lógica de negocios que admita:

  • Ejecución de código antes y después del controlador de ruta.
  • La inspección y modificación de parámetros proporcionados durante una invocación del controlador de ruta.
  • La interceptación del comportamiento de respuesta de un controlador de ruta.

Los filtros pueden ser útiles en los escenarios siguientes:

  • Validación de los parámetros de solicitud y el cuerpo que se envían a un punto de conexión.
  • Registro de información sobre la solicitud y la respuesta.
  • Validación de que una solicitud tiene como destino una versión de API compatible.

Los filtros se pueden registrar proporcionando un delegado que toma EndpointFilterInvocationContext y devuelve EndpointFilterDelegate. EndpointFilterInvocationContext proporciona acceso a HttpContext de la solicitud y a una lista Arguments que indica los argumentos pasados al controlador en el orden en que aparecen en la declaración del controlador.

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

El código anterior:

  • Llama al método de extensión AddEndpointFilter para agregar un filtro al punto de conexión /colorSelector/{color}.
  • Devuelve el color especificado excepto para el valor "Red".
  • Devuelve Results.Problem cuando se solicita /colorSelector/Red.
  • Usa next como EndpointFilterDelegate y invocationContext como EndpointFilterInvocationContext para invocar el siguiente filtro en la canalización o el delegado de solicitud si se ha invocado el último filtro.

El filtro se ejecuta antes del controlador del punto de conexión. Cuando se realizan varias invocaciones AddEndpointFilter en un controlador:

  • El código de filtro al que se llama antes de llamar a EndpointFilterDelegate (next) se ejecuta en orden de primero en entrar, primero en salir (FIFO).
  • El código de filtro al que se llama después de llamar a EndpointFilterDelegate (next) se ejecuta en orden de primero en entrar, último en salir (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();

En el código anterior, los filtros y el punto de conexión registran la siguiente salida:

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

El código siguiente usa filtros que implementan la interfaz 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();

En el código anterior, los registros de filtros y controladores muestran el orden en que se ejecutan:

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

Los filtros que implementan la interfaz IEndpointFilter se muestran en el ejemplo siguiente:


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

Validación de un objeto con un filtro

Considere un filtro que valide un objeto 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);
});

En el código anterior:

  • El objeto EndpointFilterInvocationContext proporciona acceso a los parámetros asociados a una solicitud determinada emitida al punto de conexión mediante el método GetArguments.
  • El filtro se registra mediante un objeto delegate que toma EndpointFilterInvocationContext y devuelve EndpointFilterDelegate.

Además de pasarse como delegados, los filtros se pueden registrar mediante la implementación de la interfaz IEndpointFilter. El código siguiente muestra el filtro anterior encapsulado en una clase que implementa 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);
    }
}

Los filtros que implementan la interfaz IEndpointFilter pueden resolver las dependencias de la inserción de dependencias, como se muestra en el código anterior. Aunque los filtros pueden resolver las dependencias de la inserción de dependencias, los propios filtros no se pueden resolver desde la inserción de dependencias.

ToDoIsValidFilter se aplica a los siguientes puntos de conexión:

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

El siguiente filtro valida el objeto Todo y modifica la propiedad 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);
    }
}

Registro de un filtro mediante una fábrica de filtros de punto de conexión

En algunos escenarios, es posible que sea necesario almacenar en caché parte de la información proporcionada en MethodInfo en un filtro. Por ejemplo, supongamos que queríamos comprobar que el controlador al que está asociado un filtro de punto de conexión tiene un primer parámetro que se evalúa como un tipo 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); 
});

En el código anterior:

  • El objeto EndpointFilterFactoryContext proporciona acceso a la clase MethodInfo asociada al controlador del punto de conexión.
  • La firma del controlador se examina inspeccionando MethodInfo para la firma de tipo esperada. Si se encuentra la firma esperada, el filtro de validación se registra en el punto de conexión. Este patrón de fábrica resulta útil para registrar un filtro que depende de la firma del controlador de ruta de destino.
  • Si no se encuentra una firma coincidente, se registra un filtro de tránsito.

Registro de un filtro en las acciones del controlador

En algunos escenarios, podría ser necesario aplicar la misma lógica de filtro para los puntos de conexión basados en controladores de ruta y las acciones del controlador. En este escenario, es posible invocar AddEndpointFilter en ControllerActionEndpointConventionBuilder para admitir la ejecución de la misma lógica de filtro en acciones y puntos de conexión.

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

Recursos adicionales