in ASP.NET Core Minimal APIs .NET 7 Source Code Dive ~ 10 min read.

A first look behind the scenes of minimal API endpoints
Behind the scenes of minimal APIs - Part 1

Minimal APIs have a reputation for being fast, but how do they do it? In this and subsequent posts, I look at the code behind some of the minimal API methods and look at how they're designed to take your lambda method and turn it into something that runs quickly.

In this post I take a relatively high-level look at the code behind a call to MapGet("/", () => "Hello World!"), to learn the basics of how this lambda method ends up converted into a RequestDelegate that can be executed by ASP.NET Core.

A reminder that my book, ASP.NET Core in Action, Version Three is available from manning.com and supports minimal APIs. The book very much focuses on using minimal APIs, but I find it interesting to dive into the code to see how things are implemented, which is what this post is about!

All the code in this post is based on the .NET 7.0.1 release.

Minimal APIs and minimal hosting

Minimal APIs were introduced in .NET 6 along with "minimal hosting" to provide a simpler onboarding process for .NET. Together they remove a lot of the ceremony associated with a generic-host based ASP.NET Core app using MVC. A basic hello world app in MVC might be 50-100 lines of code spread across at least 3 classes. With minimal APIs, it's as simple as:

WebApplicationBuilder builder = new WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

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

app.Run();

Obviously there's a lot happening in those four lines which are abstracted away. You still have pretty much the same power as the generic-host, but most of it is hidden from you.

You can read more about the difference between WebApplication and generic-host/Startup-based applications in my series.

In this post I'm not looking at WebApplication; instead we're going to look at how the lambda method () => "Hello world!" is turned into a RequestDelegate, which can be used to handle HTTP requests.

What's a RequestDelegate?

Before we go any further, we should probably answer the question, "what is a RequestDelegate"? According to the documentation, a RequestDelegate is:

A function that can process an HTTP request.

Which is pretty general. But if you look at the definition of the delegate you can see that describes it pretty well:

public delegate Task RequestDelegate(HttpContext context);

So a RequestDelegate is a delegate that takes an HttpContext and returns a Task.

You can think of a delegate as a "named" Func<T>. They're similar in many ways, though they have weird collection semantics too (which I won't go into here). As an aside, I was listening to Mads Torgersen describe delegate as the feature he most dislikes in C# in the No Dogma podcast just the other day.

If you're familiar with ASP.NET Core, you may also recognise that as the signature of the Invoke method for middleware:

public class MyMiddleware
{
    private readonly RequestDelegate _next;
    public MyMiddleware(RequestDelegate next)
        => _next = next;

    public Task Invoke(HttpContext context)
    {
        // Do something...
        return _next(context);
    }
}

An interesting property of RequestDelegates that you can see from the example above is that you can chain calls to them. So Invoke can be cast to a RequestDelegate and can in turn call a RequestDelegate, which itself could call a RequestDelegate. This builds the "middleware pipeline" for your application.

So when we consider a minimal API endpoint such as

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

we need to take the arbitrary lambda method provided as the endpoint handler (which is not a RequestDelegate, as it doesn't have the correct signature), and build a RequestDelegate. In this post we'll look at the high level process of what's happening here, and in the next post we'll start to get into the nitty gritty of creating a RequestDelegate.

From MapGet to RouteEntry

When you call MapGet() on WebApplication (or MapPost(), or MapFallback(), or most of the other Map*() extensions), you're calling an extension method on IEndpointRouteBuilder. Eventually, when you follow the overloads down far enough, you reach the private Map() method, which looks something like this:

private static RouteHandlerBuilder Map(
    this IEndpointRouteBuilder endpoints,
    RoutePattern pattern,
    Delegate handler,
    IEnumerable<string>? httpMethods,
    bool isFallback)
{
    RouteEndpointDataSource dataSource = endpoints.GetOrAddRouteEndpointDataSource();
    return dataSource.AddRouteHandler(pattern, handler, httpMethods, isFallback);
}

The Delegate handler argument is the handler lambda method you pass when mapping the method. Note that it's the very generic Delegate type at this point; later on we need to extract the parameters from the delegate and work out how to build them by binding the request or using services from DI.

This method first retrieves the RouteEndpointDataSource from the builder, which essentially holds a list of the routes in the application. We add the route to the collection by calling AddRouteHandler() on it. This creates a RouteEntry object (essentially just a bag of properties) and adds it to the collection. It returns a RouteHandlerBuilder instance which lets you customise the endpoint by adding metadata and filters (for example) to the endpoint.

That's basically all that happens on the call to MapGet; it adds the route to the endpoint collection. Where things get interesting is when you call app.Run() and handle a request.

Creating the endpoints

When you make your first request to ASP.NET Core where the request makes it to the EndpointRoutingMiddleware (added implicitly, or by calling UseRouting()), the middleware triggers the building of all your endpoints (and of the graph of routes they correspond to).

I wrote a series on visualizing the routes in your ASP.NET Core application using the DOT language and a Custom DfaWriter if you're interested. That was based on ASP.NET Core 3.0, but it should still be broadly applicable as far as I know!

For each RouteEntry in your application's RouteEndpointDataSource(s), the app calls CreateRouteEndpointBuilder(), which returns a RouteEndpointBuilder instance. This contains all the general metadata about the endpoint, such as the display name, as well as OpenAPI metadata, authorization metadata, as well as the actual RequestDelegate that is executed in response to a request.

The following snippet shows how the display name for an endpoint is built up by first trying to get a "sensible" name for it, such as a concrete method name or a local function name. If that fails (as in our simple () => "Hello World!") you'll end up with a name that describes the HTTP pattern only: HTTP: GET /

var displayName = pattern.RawText ?? pattern.DebuggerToString();

// Don't include the method name for non-route-handlers because the name is just "Invoke" when built from
// ApplicationBuilder.Build(). This was observed in MapSignalRTests and is not very useful. Maybe if we come up
// with a better heuristic for what a useful method name is, we could use it for everything. Inline lambdas are
// compiler generated methods so they are filtered out even for route handlers.
if (isRouteHandler && TypeHelper.TryGetNonCompilerGeneratedMethodName(handler.Method, out var methodName))
{
    displayName = $"{displayName} => {methodName}";
}

if (entry.HttpMethods is not null)
{
    // Prepends the HTTP method to the DisplayName produced with pattern + method name
    displayName = $"HTTP: {string.Join(", ", entry.HttpMethods)} {displayName}";
}

if (isFallback)
{
    displayName = $"Fallback {displayName}";
}

Next the method builds up the metadata for the endpoint. I'm not going to look at that process in detail this post, but "adding metadata" involves adding various objects to a List<object>, so the metadata can be basically anything.

To build the metadata collection, CreateRouteEndpointBuilder() adds the following to the metadata collection, in this order:

  • The MethodInfo of the handler to execute.
  • An HttpMethodMetadata object describing the HTTP verbs the handler responds to.
  • For each of the route-group conventions:
    • Apply the convention to the builder, which may add metadata.
  • Read the method and infer all the parameter types and their sources, caching the data and storing it as a RequestDelegateMetadataResult, using the RequestDelegateFactory.
    • This is a big step, where most of the magic happens. I'm going to take a detailed look at this process in a separate post.
  • Add any attributes applied to the method as metadata (e.g. [Authorize] attributes etc).
  • For each of the endpoint-specific conventions:
    • Apply the convention to the builder, which may add metadata.
  • Use the previously built RequestDelegateMetadataResult to build a RequestDelegate for the endpoint.
  • For each of the "finally" route-group conventions:
    • Apply the convention to the builder, which may add metadata

These steps are all a bit vague, but the two most important stages are where the RequestDelegateMetadataResult and RequestDelegate are built. We'll look in more detail at those steps later in this and in subsequent posts.

Once the RouteEndpointBuilder has a RequestDelegate, and the Metadata has been fully populated, the RouteEndpointDataSource calls RouteEndpointBuilder.Build(). This finalizes the endpoint as a RouteEndpoint. Once all the endpoints are built, the DfaMatcher can do its thing to build up the full graph of endpoints used in routing, which may look something like this (taken from my previous series):

An example of a DFA graph built for an app

Once the DfaMatcher is built, the EndpointRoutingMiddleware can correctly select the endpoint to route to, and the RequestDelegate that should be executed.

Building the RequestDelegateFactoryOptions with ShouldDisableInferredBodyParameters

I mentioned previously that inferring the metadata about an endpoint and building the RequestDelegate are crucial parts of the endpoint building process. The first step for these is to build an RequestDelegateFactoryOptions object. This is done in the CreateRDFOptions() method, shown below.

private RequestDelegateFactoryOptions CreateRDFOptions(
    RouteEntry entry, RoutePattern pattern, RouteEndpointBuilder builder)
{
    var routeParamNames = new List<string>(pattern.Parameters.Count);
    foreach (var parameter in pattern.Parameters)
    {
        routeParamNames.Add(parameter.Name);
    }

    return new()
    {
        ServiceProvider = _applicationServices,
        RouteParameterNames = routeParamNames,
        ThrowOnBadRequest = _throwOnBadRequest,
        DisableInferBodyFromParameters = ShouldDisableInferredBodyParameters(entry.HttpMethods),
        EndpointBuilder = builder,
    };
}

The first step in the method is to read all the route parameter names from the route pattern for the endpoint. So for a route like /users/{id} this would be a list containing just the string id.

The RoutePatternParser is responsible for taking a string like /users/{id} and converting it into a RoutePattern object with all the segments, parameters, constraints, and defaults correctly identified. There's 600 lines of code in there alone, so I skipped over that process in this post!

As well as holding the route parameter names, the options object holds some other helper functions and settings. The _applicationServices field is the DI container (the IServiceProvider) for the app, and the _throwOnBadRequest field is set by the RouteHandlerOptions.ThrowOnBadRequest property (which is true by default when running in development).

The interesting part of the CreateRDFOptions object is the call to ShouldDisableInferredBodyParameters. This method calculates how a complex object parameter in your endpoint should be treated. For example, if you have a MapPost endpoint that looks something like this:

app.MapPost("/users", (UserModel user) => {});

then minimal APIs will attempt to bind the UserModel object to the request body. However, if you have the same delegate with a MapGet request:

app.MapGet("/users", (UserModel user) => {});

then this will attempt to treat the UserModel as a service in DI, and, if not available (which presumably it won't be!) will throw an InvalidOperationException.

Whether a complex model should attempt to bind to the body by default or not is controlled by the RequestDelegateFactoryOptions.DisableInferBodyFromParameters property, which is set using the ShouldDisableInferredBodyParameters() method shown below.

private static bool ShouldDisableInferredBodyParameters(IEnumerable<string>? httpMethods)
{
    static bool ShouldDisableInferredBodyForMethod(string method) =>
        // GET, DELETE, HEAD, CONNECT, TRACE, and OPTIONS normally do not contain bodies
        method.Equals(HttpMethods.Get, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Delete, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Head, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Options, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Trace, StringComparison.Ordinal) ||
        method.Equals(HttpMethods.Connect, StringComparison.Ordinal);

    // If the endpoint accepts any kind of request, we should still infer parameters can come from the body.
    if (httpMethods is null)
    {
        return false;
    }

    foreach (var method in httpMethods)
    {
        if (ShouldDisableInferredBodyForMethod(method))
        {
            // If the route handler was mapped explicitly to handle an HTTP method that does not normally have a request body,
            // we assume any invocation of the handler will not have a request body no matter what other HTTP methods it may support.
            return true;
        }
    }

    return false;
}

The logic in this method can be summarised as the following:

  • Does the endpoint accept all HTTP verbs.
    • If so, enable inferred binding to the request body.
  • Does the endpoint accept any of the following verbs: GET, DELETE, HEAD, CONNECT, TRACE, OPTIONS
    • If so, disable inferred binding to the request body.
  • Otherwise, enable inferred binding.

In most cases, when using MapGet or MapPost for example, your endpoints only handle a single HTTP method, so whether or not binding to the body should be disabled is pretty simple.

Note that this only determines the default inferred binding to the request body. You can always override it using [FromBody] and force things like binding to a GET request's body.

In the next post, we'll look at the RequestDelegateFactory type and learn how model binding in minimal APIs works and how the final RequestDelegate is built.

Summary

In this post I provided a high level description of how calling MapGet() on WebApplication results in a RequestDelegate being built to handle the request. Calling MapGet() adds a RouteEntry object to the RouteEndpointDataSource, but no building of the endpoint takes place until after the host is started and you start handling requests.

When the routing middleware gets its first request, it calls CreateRouteEndpointBuilder() on every RouteEntry. This builds a list of all the metadata added to the endpoint (or to any route groups the endpoint is part of), builds the display name for the endpoint, and builds a RequestDelegate. This data is used to build a DFA graph for routing the endpoints.

In this post I also started to peak into the RequestDelegate-building process, showing how whether an endpoint attempts to bind a complex parameter to the request body is controlled by the HTTP verbs it supports. In the next post we take these details and look at how they're used to build up the metadata about the endpoint, as well as the final RequestDelegate.

Andrew Lock | .Net Escapades

Stay up to the date with the latest posts!

Oops! Check your details and try again.
Thanks! Check your email for confirmation.