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.
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 int
s:
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 themin
andmax
routes values, so there's no need for error checking in theParseMaxMin
function. - The route values are stored as
string
s, notint
s, so we still need to convert them. We have routing but not model binding. GetRouteData()
gives access to the wholeRouteData
object, so we can also access other data like data tokens. Alternatively, you can useGetRouteValue()
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
.
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 themin
ormax
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 theParseMaxMin
function instead, returning a400
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.
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.