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 - Minimal API with a filters and filter factories
- The
RequestDelegate
for a minimal API with a filter - Changes in
CreateArguments()
- Building the filter pipeline in
CreateFilterPipeline()
- Invoking the filter pipeline in
CreateTargetableRequestDelegate
- Handling the result of the filter pipeline
- Recap of the filter pipeline
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 parameter | EndpointFilterInvocationContext | EndpointFilterFactoryContext |
Return type | ValueTask<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 thenext
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 genericEndpointFilterInvocationContext
instance that contains a reference to theHttpContext
and the original model-bound arguments. - Instead of invoking the
handler
lambda directly, anEndpointFilterDelegate 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 astring
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) usingcontext.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 usingAddEndpointFilterFactory()
, 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
toValueTask<object?>
, regardless of what the handler method returns. - Converts the
factoryContext.MethodCall
to a call that creates a new instance of thefilterContext
, and runs the filter pipeline. It uses a genericEndpointFilterInvocationContext<T>
type for efficient "casting" in theGetArgument<T>
calls.
That latter point is interesting:
EndpointFilterInvocationContext
is a non-generic base type, but there are generic derived classesEndpointFilterInvocationContext<T>
,EndpointFilterInvocationContext<T1, T2>
etc (up to 10 generic arguments)! These generic types ensurestruct
arguments aren't boxed when callingGetArgument<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 whenwasParamCheckFailure == 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!