blog post image
Andrew Lock avatar

Andrew Lock

~9 min read

Converting a terminal middleware to endpoint routing in ASP.NET Core 3.0

Upgrading to ASP.NET Core 3.0 - Part 4

In this post I provide an overview of the new endpoint routing system, and show how you can use it to create "endpoints" that run in response to a given request URL path. I show how to take a terminal middleware used in ASP.NET Core 2.x, and convert it to the new ASP.NET Core 3.0 approach.

The evolution of routing

Routing in ASP.NET Core is the process of mapping a request URL path such as /Orders/1 to some handler that generates a response. This is primarily used with the MVC middleware for mapping requests to controllers and actions, but it is used in other areas too. It also includes functionality for the reverse process: generating URLs that will invoke a specific handler with a given set of parameters.

In ASP.NET Core 2.1 and below, routing was handled by implementing the IRouter interface to map incoming URLs to handlers. Rather than implementing the interface directly, you would typically rely on the MvcMiddleware implementation added to the end of your middleware pipeline. Once a request reached the MvcMiddleware, routing was applied to determine which controller and action the incoming request URL path corresponded to.

The request then went through various MVC filters before executing the handler. These filters formed another "pipeline", reminiscent of the middleware pipeline, and in some cases had to duplicate the behaviour of certain middleware. The canonical example of this is CORS policies. In order to enforce different CORS policies per MVC action, as well as other "branches" of your middleware pipeline, a certain amount of duplication was required internally.

The MVC filter pipeline is so similar to the middleware pipeline you've been able to use middleware as filters since ASP.NET Core 1.1.

"Branching" the middleware pipeline was often used for "pseudo-routing". Using extension methods like Map() in your middleware pipeline, would allow you to conditionally execute some middleware when the incoming path had a given prefix.

For example, the following Configure() method from a Startup.cs class branches the pipeline so that when the incoming path is /ping, the terminal middleware executes (written inline using Run()):

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseCors();

    app.Map("/ping", 
        app2 => app2.Run(async context =>
        {
            await context.Response.WriteAsync("Pong");
        });

    app.UseMvcWithDefaultRoute();
}

In this case, the Run() method is a "terminal" middleware, because it returns a response. But in a sense, the whole Map branch corresponds to an "endpoint" of the application, especially as we're not doing anything else in the app2 branch of the pipeline.

Image of a branching middleware pipeline

The problem is that this "endpoint" is a bit of a second-class citizen when compared to the endpoints in the MvcMiddleware (i.e. controller actions). Extracting values from the incoming route are a pain and you have to manually implement any authorization requirements yourself.

Another problem is that there's no way to know which branch will be run until you're already on it. For example, when the request reaches the UseCors() middleware from the above example it would be useful to know which branch/endpoint is going to be executed - maybe the /ping endpoint allows cross-origin requests, while the MVC middleware doesn't.

In ASP.NET Core 2.2, Microsoft introduced the endpoint routing as the new routing mechanism for MVC controllers. This implementation was essentially internal to the MvcMiddleware, so on the face of it, it wouldn't solve the issues described above. However, the intention was always to trial the implementation there and to expand it to be the primary routing mechanism in ASP.NET Core 3.0.

And that's what we have now. Endpoint routing separates the routing of a request (selecting which handler to run) from the actual execution of the handler. This means you can know ahead of time which handler will execute, and your middleware can react accordingly. This is aided by the new ability to attach extra metadata to your endpoints, such as authorization requirements or CORS policies.

Image of endpoint routing

So the question is, how should you map the ping-pong pipeline shown previously to the new endpoint-routing style? Luckily, there's not many steps.

A concrete middleware using Map() in ASP.NET Core 2.x

To make things a little more concrete, lets imaging you have a custom middleware that returns the FileVersion of your application. This is a very basic custom middleware that is a "terminal" middleware – i.e. it always writes a response and doesn't invoke the _next delegate.

public class VersionMiddleware
{
    readonly RequestDelegate _next;
    static readonly Assembly _entryAssembly = System.Reflection.Assembly.GetEntryAssembly();
    static readonly string _version = FileVersionInfo.GetVersionInfo(_entryAssembly.Location).FileVersion;

    public VersionMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        context.Response.StatusCode = 200;
        await context.Response.WriteAsync(_version);
        
        //we're all done, so don't invoke next middleware
    }
}

In ASP.NET Core 2.x, you might include this in your middleware pipeline in Startup.cs by using the Map() extension method to choose the URL to expose the middleware at:

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseCors();

    app.Map("/version", versionApp => versionApp.UseMiddleware<VersionMiddleware>()); 

    app.UseMvcWithDefaultRoute();
}

When you call the app with the a path prefixed with /version (e.g. /version or /version/test) you'll always get the same response, the version of the app:

1.0.0

When you send a request with any other path (that is not handled by static files), the MvcMiddleware will be invoked, and will handle the request. But with this configuration the CORS middleware (added using UseCors()) can't know which endpoint will ultimately be executed.

Converting the middleware to endpoint routing

In ASP.NET Core 3.0, we use endpoint routing, so the routing step is separate from the invocation of the endpoint. In practical terms that means we have two pieces of middleware:

  • EndpointRoutingMiddleware that does the actual routing i.e. calculating which endpoint will be invoked for a given request URL path.
  • EndpointMiddleware that invokes the endpoint.

These are added at two distinct points in the middleware pipeline, as they serve two distinct roles. Generally speaking, you want the routing middleware to be early in the pipeline, so that subsequent middleware has access to the information about the endpoint that will be executed. The invocation of the endpoint should happen at the end of the pipeline. For example:

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    // Add the EndpointRoutingMiddleware
    app.UseRouting();

    // All middleware from here onwards know which endpoint will be invoked
    app.UseCors();

    // Execute the endpoint selected by the routing middleware
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapDefaultControllerRoute();
    });
}

The UseRouting() extension method adds the EndpointRoutingMiddleware to the pipeline, while the UseEndpoints() extension method adds the EndpointMiddleware to the pipeline. UseEndpoints() is also where you actually register all the endpoints for your application (in the example above, we register our MVC controllers only).

Note: As in the example above, it is generally best practice to place the static files middleware before the Routing middleware. This avoids the overhead of routing when requesting static files. It's also important you place Authentication and Authorization controllers between the two routing middleware as described in the migration document.

So how do we map our VersionMiddleware using endpoint routing?

Conceptually, we move our registration of the "version" endpoint into the UseEndpoints() call, using the /version URL as the path to match:

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseRouting();

    app.UseCors();

    app.UseEndpoints(endpoints =>
    {
        // Add a new endpoint that uses the VersionMiddleware
        endpoints.Map("/version", endpoints.CreateApplicationBuilder()
            .UseMiddleware<VersionMiddleware>()
            .Build())
            .WithDisplayName("Version number");

        endpoints.MapDefaultControllerRoute();
    });
}

There's a few things to note, which I'll discuss below.

  • We are building a RequestDelegate using the IApplicationBuilder()
  • We no longer match based on a route prefix, but on the complete route
  • You can set an informational name for the endpoint ("Version number")
  • You can attach additional metadata to the endpoint (not shown in the example above)

The syntax for adding the middleware as an endpoint is rather more verbose than the previous version in 2.x. The Map method here requires a RequestDelegate instead of an Action<IApplicationBuilder>. The downside to this is that visually it's much harder to see what's going on. You can work around this pretty easily by creating a small extension method:

public static class VersionEndpointRouteBuilderExtensions
{
    public static IEndpointConventionBuilder MapVersion(this IEndpointRouteBuilder endpoints, string pattern)
    {
        var pipeline = endpoints.CreateApplicationBuilder()
            .UseMiddleware<VersionMiddleware>()
            .Build();

        return endpoints.Map(pattern, pipeline).WithDisplayName("Version number");
    }
}

With this extension, Configure() looks like the following:

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseRouting();

    app.UseCors();

    // Execute the endpoint selected by the routing middleware
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapVersion("/version");
        endpoints.MapDefaultControllerRoute();
    });
}

The difference in behaviour with regard to routing is an important one. In our previous implementation for ASP.NET Core 2.x, our version middleware branch would execute for any requests that have a /version segment prefix. So we would match /version, /version/123, /version/test/oops etc. With endpoint routing, we're not specifying a prefix for the URL, we're specifying the whole pattern. That means you can have route parameters in all of your endpoint routes. For example:

endpoints.MapVersion("/version/{id:int?}");

This would match both /version and /version/123 URLs, but not /version/test/oops. This is far more powerful than the previous version, but you need to be aware of it.

Another feature of endpoints is the ability to attach metadata to them. In the previous example we provided a display name (primarily for debugging purposes), but you can attach more interesting information like authorization policies or CORS policies, which other middleware can interrogate. For example:

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseRouting();

    app.UseCors();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapVersion("/version")
            .RequireCors("AllowAllHosts")
            .RequireAuthorization("AdminOnly");
    
        endpoints.MapDefaultControllerRoute();
    });
}

In this example we've added a CORS policy (AllowAllHosts) and an authorization policy (AdminOnly) to the version endpoint. When a request to the endpoint arrives, the routing middleware selects the version endpoint, and makes its metadata available for subsequent middleware in the pipeline. The authorization and CORS middleware can see that there are associated policies and act accordingly, before the endpoint is executed.

Do I have to convert my middleware to endpoint routing?

No. The whole concept of the middleware pipeline hasn't changed, and you can still branch or early-return from middleware exactly as you have been able to since ASP.NET Core 1.0. Endpoint routing doesn't have to replace your current approaches, and in some cases it shouldn't.

There's three main benefits to endpoint routing that I see:

  • You can attach metadata to endpoints so intermediate middleware (e.g. Authorization, CORS) can know what will be eventually executed
  • You can use routing templates in your non-MVC endpoints, so you get route-token parsing features that were previously limited to MVC
  • You can more easily generate URLs to non-MVC endpoints

If these features are useful to you, then endpoint routing is a good fit. The ASP.NET Core HealthCheck feature was converted to endpoint routing for example, which allows you to add authorization requirements to the health check.

However if these features aren't useful to you, there's no reason you have to convert to endpoint routing. For example, even though the static file middleware is "terminal" in the sense that it often returns a response, it hasn't been converted to endpoint routing. That's because you generally don't need to apply authorization or CORS to static files, so there would be no benefit (and a performance hit) to doing so.

On top of that you should generally place the static file middleware before the routing middleware. That ensures the routing middleware doesn't try and "choose" an endpoint for every request: it would ultimately be wrong anyway for static file paths, as the static file middleware would return before the endpoint is executed!

Overall, endpoint routing adds a lot of features to the previous routing approach but you need to be aware of the differences when upgrading. If you haven't already, be sure to check out the migration guide which details many of these changes.

Summary

In this post I gave an overview of routing in ASP.NET Core and how it's evolved. In particular, I discussed some of the advantages endpoint routing brings, in terms of separating the routing of a request from the execution of a handler.

I also showed of the changes required to convert a simple terminal middleware used in an ASP.NET Core 2.x app to act as an endpoint in ASP.NET Core 3.0. Generally speaking the changes required should be relatively minimal, but it's important to be aware of the change from "prefix-based routing" to the more fully-featured routing approach.

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