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

Generating the response writing expression for RequestDelegate: Behind the scenes of minimal APIs - Part 6

$
0
0

In the previous two posts, I looked in detail at how the RequestDelegateFactory.CreateArgument() method creates the Expression used to bind different types of parameters in your endpoint handler. The exact Expression needed is different depending on the parameter Type you're binding, and the part of the HttpRequest you're binding it to.

In the same way, depending on the return type of your handler, you need to generate a different Expression to handle the return type of the handler and write the HTTP response. In this post, we'll start looking at how RequestDelegateFactory.Create() creates the final RequestDelegate, focusing on the different expressions that are generated depending on your handler's signature.

Generating a response with CreateTargetableRequestDelegate

We're not going to look at the whole RequestDelegateFactory.Create() method in this post. Instead we're going to look at one of the private methods it calls: CreateTargetableRequestDelegate(). This method takes the handler endpoint, uses the argument Expressions created by CreateArguments() and generates the Expression that writes the response.

We're only going to look at the response writing part of the method in this post, so I've glossed over some of the other aspects of the method. The important points shown below are:

  • Build the argument binding expressions, if not already done.
  • Create the MethodCall Expression, which is the Expression that actually invokes the handler method using the argument expressions.
  • If the argument binding expressions include validation, CreateParamCheckingResponseWritingMethodCall is called which generates the 400 Bad Request in case of a failure.
  • If no validation is required, AddResponseWritingToMethodCall is called.
  • If the argument expressions use a tempSourceString variable, adds it to the start of the expression.
  • Adds the Expression to read from the request body as shown in the previous post and compiles the Expression to a Func<> that's used to build the final RequestDelegate.
private static Func<object?, HttpContext, Task>? CreateTargetableRequestDelegate(
    MethodInfo methodInfo, // the lambda handler function
    Expression? targetExpression,
    RequestDelegateFactoryContext factoryContext,
    Expression<Func<HttpContext, object?>>? targetFactory = null)
{
    // Build the argument expressions if they weren't previously built for some reason
    factoryContext.ArgumentExpressions ??= CreateArgumentsAndInferMetadata(methodInfo, factoryContext);

    // Build the Expression that actually invokes the handler, passing in the arguments
    factoryContext.MethodCall = CreateMethodCall(
        methodInfo, targetExpression, factoryContext.ArgumentExpressions);

    var returnType = methodInfo.ReturnType;

    // If any of the parameters have validation (e.g. checking for null) then
    // generate the validation Expression. Generate the final "write response"
    // Expression in both cases and assign to variable.
    var responseWritingMethodCall = factoryContext.ParamCheckExpressions.Count > 0 ?
        CreateParamCheckingResponseWritingMethodCall(returnType, factoryContext) :
        AddResponseWritingToMethodCall(factoryContext.MethodCall, returnType);

    // If any of the parameters use the tempSourceString variable for parsing
    // then add the variable definition at the start 
    if (factoryContext.UsingTempSourceString)
    {
        responseWritingMethodCall = Expression.Block(
            new[] { TempSourceStringExpr }, 
            responseWritingMethodCall);
    }

    // Add the reading of the request body and compile the Expression to a Func<>
    return HandleRequestBodyAndCompileRequestDelegate(responseWritingMethodCall, factoryContext);
}

Most of the method is about building the response-writing Expression, but it also adds the tempSourceString definition if necessary. Essentially it adds the following to the start of the Expression:

string tempSourceString;

using the following definition:

internal static readonly ParameterExpression TempSourceStringExpr =
    Expression.Variable(typeof(string), "tempSourceString");

The CreateMethodCall() function takes the reference to the handler method, the "target" for the method call, and the argument expressions. The "target" is the object (if any) that the handler method is invoked on. The target is null for minimal APIs that use static methods, but if you use an instance method or lambda, this contains a reference to the object:

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

Once compiled this translates to either calling handler(arg1, arg2) or target.handler(arg1, arg2) depending on whether target is null.

In the next section, we'll look at the CreateParamCheckingResponseWritingMethodCall() method which is called above when binding parameters to requests could possible fail.

Adding parameter validation checks to the Expression

It's easiest to understand the behaviour of CreateParamCheckingResponseWritingMethodCall() by thinking about the Expressions that CreateArgument() adds. For example, if we take the following simple minimal API endpoint:

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

Then in a previous post you saw that the start of the RequestDelegate looks something like the following:

Task Invoke(HttpContext httpContext)
{
    bool wasParamCheckFailure = false;
    string q_local;
    
    q_local = httpContext.Request.Query["q"];
    if (q_local == null)
    {
        wasParamCheckFailure = true;
        Log.RequiredParameterNotProvided(httpContext, "string", "q", "query");
    }

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

    handler.Invoke(q_local);
}

Not all of the above method is created by CreateArgument. CreateArgument() creates three parts of the RequestDelegate as Expressions, and stores each Expression in a different collection in RequestDelegateFactoryContext factoryContext:

  • The definition of any local variables (q_local) is stored in factoryContext.ExtraLocals.
  • The reference to the argument passed to the handler function (q_local) is ultimately stored in factoryContext.ArgumentTypes.
  • The "parameter check expression" is stored in factoryContext.ParamCheckExpressions.

The "parameter check expression" in this case consists of the assignment of q_local, the null check, and recording the failure:

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

With that in mind, let's see how CreateParamCheckingResponseWritingMethodCall() uses the collections of Expression to build up the RequestDelegate. I've added comments to the method below to make it clearer.

private static Expression CreateParamCheckingResponseWritingMethodCall(
    Type returnType, // the return type of our endpoint handler, which is void in this example
    RequestDelegateFactoryContext factoryContext)
{
    // Create an array to hold any local variables we need (e.g. q_local)
    // and copy the variable Expressions. +1 variable is added for 
    // the extra wasParamCheckFailure variable we need
    var localVariables = new ParameterExpression[factoryContext.ExtraLocals.Count + 1];
    for (var i = 0; i < factoryContext.ExtraLocals.Count; i++)
    {
        localVariables[i] = factoryContext.ExtraLocals[i];
    }

    // Add the `bool wasParamCheckFailure` variable
    localVariables[factoryContext.ExtraLocals.Count] = WasParamCheckFailureExpr;

    // Create an array to hold the parameter check expressions
    // and copy the check Expressions. +1 is added to the length 
    // of the array to store the block that checks if the expression was successful
    var checkParamAndCallMethod = new Expression[factoryContext.ParamCheckExpressions.Count + 1];
    for (var i = 0; i < factoryContext.ParamCheckExpressions.Count; i++)
    {
        checkParamAndCallMethod[i] = factoryContext.ParamCheckExpressions[i];
    }

    // If filters are registered, we need to let them run
    // even when there's a binding failure. More on that in a later post
    if (factoryContext.EndpointBuilder.FilterFactories.Count > 0)
    {
        // ... ignoring filter factories for now
    }
    else
    {
        // Builds an expression that looks a bit like the following
        // if(wasParamCheckFailure)
        // {
        //     httpContext.Response.StatusCode = 400;
        //     return Task.CompletedTask;
        // }
        // else
        //     return handler(q_local); // Added by AddResponseWritingToMethodCall()
        var checkWasParamCheckFailure = Expression.Condition(
            WasParamCheckFailureExpr,
            Expression.Block(
                Expression.Assign(StatusCodeExpr, Expression.Constant(400)),
                CompletedTaskExpr),
            AddResponseWritingToMethodCall(factoryContext.MethodCall!, returnType));

        // Add the above Expression to the collection
        checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailure;
    }

    // Combine the variable expressions with the parameter check and response
    // writing expressions, invoking each in turn, to build a single Expression
    return Expression.Block(localVariables, checkParamAndCallMethod);
}

In the final Expression generated above we call AddResponseWritingToMethodCall(). This is also called directly in CreateTargetableRequestDelegate() when we don't have any parameter check expressions.

Generating the response with AddResponseWritingToMethodCall()

The final method we're looking at in this post is AddResponseWritingToMethodCall(). This method is responsible for two things:

  • Invoking the handler method, as an Expression, created previously by CreateMethodCall.
  • Handling the return value of the handler method by writing the response to the HttpResponse, if required.

The body of AddResponseWritingToMethodCall() is essentially a giant if...else statement switching on the return type of the handler method, and generating the appropriate Expression. Rather than show the 15-ish if clauses, I've summarised how each return type is handled by showing the approximate effective C# code generated for each case, with an example of the handler method with the return type

Return type: void

For the simplest handler methods, which don't return anything, the return type is void:

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

There's nothing to do other than call the handler here, so the invocation code looks something like this

handler.Invoke(q_local); // invoke the handler
return Task.CompletedTask;

Return type: string

The next simplest handler is a string response:

app.MapGet("/", () => "Hello world!");

In this case the delegate sets the response Content-Type to text/plain and serializes the return value of the handler to the output stream

string text = handler.Invoke(); // this variable is actually inlined, but included for clarity
httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
return httpContext.Response.WriteAsync(text);

Return type: IResult

string is one of the special-cased return-types in minimal APIs, and IResult is another. The IResult object is responsible for writing the response to the response stream and setting any necessary Content-Type or status codes.

The static Results and TypedResults helpers are the common way to return an IResult:

app.Map("/", () => Results.NoContent());

For the delegate, that means code that looks a little like this:

var result = handler.Invoke(); // this variable is actually inlined, but included for clarity
return ExecuteResultWriteResponse(result, httpContext); // call the helper to run ExecuteAsync

private static async Task ExecuteResultWriteResponse(
        IResult? result, HttpContext httpContext)
{
    if (result is null)
    {
        throw new InvalidOperationException("The IResult returned by the Delegate must not be null.");
    }

    await result.ExecuteAsync(httpContext);
}

The "interesting" bit here is that the "main" RequestDelegate doesn't call await directly. Instead it returns the result of the async method ExecuteResultWriteResponse. There's a lot of this "indirection" in the Expression building as it makes some composability easier for the Expressions.

Another point of detail to bear in mind if you're writing custom IResult objects is that if your handler returns a value type (struct) IResult, it will be boxed before it's passed to the method call. That probably removes most potential benefits of creating struct IResult implementations.

Return type: JSON

If you return any other (non-Task) type, it is serialized to the response body as JSON. For example

app.Map("/", () => new { Message = "Hello world!"});

The RequestDelegate uses the HttpResponseJsonExtensions.WriteAsJsonAsync() method to serialize the type to the HttpResponse. You'll see that it uses the non-generic version of the WriteAsJsonAsync() method instead of the generic version which avoids issues with source generator polymorphism.

var result = handler.Invoke();
return WriteJsonResponse(httpContext.Response, result); // call the helper to run ExecuteAsync

private static Task WriteJsonResponse(HttpResponse response, object? value)
{
    // Call WriteAsJsonAsync() with the runtime type to serialize the runtime 
    // type rather than the declared type which avoids source generators issues.
    return HttpResponseJsonExtensions.WriteAsJsonAsync(
        response,
        value,
        value is null ? typeof(object) : value.GetType(),
        default);
}

That covers all the "built-in" types: string, IResult, and "other DTO".

Return type: object

When returning object, the delegate has to check whether the return object is one of the supported serialization types such as IResult or a string and handle it accordingly. If it's not either of these, it serializes the result to JSON. The result is pretty much what you would expect: it calls one of the three methods shown already

So for an API that looks like this:

app.Map("/", () => (object) "Hello world!");

you get the following response handling:

 // invoke the handler and pass the result to ExecuteAwaitedReturn
return ExecuteAwaitedReturn(handler.Invoke(), httpContext);

private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext)
{
    // Terminal built ins
    if (obj is IResult result)
    {
        return ExecuteResultWriteResponse(result, httpContext);
    }
    else if (obj is string stringValue)
    {
        httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
        return httpContext.Response.WriteAsync(stringValue);
    }
    else
    {
        // Otherwise, we JSON serialize when we reach the terminal state
        return WriteJsonResponse(httpContext.Response, obj);
    }
}

That covers all of the basic types that you can return for a handler, finally we'll cover the various Task types.

Return type: Task or ValueTask

The simplest cases are when you return a Task or ValueTask. For example:

app.Map("/", () => 
{
    return Task.CompletedTask;
});

This one is simple, we literally just return the handler:

return handler.Invoke();

The ValueTask version, using ValueTask.CompletedTask or new ValueTask() for example, is a little more complex, as it needs to convert the ValueTask to a Task:

return ExecuteValueTask(handler.Invoke());

static Task ExecuteValueTask(ValueTask task)
{
    // If the ValueTask has already completed (the common sync case)
    // We can extract the value and return
    if (task.IsCompletedSuccessfully)
    {
        task.GetAwaiter().GetResult();
        return Task.CompletedTask;
    }

    // otherwise we need to await it (async case)
    // Doing the await in a seperate method means we don't pay the cost
    // of the async state machine for the common sync case
    return ExecuteAwaited(task);
    
    static async Task ExecuteAwaited(ValueTask task)
    {
        await task;
    }
}

That covers the non-generic cases Task and Task<T>, now we'll look at the generic cases.

Return type: Task<T> or ValueTask<T>

Returning Task<T> or ValueTask<T> are probably some of the most common return types for a minimal API: you receive a request, you funnel it off to do something asynchronous like read/write from a database, and then return a DTO with the results:

app.MapGet("/", async (IWidgetService service) => 
{
    var widgets = await service.GetAll();
    return widgets.Select(widget => new { widget.Id, widget.Name });
});

As you might expect, the handling code for Task<T> and ValueTask<T> calls await on the result of the handler, then calls the same serialization code you've already seen in this post, depending on whether the returned object is a string, an IResult, or something else.

I'm not going to go through all of these combinations, so lets look at the example for the Task<T> above:

var result = handler.Invoke(
    httpContext.RequestServices.GetRequiredService<IWidgetService>());

// RequestDelegateFactory reads the generic type parameters 
// from the Task<T> in result, and calls the generic ExecuteTaskOfT
return ExecuteTaskOfT<IEnumerable<WidgetDto>>(result, httpContext); 

private static Task ExecuteTaskOfT<T>(Task<T> task, HttpContext httpContext)
{
    // Don't return null as a Task, ever!
    if (task is null)
    {
        throw new InvalidOperationException("The Task returned by the Delegate must not be null.");
    }

    // If the task has already completed, get the result
    // and call WriteJsonResponse immediately
    if (task.IsCompletedSuccessfully)
    {
        return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult());
    }

    // If it hasn't completed yet, await the call
    return ExecuteAwaited(task, httpContext);

    static async Task ExecuteAwaited(Task<T> task, HttpContext httpContext)
    {
        // await the task, and then await the call to WriteJsonResponse
        await WriteJsonResponse(httpContext.Response, await task);
    }
}

// This WriteJsonResponse method is the same one used when you
// return an object T directly
private static Task WriteJsonResponse(HttpResponse response, object? value)
{
    return HttpResponseJsonExtensions.WriteAsJsonAsync(
        response, value,value is null ? typeof(object) : value.GetType(), default);
}

There's more code here, but there's nothing too complicated going on:

  • Check the result of the handler, has the Task<T> already completed?
    • If yes, write the result using WriteJsonResponse().
    • If no, await the Task<T>, and then write the result using WriteJsonResponse().

The WriteJsonResponse method is the same one called when you return a T (no Task<>). The await call is wrapped in a local function just like the non-generic Task handler to avoid allocating the async state machine when the Task has completed synchronously (I assume).

For completeness, I'll show one more - returning a ValueTask<string>, something like:

app.MapGet("/", () => new ValueTask("Hello world!"));

No surprises here either:

var result = handler.Invoke();

// RequestDelegateFactory reads the generic type parameters 
// from the Task<T> in result, and calls ExecuteValueTaskOfString
return ExecuteValueTaskOfString(result, httpContext); 

private static Task ExecuteValueTaskOfString(ValueTask<string?> task, HttpContext httpContext)
{
    httpContext.Response.ContentType ??= "text/plain; charset=utf-8";

    // If the ValueTask has already completed (the common sync case)
    // get the result and write it directly to the response
    if (task.IsCompletedSuccessfully)
    {
        return httpContext.Response.WriteAsync(task.GetAwaiter().GetResult()!);
    }

    // If it hasn't completed yet, need to await the call
    return ExecuteAwaited(task, httpContext);

    static async Task ExecuteAwaited(ValueTask<string> task, HttpContext httpContext)
    {
        // await the ValueTask, and then await writing the response
        await httpContext.Response.WriteAsync(await task);
    }
}

And with that, we're done looking at responses. The actual code generated here is pretty simple and intuitive, taking into account the four supported return types (void, string, IResult, and T), as well as their Task/Task<T>/ValueTask<T> equivalents. Aside from that, it's mostly defensive programming and performance optimisations.

We've covered a lot in this series, so in the next post we put it all together to look at how the RequestDelegateFactory.Create() method creates the final RequestDelegate for an endpoint.

Summary

In this post, we looked at the CreateTargetableRequestDelegate and focused on the code it produces that wraps the handler method invocation. We looked at two method calls in particular:

  • CreateParamCheckingResponseWritingMethodCall() handles the case where there was a problem binding the request parameters, and generates the 400 Bad Request response.
  • AddResponseWritingToMethodCall() handles generating the final response by serializing the handler's return value to the response (and calling await on a Task response, for example).

These were two of the missing pieces in generating the whole RequestDelegate for an endpoint, so in the next post we put the whole thing together by looking at RequestDelegateFactory.Create().


Viewing all articles
Browse latest Browse all 743

Trending Articles