This is the next in a series of posts on using the middleware as filters feature of ASP.NET Core 1.1 to add a url culture provider to your application. To get an idea for how this works, take a look at the microsoft.com homepage, which includes the request culture in the url.

The microsoft.com homepage with url culture provider

In my original post, I showed how you could set this up in your own app using the new RouteDataRequestCultureProvider which shipped with ASP.NET Core 1.1. When combined with the middleware as filters feature, you can extract this culture name from the url and use it update the request culture.

In my previous post, we extended our implementation to setup global conventions, to ensure that all our routes would be prefixed with a {culture} url segment. As I pointed out in the post, the downside to this approach is that urls without a culture segment are not longer valid. Hitting the home page / of your application would give a 404 - hardly a friendly user experience!

In this post, I'll show how we can create a custom route constraint to help prevent invalid route matching, and add additional routes to catch those pesky 404s by redirecting to a cultured version of the url.

Creating a custom route constraint

As a reminder, in the last post we setup both a global route and an IApplicationModelConvention for attribute routes. The techniques described in this post can be used with both approaches, but I will just talk about the global route for brevity.

The global route we created used a {culture} segment which is extracted by the CultureProvider to determine the request culture:

app.UseMvc(routes =>  
            {
                routes.MapRoute(
                    name: "default",
                    template: "{culture}/{controller=Home}/{action=Index}/{id?}");

One of the problems with this route as it stands, is that there are no limitations on what can match the {culture} segment. If I navigate to /gibberish/ then that would match the route, using the default values for controller and action, and setting culture=gibberish as a route value.

Image of gibberish route value

Note that the url contains the route value gibberish, even though the request has fallen back to the default culture as gibberish is not a valid culture. Whether you consider this a big problem or not is somewhat up to you, but consider the case where the url is /Home/Index - that corresponds to a culture of Home and a controller of Index, even though this is clearly not the intention in the url.

Creating a constraint using regular expressions

We can mitigate this issue by adding a constraint to the route value. Constraints limit the values that a route value is allowed to have. If the route value does not satisfy the constraint, then the route will not match the request. There are a whole host of constraints you can use in your routes, such as restricting to integers, maximum lengths, whether the value is optional etc. You can also create new ones.

We want to restrict our {culture} route value to be a valid culture name, i.e. a 2 letter language code, optionally followed by a hyphen and a 2 letter region code. Now, ideally we would also validate that the 2 letters are actually a valid language (e.g. en, de, and fr are valid while zz is not), but for our purposes a simple regular expression will suffice.

With this slightly simplified model, we can easily create a new constraint to satisfy our requirements using the RegexRouteConstraint base class to do all the heavy lifting for us:

using Microsoft.AspNetCore.Routing.Constraints;

public class CultureRouteConstraint : RegexRouteConstraint  
{
    public CultureRouteConstraint()
        : base(@"^[a-zA-Z]{2}(\-[a-zA-Z]{2})?$") { }
}

The next step before we can use the constraint in our routes, is to tell the router about it. We do this by providing a string key for it, and registering our constraint with the RouteOptions object in ConfigureServices. I chose the key "culturecode".

services.Configure<RouteOptions>(opts =>  
    opts.ConstraintMap.Add("culturecode", typeof(CultureRouteConstraint)));

With this in place, we can start using the constraint in our routes

Using a custom constraint in routes

Using the constraint is as simple as adding the key "culturecode" after a colon when specifying our route values:

app.UseMvc(routes =>  
{
    routes.MapRoute(
        name: "default",
        template: "{culture:culturecode}/{controller=Home}/{action=Index}/{id?}");
});

Now, if we hit the gibberish url, we are met with the following instead:

Gibberish url returning a 404

Success! Sort of. Depending on how you look at it. The constraint is certainly doing the job, as the url provided does not match the specified route, so MVC returns a 404.

Adding the culture constraint doesn't seem to achieve a whole lot on its own, but it allows us to more safely add additional catch-all routes, to handle cases where the request culture was not provided.

Handling urls with no specified culture

As I mention in my last post, one of the problems with adding culture to the global routing conventions is that urls such as your home page at / will not match, and will return 404s.

How you want to handle to handle this is a matter of opinion. Maybe you want to have every 'culture-less' route match its 'cultured' equivalent with the default culture, so / would serve the same data as /en-GB/ (for your default culture).

An approach I prefer (and in fact the behaviour you see on the www.microsoft.com website), is that hitting a culture-less route sends a 302 redirect to the cultured route. In that case, / would redirect to /en-GB/.

We can achieve this behaviour by combining our culture constraint with a couple of additional routes, which we'll place after our global route defined above. I'll introduce the new routes one at a time.

routes.MapGet("{culture:culturecode}/{*path}", appBuilder => { });  

This route has two sections to it, the first route value is the {culture} value as we've seen before. The second, is a catch-all route which will match anything at all. This route would catch paths such as /en-GB/this/is/the/path, /en-US/, /es/Home/Missing - basically anything that has a valid culture value.

The handler for this method is essentially doing nothing - normally you would configure how to handle this route, but I am explicitly not adding to the pipeline, so that anything matching this route will return a 404. That means any URL which

  1. Has a culture; and
  2. Does not match the previous global route url

will return a 404.

Redirecting culture-less routes to the default culture

The above route does not do anything when used on its own after the global route, but it allows us to use a complete catch-all route afterward. It essentially filters out any requests that already have a culture route-value specified.

To redirect culture-less routes, we can use the following route:

routes.MapGet("{*path}", (RequestDelegate)(ctx =>  
{
    var defaultCulture = localizationOptions.DefaultRequestCulture.Culture.Name;
    var path = ctx.GetRouteValue("path") ?? string.Empty;
    var culturedPath = $"/{defaultCulture}/{path}";
    ctx.Response.Redirect(culturedPath);
    return Task.CompletedTask;
}));

This route uses a different overload of MapGet to provide a RequestDelgate rather than the Action<IApplicationBuilder> we used in the previous route. The difference is that a RequestDelegate is explicitly handling a matched route, while the previous route was essentially forking the pipeline when the route matched.

This route again uses a catch-all route value called {path}, which this time contains the whole request URL.

First, we obtain the name of the default culture from the RequestLocalizationOptions which we inject into the Configure method (see below for the full code in context). This could be en-GB in my case, or it may be en-US, de etc.

Next, we obtain the request url by fetching the {path} from the request and combine it with our default culture to create the culturedPath.

Finally, we redirect to the culture path and return a completed Task to satisfy the RequestDelegate method signature.

You may notice that I am only redirecting on a GET request. This is to prevent unexpected side effects, and in practice should not be an issue for most MVC sites, as users will be redirected to cultured urls when first hitting your site.

Putting it all together

We now have all the pieces we need to add redirecting to our MVC application. Our Configure method should now look something like this:

public void Configure(IApplicationBuilder app, RequestLocalizationOptions localizationOptions)  
{
    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{culture:culturecode}/{controller=Home}/{action=Index}/{id?}");
        routes.MapGet("{culture:culturecode}/{*path}", appBuilder => { });
        routes.MapGet("{*path}", (RequestDelegate)(ctx =>
        {
            var defaultCulture = localizationOptions.DefaultRequestCulture.Culture.Name;
            var path = ctx.GetRouteValue("path") ?? string.Empty;
            var culturedPath = $"/{defaultCulture}/{path}";
            ctx.Response.Redirect(culturedPath);
            return Task.CompletedTask;
        }));
    });

Now when we hit our homepage at localhost/ we are redirected to localhost/en-GB/ - a much nicer experience for the user than the 404 we received previously!

Redirecting culture-less urls to cultured ones

If we consider the route I described earlier, localhost/gibberish/Home/Index/, I will still receive a 404, as it did before. Note however that the user is redirected to a correctly cultured route first:

The gibberish route is redirected

The first time the url is hit it skips the first and second routes, as it does not have a culture, and is redirected to its culture equivalent, localhost/en-GB/gibberish/Home/Index/.

When this url is hit, it matches the first route, but attempts to find a GibberishController which obviously does not match. It therefore matches our second, cultured catch-all route, which returns a 404. The purpose of this second route becomes clear here, in that it prevents an infinite redirect loop, and ensures we return a 404 for urls which genuinely should be returning Not Found.

Summary

In this post I showed how you could extend the global conventions for culture I described in my previous post to handle the case when a user does not provide the culture in the url.

Using a custom routing constraint and two catch-all routes it is possible to have a single 'correct' route which contains a culture, and to re-map culture-less requests onto this route.

For more details on creating and testing custom route constraints, I recommend you check out this post by Scott Hanselman.