In the previous post, I took a relatively high-level look at how a simple minimal API such as MapGet("/", () => "Hello world"
is turned into a RequestDelegate
that can be invoked by the EndpointMiddleware
. In this post I focus in on the RequestDelegateFactory.InferMetadata()
method, which I mentioned in the previous post, to see how it works.
Inferring metadata from the delegate
In the previous post, much of the focus was on the CreateRouteEndpointBuilder()
method. That method is called when your app first receives a request and is responsible for converting the RouteEntry
and RoutePattern
associated with an endpoint into a RouteEndpointBuilder
. This is then used to build a RouteEndpoint
which can be invoked to handle a given request.
CreateRouteEndpointBuilder()
is responsible both for compiling the list of metadata for the endpoint and for creating a RequestDelegate
for the endpoint. As a reminder, the RequestDelegate
is the function that's actually invoked to handle a request, and looks like this:
public delegate Task RequestDelegate(HttpContext context);
Notice that this signature does not match the lambda delegate
we passed in MapGet
, which for the Hello World example of () => "Hello world!"
, has a signature that looks like this:
public string SomeMethod();
CreateRouteEndpointBuilder()
, in conjunction with the RequestDelegateFactory
class, is responsible for creating a function that matches the RequestDelegate
signature based on the provided delegate.
As part of creating this method, the RequestDelegateFactory
needs to analyze the provided delegate
to determine the parameter types and return types the handler uses. The RequestDelegateFactory
needs this information so it can emit code that bind the method's arguments to route values, to services in the DI container, or to the request body, for example.
Consider a slightly more complex example that injects the HttpRequest
object into the handler: MapGet("/", (HttpRequest request) => "Hello world!")
. The RequestDelegateFactory
must create a method that has the required RequestDelegate
signature, but which creates the arguments necessary to call the handler method. The final result looks a bit like the following:
Task Invoke(HttpContext httpContext)
{
// handler is the original lambda handler method.
// The HttpRequest parameter has been automatically created from the HttpContext argument
string text = handler.Invoke(httpContext.Request);
// The return value is written to the response as expected
httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
return httpContext.Response.WriteAsync(text);
}
The minimal API infrastructure builds a method like this using
Expression
s, which is part of the reason minimal APIs can be so fast; it's a bit like you wrote this code manually for each of your endpoints. Of course, it isn't trivial building these expressions, which is why this series is quite long!
Some of the information about the handler function and its parameters is explicitly added as metadata to the endpoint, as well as being used to build the RequestDelegate
directly. In the next section we'll walk through the RequestDelegateFactory.InferMetadata()
function to see how this information is inferred.
Inferring metadata about a handler method
In the previous post I showed the RouteEndpointDataSource.CreateRDFOptions()
method which creates a RequestDelegateFactoryOptions
instance based on a handler method RouteEntry
and RoutePattern
. This options object is a simple bag of options for controlling how the final RequestDelegate
is created. Most of the properties on the object are fairly self explanatory, but I've summarised their purposes below:
public sealed class RequestDelegateFactoryOptions
{
// The DI container for the app
public IServiceProvider? ServiceProvider { get; init; }
// The names of any route parameters in the route.
// e.g. for the route /view/{organsiation}/{id} contains two
// values, "organsiation" and "id"
public IEnumerable<string>? RouteParameterNames { get; init; }
// True if the RequestDelegate should throw for bad requests
public bool ThrowOnBadRequest { get; init; }
// Should the RequestDelegate try to bind the request body by default
// See previous post for a detailed explanation
public bool DisableInferBodyFromParameters { get; init; }
// Used to help build the RequestDelegate and to apply filters to endpoints
public EndpointBuilder? EndpointBuilder { get; init; }
}
You can read how the CreateRouteEndpointBuilder()
method creates this object by calling CreateRDFOptions
in the previous post. After creating the options, we have the call to RequestDelegateFactory.InferMetadata
, shown below.
public static RequestDelegateMetadataResult InferMetadata(
MethodInfo methodInfo, // 👈 The reflection information about the handler method
RequestDelegateFactoryOptions? options = null) // 👈 The options object above
{
// Create a "context" object (shown shortly)
RequestDelegateFactoryContext factoryContext = CreateFactoryContext(options);
// Read information about the handler method's arguments
factoryContext.ArgumentExpressions = CreateArgumentsAndInferMetadata(methodInfo, factoryContext);
// Save the metadata in a result object which is later used to create the RequestDelegate
return new RequestDelegateMetadataResult
{
EndpointMetadata = AsReadOnlyList(factoryContext.EndpointBuilder.Metadata),
};
}
This method takes a MethodInfo
object, which contains all the "reflection" information about the method. Note that in my minimal API endpoint examples I've been showing a "lambda" handler, but you would get similar information if you were using a static method, an instance method, or a local function as your handler.
InferMetadata
, shown above, consists of two main steps:
- Create a
RequestDelegateFactoryContext
"context" object - Infer information about the handler method's parameters
The CreateFactoryContext
method is called both here in InferMetadata()
and later when creating the RequestDelegate
in RequestDelegateFactory.Create()
, so it has several optional parameters. Only the first parameter is provided when inferring the metadata, so a lot of the method is unused at this point. The outline below shows a simplified version of the CreateFactoryContext
, taking that into account:
private static RequestDelegateFactoryContext CreateFactoryContext(
RequestDelegateFactoryOptions? options,
RequestDelegateMetadataResult? metadataResult = null, // 👈 always null in InferMetadata
Delegate? handler = null) // 👈 always null in InferMetadata
{
if (metadataResult?.CachedFactoryContext is not null)
{
// details hidden, because metadataResult is null in InferMetadata so this is not called
}
// ServiceProvider is non-null and set in CreateRDFOptions()
IServiceProvider serviceProvider = options?.ServiceProvider;
// EndpointBuilder is null in InferMetadata, so always creates a new builder
var endpointBuilder = options?.EndpointBuilder ?? new RDFEndpointBuilder(serviceProvider);
var factoryContext = new RequestDelegateFactoryContext
{
Handler = handler, // null as not provided in InferMetadata
ServiceProvider = options.ServiceProvider,
ServiceProviderIsService = serviceProvider.GetService<IServiceProviderIsService>(),
RouteParameters = options?.RouteParameterNames?.ToList(),
ThrowOnBadRequest = options?.ThrowOnBadRequest ?? false,
DisableInferredFromBody = options?.DisableInferBodyFromParameters ?? false,
EndpointBuilder = endpointBuilder
MetadataAlreadyInferred = metadataResult is not null, // false
};
return factoryContext;
}
As you can see in the snippet above, RequestDelegateFactoryContext
mostly contains copies of the values from RequestDelegateFactoryOptions
. The RDFEndpointBuilder
is a very simple implementation of EndpointBuilder
that prevents you calling Build()
. It's primary purpose is as part of the "filter" infrastructure for minimal APIs.
private sealed class RDFEndpointBuilder : EndpointBuilder
{
public RDFEndpointBuilder(IServiceProvider applicationServices)
{
ApplicationServices = applicationServices;
}
public override Endpoint Build() => throw new NotSupportedException();
}
Once the RequestDelegateFactoryContext
is created, the next step is the big one—analyzing the parameters of the handler to work out how to create them (from request parameters, services etc) and adding the metadata to the endpoint's collection.
Analyzing a handler's parameters
The RequestDelegateFactory
analyzes the handler's MethodInfo
parameters in CreateArgumentsAndInferMetadata()
, passing in the handler method, and the new context object:
private static Expression[] CreateArgumentsAndInferMetadata(
MethodInfo methodInfo, RequestDelegateFactoryContext factoryContext)
{
// Add any default accepts metadata. This does a lot of reflection
// and expression tree building, so the results are cached in
// RequestDelegateFactoryOptions.FactoryContext dor later reuse
// in RequestDelegateFactory.Create()
var args = CreateArguments(methodInfo.GetParameters(), factoryContext);
if (!factoryContext.MetadataAlreadyInferred)
{
PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext.EndpointBuilder);
// Add metadata provided by the delegate return type and parameter
// types next, this will be more specific than inferred metadata from above
EndpointMetadataPopulator.PopulateMetadata(
methodInfo,
factoryContext.EndpointBuilder,
factoryContext.Parameters);
}
return args;
}
The comments and method names in this method explain pretty well what's going on, but an important point to realize here is that we're taking the MethodInfo
and generating an array of Expression[]
; one Expression
for each parameter the handler method accepts.
Building expression trees can be…intense. In this post we're taking a relatively high-level look at how the arguments are inspected, and in subsequent posts we'll look in detail at how the expressions are created to implement the minimal API model-binding behaviour.
In the next section we'll take a high-level look at the call to CreateArguments()
where an Expression
for each of the handler method's parameters are created.
Creating the argument expressions
The first method call in CreateArgumentsAndInferMetadata()
is CreateArguments()
, passing in the ParameterInfo[]
details for the handler method, as well as the context object. This method is a little long to read in one code block, so I'll break it down into sections and discuss each as we go.
private static Expression[] CreateArguments(
ParameterInfo[]? parameters, RequestDelegateFactoryContext factoryContext)
{
if (parameters is null || parameters.Length == 0)
{
return Array.Empty<Expression>();
}
var args = new Expression[parameters.Length];
factoryContext.ArgumentTypes = new Type[parameters.Length];
factoryContext.BoxedArgs = new Expression[parameters.Length];
factoryContext.Parameters = new List<ParameterInfo>(parameters);
var hasFilters = factoryContext.EndpointBuilder.FilterFactories.Count > 0;
// ...
}
The parameters
array argument contains a ParameterInfo
instance for each of the parameters in the endpoint handler. If the handler takes no parameters, then the parameters
array is empty, and there's nothing more to do. Otherwise, we initialize some arrays that we use to record metadata about the parameters. We also check if the endpoint has any filters applied to it, as we can avoid some work if there aren't any.
I look at how filters are applied in the
RequestDelegateFactory
in detail in a subsequent post in this series. Alternatively, you can read this explanation by Safia Abdalla, who worked on the filters feature!
After initializing all the arrays, we next loop over the parameters of the method, and create the Expression
used to bind the argument to the request:
for (var i = 0; i < parameters.Length; i++)
{
args[i] = CreateArgument(parameters[i], factoryContext);
if (hasFilters)
{
// If the method has filters, create the expressions
// used to build EndpointFilterInvocationContext
}
factoryContext.ArgumentTypes[i] = parameters[i].ParameterType;
factoryContext.BoxedArgs[i] = Expression.Convert(args[i], typeof(object));
}
The CreateArgument()
method here does a lot of work. It takes the ParameterInfo
and reads various metadata to establish how to create an Expression
that can be used to "create" the argument to the handler. For example, at the start of this post you saw that a HttpRequest
parameter would be created using the expression httpContext.Request
:
Task Invoke(HttpContext httpContext)
{
string text = handler.Invoke(httpContext.Request);
// ...
}
Depending on the source of the argument (i.e. based on the model-binding behaviour) a different expression is created. For example, if the parameter should bind to a query parameter, such as in the endpoint MapGet("/", (string? search) => search)
, an expression that reads from the querystring would be created:
Task Invoke(HttpContext httpContext)
{
string text = handler.Invoke(httpContext.Request.Query["search"]);
// ...
}
For the above example, an Expression
representing
httpContext.Request.Query["search"]
would be returned from CreateArgument()
, and stored in the args
array. Additional details would also be set on the RequestDelegateFactoryContext
to reduce rework in RequestDelegateFactory.Create()
. Where appropriate, CreateArgument
also infers [Accepts]
metadata about the expected request body format, and adds it to the metadata collection.
The exact logic in
CreateArgument()
controls the model-binding logic and precedence for minimal APIs and is pretty complex, so I'm going to look in detail at this method in the next post.
A "boxed" version of the Expression
(where the expression result is cast to object
) is also created for use in endpoint filters (where required), and the type of the parameter is stored in the factoryContext
.
The final step in the CreateArguments()
method is to do some sanity checks, checking for common unsupported scenarios, before returning the generated Expression[] args
// Did we try to infer binding to the body when we shouldn't?
// (see previous post for details on how DisableInferredFromBody is calculated)
if (factoryContext.HasInferredBody && factoryContext.DisableInferredFromBody)
{
var errorMessage = BuildErrorMessageForInferredBodyParameter(factoryContext);
throw new InvalidOperationException(errorMessage);
}
// Did we try and bind to both JSON body and Form body?
// It can't be both!
if (factoryContext.JsonRequestBodyParameter is not null &&
factoryContext.FirstFormRequestBodyParameter is not null)
{
var errorMessage = BuildErrorMessageForFormAndJsonBodyParameters(factoryContext);
throw new InvalidOperationException(errorMessage);
}
// Did we try to bind to the body multiple times?
if (factoryContext.HasMultipleBodyParameters)
{
var errorMessage = BuildErrorMessageForMultipleBodyParameters(factoryContext);
throw new InvalidOperationException(errorMessage);
}
return args;
We now have an Expression
for each of the handler's parameters, describing how it should be created given the HttpContext
parameter available. The next step is to read the return value of the handler method, and use that to infer the HTTP response the endpoint generates.
Inferring the HTTP response type from the handler
The PopulateBuiltInResponseTypeMetadata()
method is responsible for effectively trying to infer a [Produces]
attribute for the handler, based on the return type of the method, and adding it to the metadata collection. You can read the detailed logic of the method here, but it effectively uses the following sequence.
- Is the return type
Task<T>
orValueTask<T>
(or otherawait
able type)?- If yes, treat the return type as type
T
and continue.
- If yes, treat the return type as type
- Is the return type
void
,Task
,ValueTask
, orIResult
?- If yes, can't infer the response type, so return.
- Is the return type
string
?- If yes, add
[Produces("text/plain")]
- If no, add
[Produces(returnType)]
- If yes, add
Note that as of .NET 7, IResult
types can implement IEndpointMetadataProvider
to provide additional [Produces]
information (for example by using the TypedResults
helpers), but IEndpointMetadataProvider
isn't handled in the PopulateBuiltInResponseTypeMetadata()
method. Instead, that's handled in the next method.
Populating metadata from self-describing parameters
.NET 7 added the IEndpointMetadataProvider
and IEndpointParameterMetadataProvider
interface to allow your handler parameters and return types to be "self describing". Parameters that implement one (or both) of these interfaces can populate metadata about themselves, which generally means you need fewer attributes and fluent methods to describe your APIs for OpenAPI.
The interfaces both contain a single static abstract
method, so you implement them by implementing a static
method on your class:
public interface IEndpointMetadataProvider
{
static abstract void PopulateMetadata(MethodInfo method, EndpointBuilder builder);
}
public interface IEndpointParameterMetadataProvider
{
static abstract void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder);
}
The static
part is important, as it means the RequestDelegateFactory
can read the metadata details once, without having an instance of your parameter. This happens in the RequestDelegateFactory.PopulateBuiltInResponseTypeMetadata()
method, which calls the helper method EndpointMetadataPopulator.PopulateMetadata()
, passing in the handler method, the EndpointBuilder
, and the handler parameters.
I've reproduced the EndpointMetadataPopulator
in full below, and added some additional explanatory comments. The PopulateMetadata()
method loops over each of the method parameters, checks if it implements either of the interfaces, and calls the implemented function if so. It has to do all this using reflection though, which makes it a little harder to follow!
internal static class EndpointMetadataPopulator
{
public static void PopulateMetadata(
MethodInfo methodInfo,
EndpointBuilder builder,
IEnumerable<ParameterInfo>? parameters = null)
{
// This array variable is created here so the array
// can be "reused" for each argument, reducing allocations
object?[]? invokeArgs = null;
parameters ??= methodInfo.GetParameters();
// Get metadata from parameter types
foreach (var parameter in parameters)
{
if (typeof(IEndpointParameterMetadataProvider)
.IsAssignableFrom(parameter.ParameterType))
{
// Parameter type implements IEndpointParameterMetadataProvider
invokeArgs ??= new object[2];
invokeArgs[0] = parameter;
invokeArgs[1] = builder;
// Use reflection to invoke the PopulateMetadata on the parameter type
// Using the generic method in between is a sneaky way
// to do some implicit caching
PopulateMetadataForParameterMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs);
}
if (typeof(IEndpointMetadataProvider).IsAssignableFrom(parameter.ParameterType))
{
// Parameter type implements IEndpointMetadataProvider
invokeArgs ??= new object[2];
invokeArgs[0] = methodInfo;
invokeArgs[1] = builder;
// Use reflection to invoke the PopulateMetadata on the parameter type
// Using the generic method in between is a sneaky way
// to do some implicit caching
PopulateMetadataForEndpointMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs);
}
}
// Get metadata from return type
var returnType = methodInfo.ReturnType;
if (AwaitableInfo.IsTypeAwaitable(returnType, out var awaitableInfo))
{
// If it's a Task<T> or ValueTask<T>, use the T as the returnType
returnType = awaitableInfo.ResultType;
}
if (returnType is not null
&& typeof(IEndpointMetadataProvider).IsAssignableFrom(returnType))
{
// Return type implements IEndpointMetadataProvider
invokeArgs ??= new object[2];
invokeArgs[0] = methodInfo;
invokeArgs[1] = builder;
// Use reflection to invoke the PopulateMetadata on the return type
PopulateMetadataForEndpointMethod.MakeGenericMethod(returnType).Invoke(null, invokeArgs);
}
}
// Helper methods and properties for efficiently calling the interface members
private static readonly MethodInfo PopulateMetadataForParameterMethod = typeof(EndpointMetadataPopulator).GetMethod(nameof(PopulateMetadataForParameter), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo PopulateMetadataForEndpointMethod = typeof(EndpointMetadataPopulator).GetMethod(nameof(PopulateMetadataForEndpoint), BindingFlags.NonPublic | BindingFlags.Static)!;
private static void PopulateMetadataForParameter<T>(ParameterInfo parameter, EndpointBuilder builder)
where T : IEndpointParameterMetadataProvider
{
T.PopulateMetadata(parameter, builder);
}
private static void PopulateMetadataForEndpoint<T>(MethodInfo method, EndpointBuilder builder)
where T : IEndpointMetadataProvider
{
T.PopulateMetadata(method, builder);
}
}
That brings us to the end of the RequestDelegateFactory.CreateArgumentsAndInferMetadata()
method. At this point, the Expression
for each of the handler's parameters have been created, including determining where the argument should be bound from (based on minimal API model-binding rules—more on that in the next post). All the metadata associated with this process has been populated and added to the EndpointBuilder
's metadata list. All that remains at this point is to return a RequestDelegateMetadataResult
from InferMetadata()
containing all the metadata we inferred from the handler function:
public static RequestDelegateMetadataResult InferMetadata(MethodInfo methodInfo, RequestDelegateFactoryOptions? options = null)
{
var factoryContext = CreateFactoryContext(options);
factoryContext.ArgumentExpressions = CreateArgumentsAndInferMetadata(methodInfo, factoryContext);
// return the metadata as an IReadOnlyList<T>
return new RequestDelegateMetadataResult
{
EndpointMetadata = AsReadOnlyList(factoryContext.EndpointBuilder.Metadata),
};
}
I was surprised to see that the
CachedFactoryContext
property is not set on the return result object. This results in a lot of re-building of theExpression
trees, so I raised an issue about it here and which is fixed for .NET 8, and which hopefully will be backported to .NET 7!
Finally, we've made it to the end of our first look at RequestDelegateFactory.InferMetadata()
. One important method I glossed over is CreateArgument()
, which is responsible for creating the Expression
trees that populate a handler method's parameters from the HttpContext
argument passed to a RequestDelegate
.
In the next post I'll look at the algorithm CreateArgument()
uses to create each argument Expression
, and hence the model-binding rules for minimal APIs.
Summary
In this post I took a first look at the RequestDelegateFactory
class, which is used to build a RequestDelegate
instance from a minimal API handler method, so that it can be called by the EndpointMiddleware
. In this post, I looked at the InferMetadata()
function.
InferMetadata()
is called as part of the endpoint construction to extract metadata about the handler method, such as its argument types, return type, implied [Produces]
attributes, and other details. As part of this process, InferMetadata()
also builds the Expression
trees that create the handler arguments from an HttpContext
in the final RequestDelegate
.