Minimal API アプリのフィルター

作成者: Fiyaz Bin HasanMartin CostelloRick Anderson

Minimal 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 を返します。
  • nextEndpointFilterDelegate として、invocationContextEndpointFilterInvocationContext として使用し、パイプライン内の次のフィルターか、最後のフィルターが呼び出された場合は要求デリゲートを呼び出します。

フィルターは、エンドポイント ハンドラーの前に実行されます。 ハンドラーで複数の 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 を検査して、ハンドラーのシグネチャが調べられます。 想定されているシグネチャが見つかった場合は、検証フィルターがエンドポイントに登録されます。 このファクトリ パターンは、ターゲット エンドポイント ハンドラーのシグネチャに依存するフィルターを登録する場合に便利です。
  • 一致するシグネチャが見つからない場合は、パススルー フィルターが登録されます。

コントローラー アクションにフィルターを登録する

一部のシナリオでは、ルートハンドラー ベースのエンドポイントとコントローラー アクションの両方に同じフィルター ロジックを適用する必要がある場合があります。 このシナリオでは、ControllerActionEndpointConventionBuilderAddEndpointFilter を呼び出して、アクションとエンドポイントで同じフィルター ロジックを実行することをサポートできます。

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

その他のリソース