My team is finally building a full-blown ASP.NET Core 3.0 project in our production environment, and we could not be more excited!

As I am prone to doing, I decided to take one particular section of ASP.NET Core 3.0 and attempt to explain it so I could remember what it does. This time around, I got drawn into exploring the routing system in ASP.NET Core, and how we can use and modify it to present all kinds of custom URLs and mapping.

life is a succession of choices, what is yours?
Which way did he go George, which way did he go? Photo by Javier Allegue Barros / Unsplash

So, for this post and the next several, we're going to take a deep dive into routing in ASP.NET Core 3.0 and show examples of a bunch of cool features. Let's get going!

Sample Project

As always, there is a sample project over on GitHub that I use to create my examples. This was created with .NET Core 3.1 and Visual Studio 2019.  Check it out!

exceptionnotfound/RoutingAspNetCoreDemo
Contribute to exceptionnotfound/RoutingAspNetCoreDemo development by creating an account on GitHub.

Endpoints and Routes

In order to discuss routing in ASP.NET Core 3.0, we must first discuss a bit of terminology: endpoints, routes, and segments.

An endpoint in ASP.NET Core 3.0 is a URL of a resource. (It's actually a bit more complicated than this, more on that in the Route Templates section).  

Routes are patterns which can be matched to given endpoints to redirect requests. Said patterns are used to parse any given URL to see if it should be redirected to a known endpoint.  

A route segment is a piece of the URL, as delimited by slashes (/).

For an example, let's take the following URL path:

/User/Details/29/John-Smith

ASP.NET Core will parse this URL by splitting it into segments. We end up with four segments:

  1. User
  2. Details
  3. 29
  4. John-Smith

The Routing system in ASP.NET Core now has the job of taking those segments (and their order) and matching them to the known routes in the system, and thereby forward requests to their matching endpoints. The routing system can also generate URLs for these endpoints.

How Does Routing Work?

Internally, the "routing" system is actually middleware that gets inserted into the application pipeline during startup. It is often the last or near-to-last piece of middleware inserted, because it directly controls which actions, pages, resources, etc. to redirect to.

Lucky for us, inserting the routing middleware is very simple. In our Startup.cs class, we need the following method:

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

    app.UseEndpoints(endpoints =>
    {
        //Define endpoint routes here
    });
}

The extension method UseEndpoints() is new in ASP.NET Core 3.0, and allows us to create the endpoints defined earlier.

You might be wondering why we need both UseRouting() and UseEndpoints(), if we can define custom routes in the latter. This is because what UseRouting() does is enable middleware that matches URLs to given endpoints, and UseEndpoints() actually executes said endpoints through their delegates. This is also the reason why "route" and "endpoint" are used almost interchangeably in this post.

Ta-da! That's all we have to do to enable our routing middleware! Now let's examine some routes to see what exactly they do, and how they are matched.

Anatomy of a Basic Route

Here's an basic route; so basic, in fact, that Visual Studio 2019 generated it when I first created the sample application.

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
}
This example uses MapControllerRoute(), there are other methods for Razor Pages, Areas, Blazor, etc.

This route definition has two parameters, a name and a pattern. The name is how we can call the route for use when creating links, and the pattern is the URL pattern the route is looking to match.

In other words, this route will match URLs that look like the following:

/Home/Index
/Home/Privacy
/User/Index
/User/Index/91
/Blog/Article/43
/Blog/Article

In short, any URL with the controller name first, followed by the action name, followed by an optional ID.

Route Defaults

This particular route also specifies that the Home controller and Index action are the defaults ("{controller=Home}" and "{action=Index}"). This is a very concise way of introducing default values for your route segments.

You can also explicitly set the defaults in a given route segment, using a format that looks like this:

endpoints.MapControllerRoute(
    name: "default",
    pattern: "{controller}/{action}/{id?}",
    defaults: new {controller = "Home", action = "Index"});

This is functionally equivalent to the route defined earlier.

Route Templates

Internally, each endpoint is comprised of a template, metadata, and a request delegate that serves the response from the endpoint. We've already seen some examples of a route template, but now we can get a little more complex.

For example, let's take the following template:

/blog/post/{id}/{**title}

Here we see two route parameters, {id} and {**title}. The double-asterisk syntax (**) specifies that this segment of the URL can match anything. So, the following URLs would match this template:

/blog/post/28/This-Is-A-Sample-Title
/blog/post/87/72953735
/blog/post/109/thisisanothersampletitle
/blog/post/194/5

The following URLs would ALSO match this template:

/blog/post/id/this-is-a-sample-title
/blog/post/this-is-a-sample-title/anything

In most cases, we won't want the second set of matching URLs to actually match this template. We need to tell routing what kinds of data can be used in our URL segments, and we can use Route Constraints to do exactly this.

Route Constraints

Route Constraints are restrictions on what can and cannot be in each segment of a URL template. The most concise way to define them is to include them directly in the template itself, like so:

/blog/post/{id:int}/{**title}

Now the {id} segment is restricted to being an integer.

We can also use multiple constraints on a single segment, like so:

/blog/post/{id:int:range(1,1000)}/{**title}

Now the {id} parameter not only must be an integer, it must also be in the range 1 to 1000 in order to match this template.

Custom Route Constraints

There are times when we'd really like to have a particular route constraint, but that constraint is not already supported by ASP.NET Core 3.0. In these times, we can create a custom route constraint.

Let's say we want a single route constraint to implement all of the following rules:

  • The segment must be an integer.
  • The segment must be greater than or equal to 1.
  • The segment must exist.

We can create a class that implements IRouteConstraint and adheres to all of these rules, like so:

public class RequiredPositiveIntRouteConstraint : IRouteConstraint
{
    public bool Match(HttpContext httpContext, 
                        IRouter router, 
                        string parameterName, 
                        RouteValueDictionary values, 
                        RouteDirection routeDirection)
    {
        return new IntRouteConstraint().Match(httpContext, router, parameterName, values, routeDirection)
            && new RequiredRouteConstraint().Match(httpContext, router, parameterName, values, routeDirection)
            && new MinRouteConstraint(1).Match(httpContext, router, parameterName, values, routeDirection);
    }
}
I cheated a bit in this example by using three existing route constraints, but I'm sure you get the idea.

We also need to attach this constraint to any route that is using it, and we can do so like this:

endpoints.MapControllerRoute(
    name: "userdetails",
    pattern: "user/{id}/{**name}",
    defaults: new {controller = "User", action = "Details"},
    constraints: new { id = new RequiredPositiveIntRouteConstraint() }
);

Note the syntax of the "constraints" parameter: it takes an anonymous array of name/value pairs, where the name is expected to be the name of a segment. Don't accidentally make a typo in that name!

For more about Route Constraints, check the official docs!

Creating URLs with LinkGenerator

One of the cool features in ASP.NET Core 3.0 is the LinkGenerator class. This class, which is injectable, can generate URLs for you within C# classes, Razor Pages, or views.  

An example might look like this:

public class HomeController : Controller
    {
        private readonly LinkGenerator _linkGenerator;

        public HomeController(LinkGenerator generator)
        {
            _linkGenerator = generator;
        }

        public IActionResult LinkGenerator()
        {
            Random random = new Random();
            var storeID = random.Next(1, 100);
            ViewData["GeneratedLink"] = _linkGenerator.GetPathByAction("ViewProducts", "Store", new { storeID });
            return View();
        }
    }
ViewData["GeneratedLink"] will be "/ViewProducts/Store/{id}"

The LinkGenerator has many overloads that help generate URLs, including:

  • GetPathByAction() - Generates the path (the portion of the URL after the root, e.g. "/ViewProducts/Store/18") for a controller and action.
  • GetPathByPage() - Generates the path for a razor page.
  • GetUriByAction() - Generates the full URL using the provided scheme, host, action, controller, and route values.
  • GetUriByPage() - Generates the full URL using the provided scheme, host, page location, handler, and route values.

For more details, check the docs!

Parameter Transformers

One last neat feature of ASP.NET Core Routing is Parameter Transformers. Parameter Transformers are service layer classes that modify the route segments and transform them into a new string value.

For example, let's say we have the following controller action (with it's corresponding view):

public class UserController : Controller
{
    public IActionResult NamesAndHealthData()
    {
        return View();
    }
}

By default, any URLs generated by routing that point to this action will look like this:

/user/namesandhealthdata

But that's not great for readability. What if we wanted instead to generate this URL:

/user/names-and-health-data

We can do this automatically using a Parameter Transformer.

First, we must decorate the action with a route that allows for custom transformations, like so:

public class UserController : Controller
{
    [HttpGet("[controller]/[action]")] //New attribute
    public IActionResult NamesAndHealthData()
    {
        return View();
    }
}

Now we need a class to do the actual transformation. This class needs to inherit from IOutboundParameterTransformer.

public class SlugifyParameterTransformer : IOutboundParameterTransformer
{
    public string TransformOutbound(object value)
    {
        if(value == null)
        {
            return null;
        }
        return Regex.Replace(value.ToString(), "([a-z])([A-Z])", "$1-$2").ToLower();
    }
}

We now need to have this transformer included in our services layer. We do that by creating a new route convention:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().AddMvcOptions(options =>
    {
        options.Conventions.Add(new RouteTokenTransformerConvention(
                                    new SlugifyParameterTransformer()));
    });
}

Finally, we can generate a link to this action. We do so using tag helpers:

<a class="nav-link text-dark" asp-controller="User" asp-action="NamesAndHealthData">Parameter Transformer</a>

Note that we still use the non-hyphenated name of the action in this anchor tag. We need to do this because the route uses that name; the transforming happens after the route is matched.

Summary

Routing in ASP.NET Core 3.0 provides us with an extensible, intuitive framework to handle our endpoints, URLs, and links.

  • Routing exposes endpoints and maps incoming requests to them using routes.
  • We can use route defaults, route templates, and route constraints to modify the behavior or content of a given route.
  • We can generate links using the LinkGenerator class.
  • We can modify the URL parameters using Parameter Transformers.

Don't forget to check out the sample project on GitHub!

In the next few posts, we will demonstrate how to do convention-based routing in MVC, attribute routing in MVC, and routing in Razor Pages. Stick around for more good stuff!

Happy Coding!