Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Building the final RequestDelegate: Behind the scenes of minimal APIs - Part 7

$
0
0

Throughout this series, we've been looking at the various pieces that go into turning a minimal API endpoint handler into a RequestDelegate that ASP.NET Core can invoke. We looked at the various classes involved, extracting metadata from a minimal API handler, and how binding works in minimal APIs. Next we looked at how RequestDelegateFactory builds up the Expression parts that are combined into a RequestDelegate, both for the argument expressions (parts 1 and 2) and for the response.

In this post, we put all these components together, to see how the RequestDelegate is built. In previous posts I've glossed over and simplified some of the complexities, but in this post I try to stick as close to the reality as possible. We're going to look at the RequestDelegateFactory.Create() method, see the order the various Expressions that go into a RequestDelegate are built, and see how they're combined. I avoid covering too many of the functions that I've covered in previous posts, so this post is best read after reading the previous posts.

In this post I'm still going to skip over filters and filter factories. We'll come back to these in the next post, to see how they modify things compared to the "simple" case shown in this post.

Building a RequestDelegate: RequestDelegateFactory.Create()

For the purposes of this post, I'm going to consider a simple minimal API handler, that takes a single parameter and returns a string:

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

The focus of this post is the RequestDelegateFactory.Create() method. There are several overloads, but they ultimately call into an overload that takes:

  • Delegate handler—the minimal API endpoint handler (string name) => $"Hello {name}"
  • RequestDelegateFactoryOptions? options—holds various context values and settings about the endpoint, as discussed previously.
  • RequestDelegateMetadataResult? metadataResult—holds the result of calling RequestDelegateFactory.InferMetadata(), including the inferred metadata, and the cached RequestDelegateFactoryContext.

The latter two parameters are optional and mostly for optimisation reasons; if they're not provided, Create() re-builds them. For the purposes of this post, I'll assume these values aren't provided, as it makes following the sequence in Create() a little easier.

In practice, in minimal APIs, these values are always provided, as they're created in RequestDelegateFactory.InferMetadata(), which is called before Create().

Throughout this post I'll track the content of important variables as we proceed through the Create() method, so we can see how everything combines.

Creating the targetExpression

Let's start looking at the RequestDelegateFactory.Create() function, taking things one statement at a time.

public static RequestDelegateResult Create(
    Delegate handler, 
    RequestDelegateFactoryOptions? options = null,
    RequestDelegateMetadataResult? metadataResult = null)
{
    if (handler is null)
    {
        throw new ArgumentNullException(nameof(handler));
    }

    UnaryExpression targetExpression = handler.Target switch
    {
        object => Expression.Convert(TargetExpr, handler.Target.GetType()),
        null => null,
    };

    // ...
}

The function starts by checking handler for null and then creates an Expression called targetExpression which has one of two values:

  • If handler.Target is null, then null
  • Otherwise, an expression that converts a "target parameter" to the required type

If you were using a static handler method, handler.Target would be null, otherwise, it returns the instance of the type that the handler was invoked on. In our case, as we're using a lambda method, the instance is the closure class (nested inside the Program class) in which we defined the lambda. This closure class typically has an "invalid" C# name, such as <>c, so we'll use that from now on.

You may find it surprising that the lambda method is an "instance" instead of static type. The reason is that the compiler creates a closure class to capture the context of the lambda (even if you define the lambda using the static keyword). You can see this in action using sharplab.io's "C#" option to see the effective lowered result of the Func<>.

At this point we have two variables of interest, handler and targetExpression:

The Func<string, string> handler and Expression targetExpression variables

Lets move on to the next few lines of Create().

Creating the RequestDelegateFactoryContext and targetFactory

The next couple of lines are relatively simple:

// ...
RequestDelegateFactoryContext factoryContext = 
    CreateFactoryContext(options, metadataResult, handler);

Expression<Func<HttpContext, object?>> targetFactory = (httpContext) => handler.Target;
// ...

The first line creates a RequestDelegateFactoryContext type. This type tracks all the important state about creating the RequestDelegate, and is mostly a bag of properties. You can see the full class definition here, but it holds things such as:

  • AllowEmptyRequestBody—are empty request bodies allowed, or should the handler throw.
  • UsingTempSourceString—does the argument parsing code use a tempSourceString variable.
  • ExtraLocals—expressions defining extra variables that are needed during argument binding.
  • ParamCheckExpressions—expressions defining the "argument validation" that occurs after binding.

There are many more properties on the context, some of which are copied straight from the options object. We already looked at the CreateFactoryContext() method in a previous post, so we'll leave it at that for now, but we're going to keep track of some of the important properties of RequestDelegateFactoryContext in subsequent steps.

The next line of Create() creates an Expression defining a factory-function that creates the necessary method target instance given an ambient HttpContext variable: httpContext => handler.Target. This could also have been defined as _ => handler.Target, as the parameter is unused; as far as I can tell, the HttpContext parameter is there for easier composability later (when you're using filters). I'll add the extra variables to our tracking table:

The Func<string, string> handler, Expression targetExpression, RequestDelegateFactoryContext factoryContext, and Expression targetFactory variables

We now come to the CreateTargetableRequestDelegate() function we looked into in the previous post.

Entering CreateTargetableRequestDelegate and creating the argument expressions

The next line of RequestDelegateFactory.Create() is:

// ...
Func<object?, HttpContext, Task>? targetableRequestDelegate = 
    CreateTargetableRequestDelegate(
        handler.Method, targetExpression, factoryContext, targetFactory);
// ...

We looked into CreateTargetableRequestDelegate in the previous post, where we looked at how the response Expression is created. In this post we'll walk through this method to see how and where the argument and response expressions are built up. Each Expression is stored on the factoryContext variable, so we'll continue to track them in our table of variables.

As you can see from the method call above, we pass pretty much the entire current context into the method:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    factoryContext.ArgumentExpressions ??= 
        CreateArgumentsAndInferMetadata(methodInfo, factoryContext);

    // ...
}

The first line of this method is CreateArgumentsAndInferMetadata(). We've already looked extensively at this method and the CreateArgument() method it relies on to build the argument binding expressions, so for this post we're going to just consider the output Expressions of this method, of which there are several!

For our example handler method

(string name) => $"Hello {name}!"`

CreateArgumentsAndInferMetadata() creates three main expressions:

  • The Expression that is passed to the handler argument—an expression consisting of the variable: name_local. This is added to factoryContext.ArgumentExpressions.
  • The Expression defining extra local variables that are required for the binding—In this case, it's similarly the name_local variable. This is added to factoryContext.ExtraLocals.
  • The parameter check expression to check that the string argument has bound correctly—This is added to factoryContext.ParamCheckExpressions. In a previous post you saw that it generates an expression similar to the following:
name_local = httpContext.Request.RouteValues["name"]
if (name_local == null)
{
     wasParamCheckFailure = true;
     Log.RequiredParameterNotProvided(httpContext, "string", "name", "route");
}

The Log function call refers to a "real" method in the RequestDelegateFactory, which is invoked in the Expression using Expression.Call().

CreateArgumentsAndInferMetadata() sets various other helper values and metadata, but these are the main values created in the simple string-binding-to-routevalue case. For other binding types, you would set additional values on factoryContext, as described in my previous posts. For example:

  • factoryContext.UsingTempSourceString—set to true when parsing a TryParse() type, such as binding an int parameter.
  • factoryContext.ParameterBinders—expressions for binding BindAsync() parameters.
  • factoryContext.JsonRequestBodyParameter—Reference to the ParameterInfo (if any) that requires binding to the request body as JSON.
  • factoryContext.FirstFormRequestBodyParameter—Reference to the first ParameterInfo (if any) that requires binding to the request body as a form. Note that multiple form request body parameters result in an error.

Putting that all together, after the call to CreateArgumentsAndInferMetadata(), we have the following variables and cached values:

Adding the ExtraLocals, ArgumentExpressions, and ParamCheckExpressions variables

Lets keep working our way through CreateTargetableRequestDelegate().

Creating the method call Expression

The next step in CreateTargetableRequestDelegate is CreateMethodCall(), which creates the Expression for actually invoking the minimal API handler:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    // ...

    factoryContext.MethodCall = 
        CreateMethodCall(methodInfo, targetExpression, factoryContext.ArgumentExpressions);
    
    // ...
}

CreateMethodCall() is a small method that takes the handler method, the targetExpression, and the argument expressions, and combines them into a single expression:

private static Expression CreateMethodCall(
    MethodInfo methodInfo, Expression? target, Expression[] arguments) =>
    target is null ?
        Expression.Call(methodInfo, arguments) :
        Expression.Call(target, methodInfo, arguments);

The Expression returned is stored in factoryContext.MethodCall. If you evaluate this, you'll find the resulting code looks a bit like this; it's simply invoking the minimal API handler and passing in the name_local argument:

((Program.<>c) target).Handler.Invoke(name_local)

This isn't exactly what it looks like, because of all the delegate caching and lowering of lambdas, but it looks sort of like this. Fundamentally, it's invoking your minimal API handler, and passing in the argument expressions.

Alternatively, if you think of your handler lambda as being a Func<string, string> defined on Program:

public class Program
{
    Func<string, string> handler = (string name) => $"Hello {name}!";
}

Then factoryContext.MethodCall looks simply like:

target.handler(name_local)

For simplicity, I'll use this notation for the rest of the post. Lets add that to our variables. We aren't going to use the ArgumentExpressions again, so I've removed them from the table:

Adding the MethodCall variable

The next part of CreateTargetableRequestDelegate deals with filters, so we're going to skip over that bit for now. and move onto CreateParamCheckingResponseWritingMethodCall

Building up the request delegate in CreateParamCheckingResponseWritingMethodCall

In the next section of CreateTargetableRequestDelegate(), things really start to come together quickly:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    // ...

    Type returnType = methodInfo.ReturnType;
    
    // ...Filter magic
    
    Expression responseWritingMethodCall = factoryContext.ParamCheckExpressions.Count > 0 
        ? CreateParamCheckingResponseWritingMethodCall(returnType, factoryContext) 
        : AddResponseWritingToMethodCall(factoryContext.MethodCall, returnType);
    
    // ...
}

In this section we call one of two methods, depending on whether there are any ParamCheckExpressions:

  • CreateParamCheckingResponseWritingMethodCall—called to add any ParamCheckExpressions to the RequestDelegate, then calls AddResponseWritingToMethodCall
  • AddResponseWritingToMethodCall—Handles serialization of the handler return value, and writing the response.

I covered both of those methods in detail in the previous post, so you can refer to that for details of how the expressions are built. For our example, we do have a ParamCheckExpression, so we invoke CreateParamCheckingResponseWritingMethodCall and store the result in the responseWritingMethodCall variable. For our example, this looks something like:

string name_local; // factoryContext.ExtraLocals
bool wasParamCheckFailure;

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

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

ExecuteWriteStringResponseAsync(
    httpContext,
    target.handler(name_local)); // factoryContext.MethodCall

The above Expression is composed both of the Expression values we created previously, and some new code added by CreateParamCheckingResponseWritingMethodCall.

The final call in the Expression invokes the handler, and passes the result to the function ExecuteWriteStringResponseAsync, along with the httpContext parameter (which isn't defined yet). ExecuteWriteStringResponseAsync is a private function in RequestDelegateFactory, shown below:

private static Task ExecuteWriteStringResponseAsync(
    HttpContext httpContext, string text)
{
    SetPlaintextContentType(httpContext);
    return httpContext.Response.WriteAsync(text);
}

private static void SetPlaintextContentType(HttpContext httpContext)
    => httpContext.Response.ContentType ??= "text/plain; charset=utf-8";

Now we're really getting somewhere. The expression above is stored in responseWritingMethodCall, so we can stop worrying about tracking the other variables in our table; this is the only Expression that matters now. The end is in sight!

Compiling the Expression to a Func<object?, HttpCotnext, Task>

The final lines in CreateTargetableRequestDelegate are as follows:

private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo,
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    // ...

    if (factoryContext.UsingTempSourceString)
    {
        responseWritingMethodCall = Expression.Block(
            new[] { TempSourceStringExpr }, responseWritingMethodCall);
    }

    return HandleRequestBodyAndCompileRequestDelegate(
        responseWritingMethodCall, factoryContext);
}

The if conditionally adds string tempSourceString to the top of the RequestDelegate if it's required for parsing purposes. It's not needed in our case, but if we had an int parameter, for example, we would need to add it.

The final method call is HandleRequestBodyAndCompileRequestDelegate(). The name hints at this method's responsibilities; it's responsible for three things:

  • Adding the necessary Expression for reading from the request body/form if it's required.
  • Adding the necessary Expression for any BindAsync arguments.
  • Compiling the Expression into a Func.

Our example API isn't reading from the request body (it's a GET request), so I'll gloss over that part.

See a previous post for more details on the Expression generated when binding to the request body.

Our example API also doesn't use any BindAsync parameters, but for completeness I've included the code generation in the HandleRequestBodyAndCompileRequestDelegate shown below:

private static Func<object?, HttpContext, Task> HandleRequestBodyAndCompileRequestDelegate(
    Expression responseWritingMethodCall, 
    RequestDelegateFactoryContext factoryContext)
{
    // Do we need to bind to the body/form?
    if (factoryContext.JsonRequestBodyParameter is null && !factoryContext.ReadForm)
    {
        // No - Do we have any BindAsync() parameters?
        if (factoryContext.ParameterBinders.Count > 0)
        {
            // Yes, so we need to generate the code for reading from the custom binders calling into the delegate
            var continuation = Expression.Lambda<Func<object?, HttpContext, object?[], Task>>(
                responseWritingMethodCall, TargetExpr, HttpContextExpr, BoundValuesArrayExpr).Compile();

            var binders = factoryContext.ParameterBinders.ToArray();
            var count = binders.Length;

            return async (target, httpContext) =>
            {
                var boundValues = new object?[count];

                for (var i = 0; i < count; i++)
                {
                    boundValues[i] = await binders[i](httpContext);
                }

                await continuation(target, httpContext, boundValues);
            };
        }

        // No - we don't have any BindAsync methods
        return Expression.Lambda<Func<object?, HttpContext, Task>>(
            responseWritingMethodCall, TargetExpr, HttpContextExpr).Compile();
    }

    // Bind to the form/body
    return factoryContext.ReadForm
        ? HandleRequestBodyAndCompileRequestDelegateForForm(responseWritingMethodCall, factoryContext)
        : HandleRequestBodyAndCompileRequestDelegateForJson(responseWritingMethodCall, factoryContext);
}

If you follow the branches through you'll see that for our example, this method doesn't add anything to the existing Expression. This method simply compiles the Expression we have into a Func<object?, HttpContext, Task> that looks something like the following:

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

Note that this is now a Func<>, which can be actually invoked, but it's not a RequestDelegate, as we have the extra target parameter in the method signature. To create the final RequestDelegate we need to follow the stack all the way back up to the Create() method where we left it to call CreateTargetableRequestDelegate().

Building the final RequestDelegate

We're almost at the finish line now. The remaining parts of Create() mostly deal with tidying up and handling edge cases we're not focusing on. The important line is where we convert the Func<> stored in targetableRequestDelegate into a real RequestDelegate:

public static RequestDelegateResult Create(
    Delegate handler, 
    RequestDelegateFactoryOptions? options = null,
    RequestDelegateMetadataResult? metadataResult = null)
{
    RequestDelegate finalRequestDelegate = targetableRequestDelegate switch
    {
        // if targetableRequestDelegate is null, we're in an edge case that we're not focusing on now!
        null => (RequestDelegate)handler,

        // Otherwise, build the RequestDelegate
        _ => httpContext => targetableRequestDelegate(handler.Target, httpContext),
    };

    // save the finalRequestDelegate in the RequestDelegateResult
    return CreateRequestDelegateResult(finalRequestDelegate, factoryContext.EndpointBuilder);
}

So for our example, the finalRequestDelegate, which is a RequestDelegate instance (Func<HttpContext, Task>), looks something like this:

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

Where

  • Program.<>c is the cached lambda instance
  • TargetableRequestDelegate is the compiled Expression we saw in the previous section (a Func<HttpContext, object?, Task>).

This is the RequestDelegate that is invoked when your minimal API endpoint handles a request.

That (finally) brings us to the end of this post on how the RequestDelegate is built up. It's a convoluted and complex process, but the end result is a simple Func<HttpContext, Task> that can be executed in response to a request, which is pretty elegant in the end!

Of course, we're not done yet. I keep promising we're going to look at filters, so in the next post we will. Hopefully with all this background, understanding how they fit in and their impact on the RequestDelegate will be a bit easier!

Summary

In this post we walked through the RequestDelegateFactory.Create() method, to understand how all the Expressions we've looked at in this series are combined into the final RequestDelegate.

We started by looking at the targetExpression and targetFactory, and established that even static lambda methods have a "target" instance. Next we moved onto CreateArgumentsAndInferMetadata() and the CreateArgument() helper in which we define the bulk of the various Expression components. All the Expressions related to binding the arguments to the HttpContext are defined here.

After defining the argument Expressions we could create the expression for invoking the lambda handler. This was a little hard to show, as it's an Expression invoking the cached lambda instance by passing in the argument Expressions.

In the next method, CreateParamCheckingResponseWritingMethodCall, we combine all the Expressions together: the local variable definitions, the argument binding, validation, invocation of the handler, and generating of the response. Things really come together at this point, but we're still working with an Expression.

In HandleRequestBodyAndCompileRequestDelegate we finally call Compile() and turn the Expression into a Func<object, HttpContext, Task>. This isn't quite a RequestDelegate, but RequestDelegateFactory.Create() "wraps" this to create the required signature by passing in the target instance, giving the final, required, Func<HttpContext, Task> signature of a RequestDelegate.


Viewing all articles
Browse latest Browse all 743

Trending Articles