blog post image
Andrew Lock avatar

Andrew Lock

~13 min read

Customising the RequestDelegate with filters

Behind the scenes of minimal APIs - Part 8

Throughout this series we've looked at the code generation for simple minimal API endpoints. As part of this process, I've been ignoring the "filters" feature added in .NET 7, as it adds a certain amount of complexity. Well, in this post we're tackling that complexity head-on to see how adding a filter changes the final generated RequestDelegate.

The RequestDelegate for a minimal API without filters

In this post, I'm focusing on how filters change the generated RequestDelegate, so we'll start by looking at the delegate for a minimal API without a filter:

app.MapGet("/{name}", (string name) => $"Hello {name}!");

If you've been following the series so far, you'll be able to work out that the generated delegate looks something like this:

Task (HttpContext httpContext) => TargetableRequestDelegate(Program.<>c, httpContext);

where TargetableRequestDelegate looks something like this:

Task TargetableRequestDelegate(object? target, HttpContext httpContext)
{
    string name_local;
    bool wasParamCheckFailure;

    name_local = httpContext.Request.RouteValues["name"]
    if (name_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
    }

    if(wasParamCheckFailure)
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    return ExecuteWriteStringResponseAsync(
        httpContext,
        handler(name_local));
}

I haven't shown the ExecuteWriteStringResponseAsync() method here but it's essentially the following, as shown in a previous post:

Task ExecuteWriteStringResponseAsync(HttpContext httpContext, string text)
{
    httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
    return httpContext.Response.WriteAsync(text);
}

So that's our starting point. Now lets add a simple filter to the endpoint and see how things change.

Minimal API with a filters and filter factories

For this post, we'll create a very basic filter that does some "validation" of the argument and returns a 400 Bad Request if it fails, and otherwise calls the handler:

app.MapGet("/{name}", (string name) => $"Hello {name}!")
    .AddEndpointFilter(async (context, next) =>
    {
        var name = context.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    {"name", new[]{"Invalid name"}}
                });
        }
        
        return await next(context); 
    });

Note that this is a very "endpoint-specific" filter, in that it makes significant assumptions about the handler method. This filter could also be written using the "filter factory" pattern as:

app.MapGet("/{name}", (string name) => $"Hello {name}!")
    .AddEndpointFilterFactory((context, next) =>
    {
        return async ctx =>
        {
            var name = ctx.GetArgument<string>(0);
            if (name is not "Sock")
            {
                return Results.ValidationProblem(
                    new Dictionary<string, string[]>
                    {
                        { "name", new[] { "Invalid name" } }
                    });
            }

            return await next(ctx);
        };
    });

Note the difference between AddEndpointFilter() and AddEndpointFilterFactory(), listed in the table below:

AddEndpointFilter()AddEndpointFilterFactory()
Context parameterEndpointFilterInvocationContextEndpointFilterFactoryContext
Return typeValueTask<object?>EndpointFilterDelegate

The EndpointFilterDelegate type is defined as follows:

public delegate ValueTask<object?> EndpointFilterDelegate(EndpointFilterInvocationContext context);

so it's effectively a Func<EndpointFilterInvocationContext, ValueTask<object?>>.

As the "filter" and "filter factory" names imply:

  • When you add a filter, you're adding code that runs as part of the RequestDelegate. The value you return from the filter is serialized to the response.
  • When add a filter factory, you're adding code that runs while building the RequestDelegate. The value returned from the filter factory is the code that runs in the pipeline. You can also return the next parameter to not add an additional filter to the pipeline.

Don't worry if this is all a bit confusing, I'll go into the patterns and differences between filters and filter factories in more detail in a separate post. I'm discussing it here because from a technical point of view, minimal APIs only deal in filter factories. The two APIs shown above are completely equivalent.

In fact, the AddEndpointFilter() method delegates directly to AddEndpointFilterFactory():

public static TBuilder AddEndpointFilter<TBuilder>(
    this TBuilder builder,
    Func<EndpointFilterInvocationContext, EndpointFilterDelegate, ValueTask<object?>> routeHandlerFilter)
    where TBuilder : IEndpointConventionBuilder
{
    return builder.AddEndpointFilterFactory(
        (routeHandlerContext, next) => 
            (context) => routeHandlerFilter(context, next));
}

The nested lambdas here are pretty confusing, but ultimately you end up with a filter factory as shown in the example above. So lets see how it changes the RequestDelegate.

The RequestDelegate for a minimal API with a filter

After adding the filter, things get quite a bit more complex in the RequestDelegate. The code below shows how the generated TargetableRequestDelegate() changes with the filter. There are three main differences:

  • The RequestDelegate creates a generic EndpointFilterInvocationContext instance that contains a reference to the HttpContext and the original model-bound arguments.
  • Instead of invoking the handler lambda directly, an EndpointFilterDelegate filterPipeline is invoked. This contains the nested "filter pipeline", which terminates in the endpoint handler.
  • The "response writing code" has to handle the ValueTask<object?> type instead of the endpoint handler's response (which is a string in our example).

Putting all this together, gives a TargetableRequestDelegate() that looks something the following. I've highlighted the differences from the original RequestDelegate in the code below

Task TargetableRequestDelegate(object? target, HttpContext httpContext)
{
    string name_local;
    bool wasParamCheckFailure;

    name_local = httpContext.Request.RouteValues["name"]
    if (name_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
    }

    if(wasParamCheckFailure)
    {
        httpContext.Response.StatusCode = 400;
    }

    // πŸ‘‡ Create a generic EndpointFilterInvocationContext<> and use it
    // to call the filter pipeline
    var filterContext = new EndpointFilterInvocationContext<string>(httpContext, name_local)
    ValueTask<object?> result = filterPipeline.Invoke(filterContext); 

    // πŸ‘‡ Handle the result of the filter pipeline, which is always
    // a ValueTask<object?>, but which may wrap many different 
    // types depending on the exact error path taken
    return ExecuteValueTaskOfObject(result, httpContext);

    // πŸ‘‡ The "full" filter pipeline. As we only have a single
    // filter in our example, there's only one level of "nesting" here
    ValueTask<object?> filterPipeline(EndpointFilterInvocationContext context)
    {
        // πŸ‘‡ Our filter
        var name = ctx.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    { "name", new[] { "Invalid name" } }
                });
        }

        // πŸ‘‡Call the "inner" handler
        return await filteredInvocation(ctx);
    }

    // πŸ‘‡ The "innermost" filter in the pipeline, which invokes the handler method
    ValueTask<object?> filteredInvocation(EndpointFilterInvocationContext context)
    {
        if(context.HttpContext.Response.StatusCode >= 400)
        {
            // Already errored, don't run the handler method
            return ValueTask.CompletedTask;  
        }
        else
        {
            // Create the "instance" of the target (if any)
            var target = targetFactory(context.HttpContext); 
            // Execute the handler using the arguments from the filter context
            // and wrap the result in in a ValueTask
            return ValueTask.FromResult<object?>( 
                target.handler.Invoke(context.GetArgument<string>(0)) // the actual handler execution
            );
        }
    }
}

As promised, there's quite a lot going on here. For the rest of the post we'll look at how the RequestDelegate building changes when the endpoint has filters.

This post assumes you've been following along in the series, so I'm mostly touching on changes to the no-filter behaviour.

Changes in CreateArguments()

The first changes in the RequestDelegate generation occur in the CreateArguments() function. After generating the Expression to model-bind the arguments as discussed in previous posts, n additional Expression is generated for each handler argument. This Expression is for retrieving each argument from the EndpointFilterInvocationContext, similar to the following:

context.GetArgument<string>(0);

When dynamic code is not supported, the generated expression retrieves the object from an IList<object?> (which is a boxing operation for value types) using context.Arguments[0].

This extra Expression is stored in factoryContext.ContextArgAccess, and is used to invoke the handler method in the final step of the filter pipeline.

Building the filter pipeline in CreateFilterPipeline()

The next big change is in CreateTargetableRequestDelegate() where we call CreateFilterPipeline() to create an EndpointFilterDelegate:

EndpointFilterDelegate? filterPipeline = null

if (factoryContext.EndpointBuilder.FilterFactories.Count > 0)
{
    filterPipeline = CreateFilterPipeline(methodInfo, targetExpression, factoryContext, targetFactory);

    // ... more processing, shown below
}

CreateFilterPipeline() is responsible for building up the pipeline, and has the following signature:

private static EndpointFilterDelegate? CreateFilterPipeline(
    MethodInfo methodInfo, // the original endpoint handler method
    Expression? targetExpression, // the 'target' parameter cast to the correct type, e.g. (Program)target
    RequestDelegateFactoryContext factoryContext, // the factory context for building the RequestDelegate
    Expression<Func<HttpContext, object?>>? targetFactory) // _ => handler.Target; 

I'll break down each step of this method in the following sections as it gradually builds up the filter pipeline.

Creating the final handler invocation

Similar to building a middleware pipeline and other "Matryoshka doll" designs, CreateFilterPipeline() starts with the "innermost" handler, and successively wraps extra handlers around it. First, it creates the call to the inner handler using:

targetExpression is null
    ? Expression.Call(methodInfo, factoryContext.ContextArgAccess)
    : Expression.Call(targetExpression, methodInfo, factoryContext.ContextArgAccess)

This expression invokes the original endpoint handler method using the context.GetArgument<string>(0) expressions:

target.handler.Invoke(context.GetArgument<string>(0));

This expression is passed as an argument to the MapHandlerReturnTypeToValueTask() method, along with the original handler's return type (string in our case):

Expression handlerReturnMapping = MapHandlerReturnTypeToValueTask(
    targetExpression is null        // target.handler.Invoke(context.GetArgument<string>(0));
        ? Expression.Call(methodInfo, factoryContext.ContextArgAccess)
        : Expression.Call(targetExpression, methodInfo, factoryContext.ContextArgAccess),
    methodInfo.ReturnType);         // string

MapHandlerReturnTypeToValueTask() is responsible for changing the return type of the endpoint handler method into a ValueTask. Note that this is not about writing the response, this is purely about turning it into a ValueTask<object?> so that it can fit in the filter pipeline as an EndpointFilterDelegate.

For our minimal API example (returning a string), MapHandlerReturnTypeToValueTask() calls the WrapObjectAsValueTask method which simply wraps result of the handler call in a ValueTask<object?>, giving code similar to:

return ValueTask.FromResult<object?>(
    target.handler.Invoke(context.GetArgument<string>(0))
);

Creating the target instance

Next up, CreateFilterPipeline() creates the target instance using the targetFactory. I discussed these briefly in the previous post: target is the instance on which the handler method is invoked. For a lambda method, that's the containing class, but it could be null if you're using a static method for example.

BlockExpression handlerInvocation = Expression.Block(
    new[] { TargetExpr },
    targetFactory == null
        ? Expression.Empty()
        : Expression.Assign(TargetExpr, Expression.Invoke(targetFactory, FilterContextHttpContextExpr)),
    handlerReturnMapping
);

This creates an expression that looks something like this, where targetFactory is defined as _ => handler.Target for our minimal API example:

target = targetFactory(context.HttpContext);
return ValueTask.FromResult<object?>(                     // from handlerReturnMapping
    target.handler.Invoke(context.GetArgument<string>(0)) // 
);

For static methods, targetFactory will be null, so the whole handlerInvocation would look something like this:

return ValueTask.FromResult<object?>(              // from handlerReturnMapping
    handler.Invoke(context.GetArgument<string>(0)) // 
);

We now need to turn this handler invocation into an EndpointFilterDelegate.

Building the final inner handler

The next call in CreateFilterPipeline() creates the inner handler by defining an Expression as follows:

EndpointFilterDelegate filteredInvocation = Expression.Lambda<EndpointFilterDelegate>(
    Expression.Condition(
        Expression.GreaterThanOrEqual(FilterContextHttpContextStatusCodeExpr, Expression.Constant(400)),
        CompletedValueTaskExpr,
        handlerInvocation),
    FilterContextExpr).Compile();

This Expression code translates to something like this for our minimal API:

if(context.HttpContext.Response.StatusCode >= 400)
{
    return ValueTask.CompletedTask;
}
else
{
    target = targetFactory(context.HttpContext);              // 
    return ValueTask.FromResult<object?>(                     // from handlerInvocation
        target.handler.Invoke(context.GetArgument<string>(0)) // 
    );
}

This is then compiled into an EndpointFilterDelegate, so it effectively becomes a Func<EndpointFilterInvocationContext, ValueTask<object?>> that looks like this:

ValueTask<object?> filteredInvocation(EndpointFilterInvocationContext context)
{
    if(context.HttpContext.Response.StatusCode >= 400)
    {
        return ValueTask.CompletedTask;
    }
    else
    {
        var target = targetFactory(context.HttpContext);          // 
        return ValueTask.FromResult<object?>(                     // from handlerInvocation
            target.handler.Invoke(context.GetArgument<string>(0)) // 
        );
    }
}

As you can see, this final handler bypasses the handler invocation if any of the filters set an error response code. The response of the handler method is wrapped in a ValueTask and returned from the handler. This lets the handler slot into the rest of the filter pipeline.

Building up the filter pipeline

We now have most of the bits ready so we can actually invoke the filter factories. Remember, filter factories contain code that runs now, and return an EndpointFilterDelegate. CreateFilterPipeline() loops through each factory (from last-to-first), passing in the EndpointFilterFactoryContext, and the "remainder" of the filter pipeline as the next parameter:

var routeHandlerContext = new EndpointFilterFactoryContext
{
    MethodInfo = methodInfo,           // this is the original handler
    ApplicationServices = factoryContext.EndpointBuilder.ApplicationServices,
};

var initialFilteredInvocation = filteredInvocation;

// πŸ‘‡Loop through all registered factories starting from the last filter added
// The "last" filter added will be the "innermost" filter, which executes _after_
// the "outer" filters, hence the reversal
for (var i = factoryContext.EndpointBuilder.FilterFactories.Count - 1; i >= 0; i--)
{
    var currentFilterFactory = factoryContext.EndpointBuilder.FilterFactories[i];
    
    // invoke the factory, passing in the context and the filtered pipeline so-far
    filteredInvocation = currentFilterFactory(routeHandlerContext, filteredInvocation);
}

For each filter the filteredInvocation is passed as the next parameter. So if we think about our example which has a single filter, currentFilterFactory will be the "factory" version of our original filter:

(EndpointFilterFactoryContext context, EndpointFilterDelegate next) =>
{
    return async ctx =>
    {
        var name = ctx.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    { "name", new[] { "Invalid name" } }
                });
        }

        return await next(ctx);
    };
}

The context parameter is the routeHandlerContext defined in the previous code block, and the filteredInvocation is passed as next. This happens repeatedly for each filter factory, so you successively nest the filters.

After all the filter factories are executed, CreateFilterPipeline() returns the final filtered pipeline or null if the filter factories didn't modify the pipeline at all.

Remember, if you use add filters using AddEndpointFilter() , the filter will always run. If you add filters using AddEndpointFilterFactory(), you can choose to not add the filter to an endpoint, and have zero runtime impact.

// The filter factories have run without modifying per-request behavior, 
// so we can skip running the pipeline.
if (ReferenceEquals(initialFilteredInvocation, filteredInvocation))
{
    return null;
}

return filteredInvocation;

That covers the CreateFilterPipeline() method, so now lets go back to looks at where it was called in CreateTargetableRequestDelegate().

Invoking the filter pipeline in CreateTargetableRequestDelegate

We invoked CreateFilterPipeline() from CreateTargetableRequestDelegate when building up the final RequestDelegate. We can now look at the rest of the changes:

EndpointFilterDelegate? filterPipeline = null;
var returnType = methodInfo.ReturnType;

if (factoryContext.EndpointBuilder.FilterFactories.Count > 0)
{
    // Build the filter pipeline in CreateFilterPipeline()
    filterPipeline = CreateFilterPipeline(methodInfo, targetExpression, factoryContext, targetFactory);

    // If we actually added any filters
    if (filterPipeline is not null)
    {
        // Create an expression that invokes the filter pipeline
        Expression<Func<EndpointFilterInvocationContext, ValueTask<object?>>> invokePipeline = 
            (context) => filterPipeline(context);

        // The return type of the pipeline is always ValueTask<object?>,
        // regardless of what the original handler return type was.
        returnType = typeof(ValueTask<object?>);
        
        // Change the "handler" method to be the pipeline invocation instead
        factoryContext.MethodCall = Expression.Block(
            new[] { InvokedFilterContextExpr },
            Expression.Assign(
                InvokedFilterContextExpr,
                CreateEndpointFilterInvocationContextBase(factoryContext, factoryContext.ArgumentExpressions)),
            Expression.Invoke(invokePipeline, InvokedFilterContextExpr)
        );
    }
}

This code does two things:

  • Changes the final returnType to ValueTask<object?>, regardless of what the handler method returns.
  • Converts the factoryContext.MethodCall to a call that creates a new instance of the filterContext, and runs the filter pipeline. It uses a generic EndpointFilterInvocationContext<T> type for efficient "casting" in the GetArgument<T> calls.

That latter point is interesting: EndpointFilterInvocationContext is a non-generic base type, but there are generic derived classes EndpointFilterInvocationContext<T>, EndpointFilterInvocationContext<T1, T2> etc (up to 10 generic arguments)! These generic types ensure struct arguments aren't boxed when calling GetArgument<T>.

All this gives code that looks something like this for our example:

EndpointFilterInvocationContext filterContext = 
    new EndpointFilterInvocationContext<string>(httpContext, name_local)
invokePipeline.Invoke(filterContext);

This invokes the filter pipeline, but we still need to handle the result of the filter.

Handling the result of the filter pipeline

The filter pipeline wraps the handler invocation, but we still need to handle the result of the filter pipeline and add the parameter check code. Both of these steps differ from the simple cast when we have filters:

  • The filter pipeline always return ValueTask<object?>, instead of the handler result, so we need to change how the result is serialized to the response.
  • Without filters, a Task.CompletedTask is returned when wasParamCheckFailure == true, before the handler is invoked. We can't do this with the filter pipeline, because we always need to invoke the filters, regardless of whether model-binding was successful.

The latter point is simple to achieve: in CreateParamCheckingResponseWritingMethodCall(), we change the validation clause to set the status code to 400, but not to return a Task.CompletedTask. So instead of this:

if(wasParamCheckFailure)
{
    httpContext.Response.StatusCode = 400;
    return Task.CompletedTask;
}

we generate this:

if(wasParamCheckFailure)
{
    httpContext.Response.StatusCode = 400;
}

As you saw earlier, the inner-most filter (which invokes the endpoint handler) bypasses the handler if the status code is an error code after the filters execute.

Finally, AddResponseWritingToMethodCall() adds the code to handle the ValueTask<object?>, by awaiting it and generating the appropriate response-writing method, as described in a previous post. There's nothing specific to the filter-pipeline here, other than that we always need the same response handling, as the filter pipeline always returns ValueTask<object?>.

And with that, we're done!

Recap of the filter pipeline RequestDelegate

To finish off, we'll take one more look at the final "effective" RequestDelegate for our "filtered" minimal API:

Task TargetableRequestDelegate(object? target, HttpContext httpContext)
{
    string name_local;
    bool wasParamCheckFailure;

    name_local = httpContext.Request.RouteValues["name"]
    if (name_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
    }

    if(wasParamCheckFailure)
    {
        httpContext.Response.StatusCode = 400;
        // Note that we _don't_ return Task.CompletedTask here
    }

    var filterContext = new EndpointFilterInvocationContext<string>(httpContext, name_local)
    // Invoke the filter pieline
    ValueTask<object?> result = filterPipeline.Invoke(filterContext); 

    // Handle the result of the filter pipeline and serialize
    return ExecuteValueTaskOfObject(result, httpContext);

    // The filter pipeline for our handler (single level of nesting)
    ValueTask<object?> filterPipeline(EndpointFilterInvocationContext context)
    {
        var name = ctx.GetArgument<string>(0);
        if (name is not "Sock")
        {
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    { "name", new[] { "Invalid name" } }
                });
        }

        // Call the "inner" handler
        return await filteredInvocation(ctx);
    }

    // The "innermost" filter in the pipeline, which invokes the handler method
    ValueTask<object?> filteredInvocation(EndpointFilterInvocationContext context)
    {
        if(context.HttpContext.Response.StatusCode >= 400)
        {
            // Already errored, don't run the handler method
            return ValueTask.CompletedTask;  
        }
        else
        {
            // Create the "instance" of the target (if any)
            var target = targetFactory(context.HttpContext); 
            // Execute the handler using the arguments from the filter context
            // and wrap the result in in a ValueTask
            return ValueTask.FromResult<object?>( 
                target.handler.Invoke(context.GetArgument<string>(0)) // the actual handler execution
            );
        }
    }
}

And with that, we're finished with this behind-the scenes look of minimal APIs. This series has obviously been very in-depth. There's absolutely no need to know or even understand all this to use minimal APIs, but I personally find it really interesting to see how the sausage is made!

Summary

In this post we looked at how minimal API filters and filter factories change the final RequestDelegate created for the endpoint. We started by looking at the final RequestDelegate created for a filtered endpoint handler. There are several important differences compared to the un-filtered RequestDelegate:

  • When argument binding fails, we don't immediately short-circuit as in the un-filtered case. Instead, the filters still execute, and the binding error is handled in the "innermost" handler.
  • The result of the endpoint handler is always wrapped in a ValueTask<object?>.
  • The EndpointFilterInvocationContext<> context object is custom created for each endpoint handler, to match the number and type of the endpoint handler's arguments.
  • If a filter factory doesn't customize the EndpointFilterDelegate, it doesn't add to the pipeline at all.

The main takeaway is that filters in minimal APIs are implemented in pretty much the most efficient way they could be. They only add overhead to the specific endpoints they apply to, and they're compiled into the final RequestDelegate in a very efficient manner.

That brings us to the end of this in-depth look at minimal APIs. Personally I'm impressed with how the RequestDelegateFactory works to make minimal APIs pretty much as efficient as they can be!

Andrew Lock | .Net Escapades
Want an email when
there's new posts?