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 describedelegate
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 RequestDelegate
s 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 theRequestDelegateFactory
.- 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 aRequestDelegate
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):
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 aRoutePattern
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 aGET
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
.