Accessing route values in endpoint middleware in ASP.NET Core 3.0

andrewlocknet

Andrew Lock "Sock"

Posted on April 19, 2020

Accessing route values in endpoint middleware in ASP.NET Core 3.0

Accessing route values in endpoint middleware in ASP.NET Core 3.0

In my recent series about upgrading to ASP.NET Core 3.0, I described how the new endpoint routing system can be combined with terminal middleware (i.e. middleware that generates a response). In that post I showed how you can map a path, e.g. /version, to the terminal middleware and create an endpoint.

There are a number of benefits to this, such as removing the duplication of CORS and Authorization logic that is required in ASP.NET Core 2.x. Another benefit is that you now get proper "MVC-style" routing with placeholders and capture groups, instead of the simple "constant-prefix" Map() function that's available in ASP.NET Core 2.0.

In this post I show how you can access the route values exposed by the endpoint routing system in your middleware.

Route values in endpoint routing

Endpoint routing separates the "identify which route was selected" step, from the "execute the endpoint at that route" step of an ASP.NET Core middleware pipeline (see my previous post for a more in depth discussion). By splitting these two steps, which previously were both handled internally by the MVC middleware, we can now use fully featured routing (which was previously an MVC feature) with non-MVC components, such as middleware.

There is a general push in this direction among the ASP.NET Core team currently. Project Houdini is attempting to turn more MVC features into "core" ASP.NET Core features.

Lets imagine you want to have a simple endpoint that generates random numbers (I know, it's a silly example). The caveat is that the request must also contain a max and min value for the range of numbers. For example, /random/50/100 should return a random value between 50 and 100.

In ASP.NET Core 2.x, handling dynamic routes like this is a bit of a pain for middleware. In fact, generally speaking, it probably wouldn't be worth the hassle at all - you'd be better off just using the routing and model binding features built in to MVC instead. Nevertheless, for comparison purposes (and to show the benefits of 3.0) I show how you might do this below.

The basic random number middleware

Whichever approach we're going to be using - either the manual 2.x approach or the 3.0 endpoint routing approach, we need our random number generating middleware. The basic outline of the middleware is shown below

public class RandomNumberMiddleware
{
    private static readonly Random _random = new Random();
    public RandomNumberMiddleware(RequestDelegate next) { } // Required

    public async Task InvokeAsync(HttpContext context)
    {
        // Try and get the max and min values from the route/path
        var maybeValues = ParseMaxMin(context);

        if (!maybeValues.HasValue)
        {
            context.Response.StatusCode = 400; //couldn't parse route values
            return;
        }

        // deconstruct the tuple
        var (min, max) = maybeValues.Value; 

        // Get the random number using the extracted limits
        var value = GetRandomValue(min, max);

        // Write the response as plain text
        context.Response.ContentType = "text/plain";
        await context.Response.WriteAsync(value.ToString());
    }

    private static int GetRandomValue(int min, int max)
    {
        // Get a random number (swapping max and min if necessary)
        return min < max
            ? _random.Next(min, max)
            : _random.Next(max, min);
    }

    private static (int min, int max)? ParseMaxMin(HttpContext context)
    {
        /* Parse the values from the HttpContext, shown below*/
    }
}

The middleware shown above is pretty much the same both in the "legacy" version and in the endpoint routing version, it's only the ParseMaxMin function that will change. Follow through the InvokeAsync function to make sure you understand what's happening. First we try and extract the max and min values from the request (we'll come to that shortly), and if that fails, we return a 400 Bad Request response. If the values were extracted successfully, we generate a random number and return it in a plain text response.

Hopefully that's all relatively easy to follow. Which brings us to the ParseMaxMin function. This function needs to grab the max and min values from the incoming request, i.e. the 10 and 50 from /random/10/50.

Parsing the path in ASP.NET Core 2.0

Unfortunately, without endpoint routing we're stuck with plain ol' string manipulation, splitting segments on / and trying to parse out numbers:

private static (int min, int max)? ParseMaxMin(HttpContext context)
{
    var path = context.Request.Path;
    if (!path.HasValue) return null; // e.g. /random, /random/

    var segments = path.Value.Split('/');

    if (segments.Length != 3) return null; // e.g. /random/12, /random/blah, /random/123/12/tada
    System.Diagnostics.Debug.Assert(string.IsNullOrEmpty(segments[0])); // first segment is always empty
    if (!int.TryParse(segments[1], out var min)) return null; // e.g. /random/blah/123
    if (!int.TryParse(segments[2], out var max)) return null; // e.g. /random/123/blah

    return (min, max);
}

This isn't a huge amount of code, but it's the gnarly sort of stuff I hate writing, just to grab a couple of values from the path. I added a bunch of the error checking to catch all the mal-formed URLs where the user doesn't provide two integers as well, and we'll return a 400 response for those. It's not awful, but you can easily see how the code for this could balloon with more complex requirements.

To use the middleware we create a branch using the Map extension method in Startup.Configure().

public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();

        app.Map("/random", // branch the pipeline
            random => random.UseMiddleware<RandomNumberMiddleware>()); // run the middleware

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/", async context =>
            {
                await context.Response.WriteAsync("Hello World!");
            });
        });
    }
}

Now if you hit a valid URL prefixed with /random you'll get a random number. If you hit the root home page at / you'll get the Hello World response, and if you hit an invalid URL prefix with /random you'll get a 400 Bad Request response.

Image of the different responses possible when using manual parsing of route parameters

Accessing route values from middleware

Now lets look at the alternative approach, using endpoint routing. I'm going to work backwords in this case. To start with, I'll create an extension method to make it easy to register the middleware as an endpoint. This code is straight out of my previous post, so be sure to check that one if the code below doesn't make sense.

public static class RandomNumberRouteBuilderExtensions
{
    public static IEndpointConventionBuilder MapRandomNumberEndpoint(
        this IEndpointRouteBuilder endpoints, string pattern)
    {
        var pipeline = endpoints.CreateApplicationBuilder()
            .UseMiddleware<RandomNumberMiddleware>()
            .Build();

        return endpoints.Map(pattern, pipeline).WithDisplayName("Random Number");
    }
}

With this extension method available, we can register our middleware with a route pattern. This is the same routing used by MVC so you can use all the same features - optional and default values, constraints, catch-all parameters. In this case, I've added constraints to the min and max route values to ensure that the values provided are convertible to int. More on that shortly.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapRandomNumberEndpoint("/random/{min:int}/{max:int}"); // <-- Add this line
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

We're nearly there. All that remains is to implement ParseMaxMin for our middleware. Now, to be clear, we could still manually parse the values out of Request.Path as we did previously, but we don't have to. Endpoint routing takes care of all that itself - all we need to do is to access the route values by name, and convert them to ints:

private static (int min, int max)? ParseMaxMin(HttpContext context)
{
    // Retrieve the RouteData, and access the route values
    var routeValues = context.GetRouteData().Values;

    // Extract the values
    var minValue = routeValues["min"] as string;
    var maxValue = routeValues["max"] as string;

    // Alternatively, grab the values directly
    // var minValue = context.GetRouteValue("min") as string;
    // var maxValue = context.GetRouteValue("max") as string;

    // The route values are strings, but will always be parseable as int 
    var min = int.Parse(minValue);
    var max = int.Parse(maxValue);

    // The values must be valid, so no error cases
    return (min, max);
}

There's a couple of things to note with this approach:

  • The route value constraint ensures we can only get valid int values for the min and max routes values, so there's no need for error checking in the ParseMaxMin function.
  • The route values are stored as strings, not ints, so we still need to convert them. We have routing but not model binding.
  • GetRouteData() gives access to the whole RouteData object, so we can also access other data like data tokens. Alternatively, you can use GetRouteValue() to access a single route value at a time.

The code here is much simpler than before, and isn't messing around with string manipulation. It's much more obvious what's going on! The behaviour is the same as before in the happy cases, but if you enter values for max and min that can't be parsed as integers, you'll get a 404 Not Found response, rather than a 400 Bad Request.

Image of the different responses possible when using endpoint routing

The code in ParseMaxMin is definitely nicer than before, but there's a couple of problems with this approach:

  • Getting a 404 when you have a typo in the min or max values is not a good user experience. It happens because we're using the route constraints for validation, which is generally not a good idea. A better approach would be to remove the constraints, and handle invalid values in the ParseMaxMin function instead, returning a 400 to the user instead.
  • If you don't specify the route template correctly, such as a typo in max/min (e.g. /random/{maximum}/{min}), or you forgot to include one of them (e.g. /random/{max}), you will get an exception at runtime when the middleware executes!

Clearly we need to be a bit more careful, even when using endpoint routing.

Playing it safe

First of all, lets remove the int constraint from the route path. We can also make the parameters optional, so that requests to /random/123 etc are still handled by the middleware, ensuring we can generate a more meaningful 400 Bad Request instead of a 404.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapRandomNumberEndpoint("/random/{min?}/{max?}"); // no route value constraints, and optional parameters
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

Next, even though we can easily extract route values from the request, it's wise to be defensive in the middleware. Using int.TryParse is a simple way to add some safety, and ensures we return a 400 Bad Request response when the user enters gibberish, or misses parameters entirely.

private static (int min, int max)? ParseMaxMin(HttpContext context)
{
    var routeValues = context.GetRouteData().Values;
    var minValue = routeValues["min"] as string;
    var maxValue = routeValues["max"] as string;

    if (!int.TryParse(minValue, out var min)) return null; // e.g. /random/blah/123
    if (!int.TryParse(maxValue, out var max)) return null; // e.g. /random/123/blah

    return (min, max);
}

Running the application again, gives us the best of both worlds. A simple ParseMaxMin function, and the application behaviour we're after.

Image of returning a 400 response instead of a 404 response when an invalid request is made

Endpoint routing certainly makes things easier for these sorts of cases, but I think things will really get interesting if Project Houdini ends up allowing things like model binding to be used to simplify some of this mapping code (without bloating the simple approach if that's all you need). Either way, it's good to know accessing routing information is just a GetRouteData() away if you need it!

Summary

In this post I showed how you could access route values from terminal middleware when used with endpoint routing. I showed how endpoint routing removes a lot of the previous boilerplate that would be required when branching the middleware with Map. Instead, you can rely on endpoint routing to parse the request's path for you.

As a follow up, I described the behaviour if you go a bit too far with routing, and use route constraints for validation. While tempting (because it's so easy!) it's better to perform validation of route parameters in your middleware, so you can return an appropriate (400) response if necessary.

💖 💪 🙅 🚩
andrewlocknet
Andrew Lock "Sock"

Posted on April 19, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related