최소 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" 값을 제외하고 지정된 색을 반환합니다.
  • /colorSelector/Red가 요청되면 Results.Problem을 반환합니다.
  • 마지막 필터가 호출된 경우 EndpointFilterDelegatenext를 사용하고 EndpointFilterInvocationContextinvocationContext를 사용하여 파이프라인 또는 요청 대리자에서 다음 필터를 호출합니다.

필터는 엔드포인트 처리기 전에 실행됩니다. 처리기에서 여러 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 메서드를 통해 엔드포인트에 발급된 특정 요청과 연결된 매개 변수에 대한 액세스를 제공합니다.
  • 필터는 EndpointFilterInvocationContext를 사용하고 EndpointFilterDelegate를 반환하는 delegate를 사용하여 등록됩니다.

대리자로 전달되는 것 외에도 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에서 종속성을 확인할 수 있지만 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();

추가 리소스