blog post image
Andrew Lock avatar

Andrew Lock

~12 min read

Generating argument expressions for minimal APIs

Behind the scenes of minimal APIs - Part 4

In the previous post in this series, we explored the CreateArgument() method, and showed how this method is responsible for defining how model-binding works in minimal APIs, by choosing which source to bind from and generating an Expression for the binding.

We skipped over the Expression generation in the previous post, so we're going to dive into it in this post and look at the (effective) code that CreateArgument() generates.

This is a pretty long post, so a table of contents seems in order:

We'll look at half of the binding expressions in CreateArgument in this post, and half in the next post.

A quick recap of RequestDelegateFactory.CreateArgument()

In this series we've looked at some of the important classes and methods involved in building the metadata about an endpoint, and ultimately building the RequestDelegate that ASP.NET Core can execute:

  • RouteEndpointDataSourceβ€”stores the "raw" details about each endpoint (the handler delegate, the RoutePattern etc), and initiates the building of the endpoints into a RequestDelegate and collating of the endpoint's metadata.
  • RequestDelegateFactory.InferMetadata()β€”responsible for reading the metadata about the endpoint handler's arguments and return value, and for building an Expression for each argument that can be later used to build the RequestDelegate.
  • RequestDelegateFactory.Create()β€”responsible for creating the RequestDelegate that ASP.NET Core invokes by building and compiling an Expression from the endpoint handler.

So far we've been focusing on the InferMetadata() function, and in this post we're looking closer at a method it calls: CreateArgument(). CreateArgument() creates an Expression that can create a handler parameter, given you have access to an HttpContext httpContext variable.

For example, lets imagine you have a handler that looks something like this:

app.MapGet("/{id}", (string id, HttpRequest request, ISomeService service) => {});

To execute the handler, ASP.NET Core ultimately needs to generate an expression that looks a bit like the following: I've highlighted the code that CreateArgument() generates using πŸ‘ˆ:

Task Invoke(HttpContext httpContext)
{
    // Parse and model-bind the `id` parameter from the RouteValues
    bool wasParamCheckFailure = false;
    
    string id_local = httpContext.Request.RouteValues["id"];  // πŸ‘ˆ
    if (id_local == null)                                     // πŸ‘ˆ
    {                                                         // πŸ‘ˆ
        wasParamCheckFailure = true;                          // πŸ‘ˆ
        Log.RequiredParameterNotProvided(httpContext, "string", "id", "route");  // πŸ‘ˆ
    }                                                         // πŸ‘ˆ

    if(wasParamCheckFailure)
    {
        // binding failed, return a 400
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }
     
    // handler is the original lambda handler method.
    // The HttpRequest parameter has been automatically created from the HttpContext argument
    // and the ISomeService parameter is retreived from the DI container
    string text = handler.Invoke(
        id_local,                                                      // πŸ‘ˆ
        httpContext.Request,                                           // πŸ‘ˆ
        httpContext.RequestServices.GetRequiredService<ISomeService>() // πŸ‘ˆ
    ); 

    // The return value is written to the response as expected
    httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
    return httpContext.Response.WriteAsync(text);
}

Much of this code (though not all) is generated by CreateArgument() as an Expression tree. Specifically, it generates the code that creates handler arguments based on an ambient httpContext variable, and checks that the values are valid (not null).

In this post, we're going to look at the various forms this generated code can take, depending on the source of the binding (e.g. the querystring, the request body) as well as the type of parameter being bound, and its nullability.

Expression trees

A fundamental concept used by the RequestDelegateFactory is Expression trees, and compiling them into an executable delegate.

You can read all about Expression trees in the docs. This provides a nice introduction, as well as examples of how to work with expression trees. One of the most useful hints is how to debug expression trees using Visual Studio and other tools.

The CreateArgument() method potentially creates several Expression trees for each parameter:

  • The Expression defining how to create the argument from the data source.
  • Any extra local variables created to support the extraction of the argument.
  • An expression for checking whether the parameter is valid.

This will all become more obvious as we look at some examples, but I wanted to highlight these to point out that nullability is a big part of the generated Expression trees, and contributes to whether the parameter is valid or not.

For example, an endpoint that looks like this:

app.MapGet("/", (string query, int id) => {})

in which the query and id parameters are both non-nullable, will have a different Expression than the following example, in which the parameters are marked nullable or have default values.

app.MapGet("/", (string? query, int id = -1) => {})

In the former case, the generated Expression must keep track of whether the expected parameters were provided in the querystring. Parsing the query into the int id parameter must be checked for both of the handlers. As you can imagine, this all adds considerable complexity to the generated Expressions!

Generating expressions for well-known types

As you saw in the previous post, CreateArgument() first checks for any [From*] attributes applied to parameters to determine the binding source, but I'm going to skip over this section for now to look at binding parameters to well-known types.

As I described in the last post, you can inject types such as HttpContext and CancellationToken, and these are bound automatically to properties of the HttpContext injected into the final RequestDelegate. As these properties are directly available, they are the simplest Expressions to generate (which is why we're starting with them)!

CreateArgument() contains a whole load of static readonly cached expressions representing these well known types. As an example, we'll look at how these Expressions are defined, and what the generated code for the argument looks like:

static readonly ParameterExpression HttpContextExpr = 
    Expression.Parameter(typeof(HttpContext), "httpContext");

static readonly MemberExpression HttpRequestExpr = 
    Expression.Property(HttpContextExpr, typeof(HttpContext).GetProperty(nameof(HttpContext.Request))!);

Here we have two types of expression. The first is a ParameterExpression, so it defines the Type and name of a parameter passed into a method. The second is a MemberExpression that describes accessing a property. As the HttpContextExpr is passed into the Expression.Property call, this is equivalent to code that simply looks like this:

httpContext.Request

Doesn't look like a lot does it!? πŸ˜„ But this is all CreateArgument() needs to generate for built-in types; this expression defines exactly how to create the argument that is passed to a handler with an HttpRequest parameter. The same applies to most of the other simple "well-known" types. For example, consider this handler that only uses built in types:

app.MapGet("/", (HttpContext c, HttpRequest r, CancellationToken c, Stream s) => {})

CreateArgument() generates a single simple expression for each argument; no extra validation or local variable expressions are needed. The final RequestDelegate for this handler will look something like the following (where handler refers to the lambda method in the minimal API):

Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext,
        httpContext.Request,
        httpContext.RequestAborted,
        httpContext.Body); 

    return Task.CompletedTask;
}

Pretty neat, huh!

Of course, not everything can be as simple as this. Now we've covered the simple cases, lets look at something more complicated: generating expressions for binding to route values, the querystring, and headers.

Generating expressions for binding to route values, the querystring, and headers

Before we start, the good news is that binding to each of these resources have a lot of similarities:

  • They are all exposed on HttpRequest: HttpRequest.RouteValues, HttpRequest.Query, and HttpRequest.Headers.
  • They are all exposed as a collection of items, accessible using an indexer (Item) that takes a string key.
  • Each of the indexed values may return an object that represents multiple values, for example a StringValues object.

Because of these similarities, the Expression generating code in CreateArgument() is much the same for each. For that reason we'll stick to looking at the code generated for just one of these, the querystring. In addition, we won't look at the actual code building up the Expression trees, as frankly, it's far too confusing.

Instead, we'll take a different approach: I'll show an example handler, and we'll look at the effective code generated for it. We'll then tweak it slightly (make it nullable or change the type of the parameter, for example) and see how the code changes. This will let us explore all the different expressions without getting bogged down in trying to follow the logic (but you're obviously welcome to read the source if you prefer)!

Binding an optional string parameter to a query value

We'll start with a simple handler that optionally binds to a query string value:

app.MapGet("/", (string? q) => {});

If you follow the flow chart from my previous post, you'll see that CreateArgument() will choose to bind this argument to a querystring value (rather than a route value, as there aren't any). As the parameter is optional (so doesn't need checking for null) and is a string (so doesn't require conversion), the resulting RequestDelegate looks something like this:

Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext.Request.Query["q"] != null 
            ? httpContext.Request.Query["q"]
            : (string) null); 

    return Task.CompletedTask;
}

There's a bit of duplication in the generated expression here, which simplifies handling some other scenarios, but otherwise there's not a lot to discuss here, so lets move on.

Binding an optional string parameter with a default value

For the next scenario we'll update the handler's parameter to no longer be nullable, and instead we'll give it a default value. Lambdas don't support default values in parameters, so we'll convert to a local function for this example, but this has no other impact on the expression generated

app.MapGet("/", Handler);
void Handler(string q = "N/A") { }

So how does this affect the generated code? well, as you can see below, it's very similar! Instead of falling back to null, we fall back to the provided default value, as you might expect:

Task Invoke(HttpContext httpContext)
{
    handler.Invoke(
        httpContext.Request.Query["q"] != null 
            ? httpContext.Request.Query["q"]
            : (string) "N/A"); // πŸ‘ˆ Using the default value instead of `null`

    return Task.CompletedTask;
}

Ok, that covers the optional case, lets move on to when the parameter is required.

Binding a required string parameter

We'll go back to the original handler now, but convert it to a string parameter instead of string?:

app.MapGet("/", (string q) => {});

With the parameter now required, we have to make sure we check that it's actually provided, and if not, log the error. CreateArgument() generates three expressions to handle this, each of which is shown below.

First, we define a local variable expression which has the type of the parameter (string) and is named based on the parameter:

string q_local;

Next, we have the binding expression, which reads the value from the querystring, checks it for null, and both sets a variable wasParamCheckFailure and logs the error if required:

q_local = httpContext.Request.RouteValues["q"]
if (q_local == null)
{
     wasParamCheckFailure = true;
     Log.RequiredParameterNotProvided(httpContext, "string", "q", "query");
}

The wasParamCheckFailure is created later, in the the RequestDelegateFactory.Create() method, and the Log here refers to a static helper method on the RequestDelegateFactory itself.

The final expression is what's passed to the handler function, which in this example is just q_local

Putting it all together, and combining with the extra details added by RequestDelegateFactory.Create() gives a RequestDelegate that looks roughly like this:

Task Invoke(HttpContext httpContext)
{
    bool wasParamCheckFailure = false; // Added by RequestDelegateFactory.Create()
    string q_local;

    q_local = httpContext.Request.RouteValues["q"]
    if (q_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "q", "query");
    }

    if(wasParamCheckFailure) // Added by RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

Things are definitely getting a bit more complex now! Lets add another layer of complexity in and look at binding to something other than a string, where we need to call TryParse to create the value.

Binding an optional int parameter to a query value

Let's go back to our original simple handler, but this time optionally bind an int to a query string value:

app.MapGet("/", (int? q) => {});

Simply changing from a string? to an int? has a big knock-on effect on the generated code as it now needs to account for:

  • Working with nullable types. Nullables are weird. Trust me πŸ˜…
  • Parsing the string source into an int and handling failures
  • Temporary values to store the intermediate values

If you put it all together, and combine with the extra logic that RequestDelegateFactory.Create() adds, you end up with a RequestDelegate that looks something like this:

Task Invoke(HttpContext httpContext)
{
    string tempSourceString; // Added by RequestDelegateFactory.Create()
    bool wasParamCheckFailure = false; // Added by RequestDelegateFactory.Create()
    int? q_local;

    tempSourceString = httpContext.Request.RouteValues["q"]
    if (tempSourceString != null)
    {
        if (int.TryParse(tempSourceString, out int parsedValue))
        {
            q_local = (int?)parsedValue;
        }
        else
        {
            wasParamCheckFailure = true;
            Log.ParameterBindingFailed(httpContext, "Int32", "q", tempSourceString)
        }
    }

    if(wasParamCheckFailure) // Added by RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

Lets look at how adding a default value changes the generated code.

Binding an optional int parameter with a default value

Just as we did with string, we'll switch to using a non-nullable parameter with a default value, using a local function:

app.MapGet("/", Handler);
void Handler(int q = 42) { }

The generated RequestDelegate in this case is very similar to the previous example. There are three main differences:

  • The locals are int instead of int? as you would expect
  • We can directly assign q_local in the TryParse call, because it's an int now instead of int?. That means we can also invert the if clause, simplifying things a little
  • We add an else clause for when tempSourceString is null and assign q_local to the default value.

The RequestDelegate looks like this:

Task Invoke(HttpContext httpContext)
{
    string tempSourceString; // Added by RequestDelegateFactory.Create()
    bool wasParamCheckFailure = false; // Added by RequestDelegateFactory.Create()
    int q_local; // πŸ‘ˆ int instead of int?

    tempSourceString = httpContext.Request.RouteValues["q"]
    if (tempSourceString != null)
    {
        // We can directly assign q_local here πŸ‘‡
        if (!int.TryParse(tempSourceString, out q_local)) // πŸ‘ˆ which means we can invert this if clause
        {
            wasParamCheckFailure = true;
            Log.ParameterBindingFailed(httpContext, "Int32", "q", tempSourceString)
        }
    }
    else
    {
        q_local = 42; // πŸ‘ˆ Assign the default if the value wasn't present
    }

    if(wasParamCheckFailure) // Added by RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

For completeness, let's look at the final equivalent case for our parameter, where the value is required.

Binding a required int parameter

In our final handler example we have a required int parameter:

app.MapGet("/", (int q) => {});

The generated code for this case is similar to the previous version where we had a default value, with two changes:

  • An extra pre-check that the value grabbed from the source is not null.
  • No else clause setting a default

The final RequestDelegate looks something like this:

Task Invoke(HttpContext httpContext)
{
    string tempSourceString; // Added by RequestDelegateFactory.Create()
    bool wasParamCheckFailure = false; // Added by RequestDelegateFactory.Create()
    int q_local;

    tempSourceString = httpContext.Request.RouteValues["q"]
    
    if (tempSourceString == null) // πŸ‘ˆ Extra block checking it was bound
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "Int32", "q");
    }
    
    if (tempSourceString != null)
    {
        if (!int.TryParse(tempSourceString, out q_local))
        {
            wasParamCheckFailure = true;
            Log.ParameterBindingFailed(httpContext, "Int32", "q", tempSourceString)
        }
    } // πŸ‘ˆ No else clause

    if(wasParamCheckFailure) // Added by RequestDelegateFactory.Create()
    {
        httpContext.Response.StatusCode = 400;
        return Task.CompletedTask;
    }

    handler.Invoke(q_local); 

    return Task.CompletedTask;
}

With that we've now covered the generated expressions for:

  • Well-known types like HttpContext and HttpRequest
  • Binding required and optional string values to route values, the querystring, and headers.
  • Binding required and optional "TryParse()" values (like int) to route values, the querystring, and headers.

This is obviously only a small number of the possible parameter types that you can bind to, but this post is already too long, so we'll leave it there for now. In the next post we'll carry on where we left off, and look at binding to the remaining parameter types.

Summary

The RequestDelegateFactory.CreateArgument() method is responsible for creating the Expression trees for binding minimal API handler arguments to the HttpContext. RequestDelegateFactory.Create() uses these expression trees to build the final RequestDelegate that ASP.NET Core executes to handle a request.

In this post I showed examples of the expression trees generated for specific parameter types. I started by showing how well-known types like HttpRequest and CancellationToken generate expressions that bind arguments to properties of HttpContext. These are the simplest cases, as they don't require any validation.

Next we looked at binding string parameters to the querystring, route values, and header values. The expressions for optional parameters (string?) and parameters with default values were relatively simple, but for required values (string) we saw that some validation was required.

Much more validation is required when using parameter types that must be parsed from a string, such as int values. Whether you're using optional or required values, these require some degree of validation, which results in a lot more generated code in the expression. In the next post we'll continue looking at the expressions generated in CreateArgument() for other parameter types.

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