Localized routes with ASP.NET 5 and MVC 6

Β· 1377 words Β· 7 minutes to read

In my Web API book, in one of the chapters (source here), I’m discussing an in interesting approach towards route localization, using attribute routing.

The whole idea came from the fact that at some point in the past I used to work on a really large application - 70+ language versions, all of which required localizations on the route level.

That approach allowed you to define a single attribute route at action level (as opposed to, well, 70+ routes), and have it auto-translated by the plugged in infrastructure, as long as you provide the mapping to other languages at application startup.

Let’s have a look at how same type of functionality can be built in ASP.NET MVC 6.

Background πŸ”—

That Web API approach to localizing routes relied on the extensibility points around Web API 2 attribute routing - IDirectRouteProvider. In short, you could plug custom logic into the Web API pipeline, and as the framework was creating routes for the first time (at application startup), a single route attribute could be used to create a number of related attribute routes - each for a different language. The only thing needed was to map the route name that’s the defined on the controller level, to the translations of routes defined in some central place

This was very powerful, but unfortunately the extensibility around attribute routing in MVC 6 is completely different - so if you wish to port this type of route localization feature to an MVC 6 application, you will need to rewrite it from scratch and resort to our good friend (if you have been reading this blog recently), IApplicationModelConvention.

Route localization approach πŸ”—

Here’s what we want to achieve in a few simple steps.

  1. We want the ability to define a route on the action level, just like we would do that normally for non localized routes. These routes will represent our default routes for our default culture, say en-Ca.
public class OrdersController : Controller  
{  
[LocalizedRoute("order", Name = "order")]  
public Order GetAll()  
{  
//omitted for brevity  
}

[LocalizedRoute("order/{id:int}", Name = "orderById")]  
public Order GetById(int id)  
{  
//omitted for brevity  
}  
}  

The attribute used here is a subclass of the default RouteAttribute, and introduces an extra property for Culture.

public class LocalizedRouteAttribute : RouteAttribute  
{  
public LocalizedRouteAttribute(string template) : base(template)  
{  
Culture = "en-Ca";  
}

public string Culture { get; set; }  
}  
  1. We gave our routes distinct names, so at application startup, we’d like to provide translations for those routes from somewhere (config files, data store, doesn’t matter - whatever suits your needs) and have extra routes automagically created for us. The matching of the default routes defined in the code, and the translated ones should happen by the route name - that’s why we needed it in the first place.

Here is a snippet of ASP.NET 5 Startup code that configures ASP.NET MVC 6 and that introduces route localization:

public class Startup  
{  
public void ConfigureServices(IServiceCollection services)  
{  
var localizedRoutes = new Dictionary<string, LocalizedRouteInformation[]>  
{  
{  
"order", new[]  
{  
new LocalizedRouteInformation("de-CH", "auftrag"),  
new LocalizedRouteInformation("pl-PL", "zamowienie"),  
}  
},  
{  
"orderById", new[]  
{  
new LocalizedRouteInformation("de-CH", "auftrag/{id:int}"),  
new LocalizedRouteInformation("pl-PL", "zamowienie/{id:int"),  
}  
}  
};

services.AddMvc(o => o.AddLocalizedRoutes(localizedRoutes));  
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)  
{  
app.UseMvc();  
}  
}  

So based on the route name, such as order or orderById, we are defining localized versions for Swiss German and Polish localizations. Again, this data could have been read from an external data source, but it’s hardcoded here for simplicity. Afterwards, the route collection is passed into the magical extension method AddLocalizedRoutes which we’ll dive into in a moment. Same applies for another class used in the snippet above, LocalizedRouteInformation.

Overall looks simple and elegant, doesn’t it?

Route localization under the hood πŸ”—

So let’s look deeper, perhaps first at LocalizedRouteInformation. It’s a simple POCO which we’ll use to contain information about local versions of routes:

public class LocalizedRouteInformation  
{  
public string Culture { get; }  
public string Template { get; }

public LocalizedRouteInformation(string culture, string template)  
{  
Culture = culture;  
Template = template;  
}  
}  

The core part of the work will happen, as mentioned earlier, inside a custom IApplicationModelConvention - and we are going to call it LocalizedRouteConvention.

Let me show you the implementation first, and then we can discuss step by step what’s happening inside it.

public class LocalizedRouteConvention : IApplicationModelConvention  
{  
private readonly Dictionary<string, LocalizedRouteInformation[]> _localizedRoutes;

public LocalizedRouteConvention(Dictionary<string, LocalizedRouteInformation[]> localizedRoutes)  
{  
_localizedRoutes = localizedRoutes;  
}

public IEnumerable<AttributeRouteModel> GetLocalizedVersionsForARoute(string name)  
{  
if (string.IsNullOrWhiteSpace(name)) yield break;

LocalizedRouteInformation[] routeSpecificLocalizedRoutes;  
if (_localizedRoutes.TryGetValue(name, out routeSpecificLocalizedRoutes))  
{  
foreach (var entry in routeSpecificLocalizedRoutes)  
{  
yield return  
new AttributeRouteModel(new LocalizedRouteAttribute(entry.Template)  
{  
Name = name + entry.Culture,  
Culture = entry.Culture  
});  
}  
}  
}

public void Apply(ApplicationModel application)  
{  
foreach (var controller in application.Controllers)  
{  
var newActions = new List<ActionModel>();  
foreach (var action in controller.Actions)  
{  
var localizedRouteAttributes = action.Attributes.OfType<LocalizedRouteAttribute>().ToArray();  
if (localizedRouteAttributes.Any())  
{  
foreach (var localizedRouteAttribute in localizedRouteAttributes)  
{  
var localizedVersions = GetLocalizedVersionsForARoute(localizedRouteAttribute.Name);  
foreach (var localizedVersion in localizedVersions)  
{  
var newAction = new ActionModel(action)  
{  
AttributeRouteModel = localizedVersion,  
};  
newAction.Properties["culture"] = new CultureInfo(((LocalizedRouteAttribute) localizedVersion.Attribute).Culture ?? "en-Ca");  
newAction.Filters.Add(new LocalizedRouteFilter());  
newActions.Add(newAction);  
}  
}  
}  
}

foreach (var newAction in newActions)  
{  
controller.Actions.Add(newAction);  
}  
}  
}  
}  

So the constructor takes a dictionary - where the key will represent a route name, and the value will be an array of LocalizedRouteInformation instances. We have already seen this structure in play in the previous code snippet so it should not be surprising - after all a single route name can have a bunch of different localized versions attached to it.

We discusssed IApplicationModelConvention a few times on this blog before, but in short, it allows you to modify, process and adapt the controllers, actions and even parameters on them, that the framework discovers at application startup. MVC 6 will call the Apply method of your custom convention class, and that’s where you need to do whatever you are trying to achieve with your custom convention.

In our case we loop through all controllers and all of the actions found on the controllers and try to find if any of them have been decorated with LocalizedRouteAttribute. If that’s the case, it means we should intervene and create more routes (all of the localized versions).

We use the Name property defined on LocalizedRouteAttribute to look up LocalizedRouteInformation instances in the dictionary that was used to initialze the convention object. If something is there, for each of the found localizations, we create instances of AttributeRouteModel using the data stored in LocalizedRouteInformation - which will be a new version of an attribute route.

Afterwards we create a new ActionModel representing each new localized route and attach it to the controller. This is something worth remembering - even if you want to have multiple routes pointing to same action in MVC 6, in reality in the semantic model of the app that MVC 6 holds, each of the routes will end up being a separate ActionModel, as if it was a separate action method altogether.

You may have also noticed that we saved the culture into ActionModel Properties and added a LocalizedRouteFilter to each action. This is not mandatory but is an interesting twist that we can throw in. The filter is shown below:

public class LocalizedRouteFilter : IResourceFilter  
{  
public void OnResourceExecuting(ResourceExecutingContext context)  
{  
if (context.ActionDescriptor.Properties.ContainsKey("culture"))  
{  
var culture = context.ActionDescriptor.Properties["culture"] as CultureInfo;  
if (culture != null)  
{  
#if DNX451  
Thread.CurrentThread.CurrentCulture = culture;  
Thread.CurrentThread.CurrentUICulture = culture;  
#else  
CultureInfo.CurrentCulture = culture;  
CultureInfo.CurrentUICulture = culture;  
#endif  
}  
}  
}

public void OnResourceExecuted(ResourceExecutedContext context)  
{  
}  
}  

The filter will pick up the culture from the Properties dictionary and set in on the thread. This way your specific localized route will have correct culture attached to the currently executing thread by the time it reaches the controller, which you can use to do all kinds of interesting things.

Finally, we initially used an extension method to bootstrap all of this so this is the last piece of code we have to write. The extension method is shown below:

public static class RouteLocalizationExtensions  
{  
public static void AddLocalizedRoutes(this MvcOptions options, Dictionary<string, LocalizedRouteInformation[]> localizedRoutes)  
{  
options.Conventions.Insert(0, new LocalizedRouteConvention(localizedRoutes));  
}  
}  

Really all it does is just insert the LocalizedRouteConvention into the MvcOptions object.

And that’s it - all done, and it works for both desktop CLR and CoreCLR!

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP