In my last post I showed how your could use the middleware as filters feature of ASP.NET Core 1.1.0 along with the RouteDataRequestCultureProvider to set the culture of your application from the url. This allowed you to distinguish between different cultures from a url segment, for example www.microsoft.com/en-GB/ and www.microsoft.com/fr-FR/.

The main downside to that approach was that it required inserting and additional {culture} route segment into all your routes, so that the RouteDataRequestCultureProvider could extract the route, and adding a MiddlewareFilter to every applicable controller. I only showed an example for when you are using Attribute routing, but it would also be necessary to add {culture} to all your convention-based routes too (if you're using them).

In this post, I'll show the various ways you can configure your routes globally, so that all your urls will have a culture prefix by default.

This is the one of a series of posts on using middleware as filters:

Adding a global MiddlewareFilter

I'm going to be continuing where I left off in the last post, with a ValuesController I am using for displaying the current culture:

[Route("{culture}/[controller]")]
[MiddlewareFilter(typeof(LocalizationPipeline))]
public class ValuesController : Controller  
{
    [Route("ShowMeTheCulture")]
    public string GetCulture()
    {
        return $"CurrentCulture:{CultureInfo.CurrentCulture.Name}, CurrentUICulture:{CultureInfo.CurrentUICulture.Name}";
    }
}

Hitting the url /fr-FR/Values/ShowMeTheCulture for example would show that the current culture was set to fr-FR, which was our goal. The downside to using this approach more generally is that we would need to add the MiddlewareFilter to all our controllers, and add the {culture} url segment. Ideally, we want to just be able to define our routes and controller the same as we were, before we were thinking about localisation.

The first of these problems is easily fixed by adding the MiddlewareFilter as a Global filter to MVC. You can do this by updating the call to AddMvc in ConfigureServices of your Startup class:

public void ConfigureServices(IServiceCollection services)  
{
    services.AddMvc(opts =>
    {
        opts.Filters.Add(new MiddlewareFilterAttribute(typeof(LocalizationPipeline)));
    });

    // other service configuration
}

By adding the filter here, we can remove the MiddlewareFilter attribute from our ValuesController; it will be automatically applied to all our action methods. That's the first step done!

Using a convention to globally add a culture prefix to attribute routes

Now we've dealt with that, we can take a look at our RouteAttribute based routes. We want to avoid having to explicitly add the {culture} segment to every route we define.

Luckily, in ASP.NET Core MVC, you can register custom conventions on application startup which specify additional conventions that can be applied to the url. For example, you could ensure all your url paths are prefixed with /api, or you could specify the current environment (live/test) in the url, or rename your action methods completely.

In this case, we are going to prefix all our attribute routes with {culture} so we don't have to do it manually. I'm not going to go extensively into how the convention works, so I strongly suggest checking out the above links for more details!

First we create our convention by implementing IApplicationModelConvention:

public class LocalizationConvention : IApplicationModelConvention  
{
    public void Apply(ApplicationModel application)
    {
        var culturePrefix = new AttributeRouteModel(new RouteAttribute("{culture}"));

        foreach (var controller in application.Controllers)
        {
            var matchedSelectors = controller.Selectors.Where(x => x.AttributeRouteModel != null).ToList();
            if (matchedSelectors.Any())
            {
                foreach (var selectorModel in matchedSelectors)
                {
                    selectorModel.AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(culturePrefix,
                        selectorModel.AttributeRouteModel);
                }
            }

            var unmatchedSelectors = controller.Selectors.Where(x => x.AttributeRouteModel == null).ToList();
            if (unmatchedSelectors.Any())
            {
                foreach (var selectorModel in unmatchedSelectors)
                {
                    selectorModel.AttributeRouteModel = culturePrefix;
                }
            }
        }
    }
}

This convention is pretty much identical to the one presented by Filip from StrathWeb. It works by looping through all the Controllers in the application, and checking if the controller has an AttributeRoute attribute. If it does, then it combines the route template with the {culture} prefix, otherwise it adds a new one.

After this convention has run, every controller should effectively have a RouteAttribute that is prefixed with {culture}. The next thing to do is to let MVC know about our new convention. We can do this by adding it in the call to AddMvc:

public void ConfigureServices(IServiceCollection services)  
{
    // Add framework services.
    services.AddMvc(opts =>
    {
        opts.Conventions.Insert(0, new LocalizationConvention());
        opts.Filters.Add(new MiddlewareFilterAttribute(typeof(LocalizationPipeline)));
    });
}

With that in place, we can update our ValuesController to remove the {culture} prefix from the RouteAttribute, and can delete the MiddlewareFilterAttribute entirely:

[Route("[controller]")]
public class ValuesController : Controller  
{
    //overall route /{culture}/Values/ShowMeTheCulture
    [Route("ShowMeTheCulture")]
    public string GetCulture()
    {
        return $"CurrentCulture:{CultureInfo.CurrentCulture.Name}, CurrentUICulture:{CultureInfo.CurrentUICulture.Name}";
    }
}

And we're done! We don't need to reference the {culture} directly in our route attributes, but our urls will still require it:

Changing the culture via the url

Caveats

There's a couple of points to be aware of with this method. First off, it's important to understand we have replaced the previous route; so the previous route of /Values/ShowMeThCulture is no longer accessible - you must provide the culture, just as if you had added the {culture} segment to the RouteAttribute directly:

404 when you don't provide the url

The other point to be aware of, is that we specified the {culture} prefix on the controller RouteAttribute in the convention. That means using an action RouteAttribute that specifies a path relative to root (which ignores the controller RouteAttribute) will not contain the {culture} prefix.

For example using [Route("~/ShowMeTheCulture")] on an action, will correspond to the url /ShowMeTheCulture - not /{culture}/ShowMeTheCulture. This may or may not be desirable for your use case, but it's likely you want these routes to be localised too, so it's worth keeping an eye out for. There's probably a different way of writing the convention to handle this, but I haven't dug into it too far yet, so please let me know below if you know a way!

Updating the default route handler

We have covered adding a convention for attribute routing, but what if you're using global route handling conventions? In the default templates, ASP.NET Core MVC is configured with the following route:

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

This allows you to create controllers without using a RouteAttribute. Instead, the controller and action will be inferred. This lets you create Controllers like this:

    public class HomeController : Controller
    {
        public string Index()
        {
            return $"CurrentCulture:{CultureInfo.CurrentCulture.Name}, CurrentUICulture:{CultureInfo.CurrentUICulture.Name}";
        }
    }

The default values in our routing convention mean that this action method will be hit for the urls /Home/Index/, /Home/ and just /.

If we update the default convention with a {culture} segment, then we can continue to have this behaviour, but with the culture prefixed to the url, so that they map to
/en-GB/Home/Index/ or /fr-FR/ for example. It is as simple as updating the template in UseMvc:

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

Now when we browse our website, we will get the desired result:

Using a convention based url to set the culture

Caveats

The biggest caveat here is that the IApplicationModelConvention we added previously will break this global route by adding a RouteAttribute to controllers that do not have one. Generally speaking, if you're using an IApplicationModelConvention I'd recommend using either the global routes or RouteAttributes rather than trying to combine both. Again, there's probably a way to write the convention to work with both attribute and global routes but I haven't dug too deep yet.

Also, as before, you won't be able to access the controller at /Home/Index anymore - you always have to specify the culture in the url.

Other considerations

With both of these routes, one major issue is that you always need to specify the culture in the url. This may be fine for an API, but for a website this could give a poor user experience - hitting the base url / would return a 404 with this setup!

It's important to setup additional routes to handle this, most likely redirecting to a route containing the default culture. For example, if you hit the url www.microsoft.com/ you will be redirected to www.microsoft.com/en-GB/ or something similar. This may require adding additional conventions or global routes depending on your setup. I will cover some approaches for doing this in a couple of upcoming posts.

Summary

This post led on from my previous post in which I showed how you could use the middleware as filters feature of ASP.NET Core to set the culture for a request using a URL segment. This post showed how to extend that setup to avoid having to add explicit {culture} segments to all of your controllers by adding a global convention (for route attributes) or by amending your global route configuration.

There are still a number of limitations to be aware of in this setup as I highlighted, but it brings you closer to a complete url localisation solution!