blog post image
Andrew Lock avatar

Andrew Lock

~13 min read

Extracting metadata from a minimal API handler

Behind the scenes of minimal APIs - Part 2

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 Expressions, 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> or ValueTask<T> (or other awaitable type)?
    • If yes, treat the return type as type T and continue.
  • Is the return type void, Task, ValueTask, or IResult?
    • 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)]

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 the Expression 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.

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