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:
- A quick recap of
RequestDelegateFactory.CreateArgument()
- Expression trees
- Generating expressions for well-known types
- Generating expressions for binding to route values, the querystring, and headers
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 handlerdelegate
, theRoutePattern
etc), and initiates the building of the endpoints into aRequestDelegate
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 anExpression
for each argument that can be later used to build theRequestDelegate
.RequestDelegateFactory.Create()
—responsible for creating theRequestDelegate
that ASP.NET Core invokes by building and compiling anExpression
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 Expression
s!
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
, andHttpRequest.Headers
. - They are all exposed as a collection of items, accessible using an indexer (
Item
) that takes astring
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 anint
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 ofint?
as you would expect - We can directly assign
q_local
in theTryParse
call, because it's anint
now instead ofint?
. That means we can also invert theif
clause, simplifying things a little - We add an
else
clause for whentempSourceString
isnull
and assignq_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
andHttpRequest
- Binding required and optional
string
values to route values, the querystring, and headers. - Binding required and optional "
TryParse()
" values (likeint
) 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.