How to Localized MVC Routing with Area Without Specifying Culture Short Name in the Url
Posted on: December 21, 2015
In a previous post, I wrote how to enhance the standard Asp.Net MVC Routing to have localized URL with controller and action. This solution had some flaws that this article will cover. First of all, it was not working with area. Second, you were not able to use all functionalities like constraints and default values and third, it was a lot of configuration by having to use a lot of lists.
This article illustrates an easier way to define your routing configuration by adding a Fluent API but also by giving you all standard routing capabilities. This solution will let you configure every route possibles: values, defaults values, constraints, etc and will enhance the standard MVC routing by figuring out which language to display all your resources by setting the culture for you.
Before going any further, here is an example of what the system let you use without Fluent API.
1var controllerRoutes = new List<ControllerSectionLocalized>{2 new ControllerSectionLocalized("Home",3 new List<LocalizedSection>{4 new LocalizedSection(LocalizedSection.EN, "Home")5 ,new LocalizedSection(LocalizedSection.FR, "Demarrer")6 }7 ,new List<ActionSectionLocalized>{8 new ActionSectionLocalized("Index"9 , new List<LocalizedSection>{10 new LocalizedSection(LocalizedSection.EN, "Index")11 , new LocalizedSection(LocalizedSection.FR, "Index")12 }13 , new { id = ""} , null , "{controller}/{action}/{id}" )14 }),15 new ControllerSectionLocalized("Account" ,16 new List<LocalizedSection>{17 new LocalizedSection(LocalizedSection.EN, "Account")18 ,new LocalizedSection(LocalizedSection.FR, "Compte")19 }20 ,new List<ActionSectionLocalized>{21 new ActionSectionLocalized("Profile"22 , new List<LocalizedSection>{23 new LocalizedSection(LocalizedSection.EN, "Profile")24 ,new LocalizedSection(LocalizedSection.FR, "Profile")25 }26 , new {username = UrlParameter.Optional } , null , "{action}/{username}" )27 , new ActionSectionLocalized("DisplayBadges"28 , new List<LocalizedSection>{29 new LocalizedSection(LocalizedSection.EN, "Badges")30 ,new LocalizedSection(LocalizedSection.FR, "Medailles")31 } , null , null , "{action}" )32 , new ActionSectionLocalized("Privilege"33 , new List<LocalizedSection>{34 new LocalizedSection(LocalizedSection.EN, "Privilege-benefits")35 ,new LocalizedSection(LocalizedSection.FR, "benefice-des-privileges")36 } , null , null , "{action}" )37 , new ActionSectionLocalized("PrivilegeBuyConfirm"38 , new List<LocalizedSection>{ new LocalizedSection(LocalizedSection.EN, "Privilege-buy-confirm")39 ,new LocalizedSection(LocalizedSection.FR, "confirmation-achat-privilege") } , null , null , "{action}" )40 , new ActionSectionLocalized("Login"41 , new List<LocalizedSection>{42 new LocalizedSection(LocalizedSection.EN, "Login") ,new LocalizedSection(LocalizedSection.FR, "Identification")43 } , null , null , "{controller}/{action}" ) , new ActionSectionLocalized("ReSendingCreationEmail" , new List<LocalizedSection>{ new LocalizedSection(LocalizedSection.EN, "SendValidationMail")44 ,new LocalizedSection(LocalizedSection.FR, "EnvoieCourrielDeValidation") } , null , null , "{controller}/{action}/{emailAddress}/now"45 /* We need to be able to have more than just action translated but also text token for e.g. NOW*/ )46 , new ActionSectionLocalized("ActivateAccount"47 , new List<LocalizedSection>{ new LocalizedSection(LocalizedSection.EN, "ActivateAccount")48 ,new LocalizedSection(LocalizedSection.FR, "activer-compte") } , null , null , "{controller}/{action}/{emailAddress}/now"49 /* We need to be able to have more than just action translated but also text token for e.g. NOW*/ ) } )50};
And with the Fluent Routing API:
1var controllerRoutes = FluentLocalizedRoute.BuildRoute()2 .ForBilingualController("Home", "Home", "Demarrer")3 .WithBilingualAction("Index", "Index", "Index")4 .WithDefaultValues(new { id = "" })5 .UseDefaulUrl()6 .ForBilingualController("Account", "Account", "Compte")7 .WithBilingualAction("Profile", "Profile", "Profile")8 .WithDefaultValues(new { username = UrlParameter.Optional })9 .WithUrl("{action}/{username}")10 .And()11 .WithBilingualAction("DisplayBadges", "Badges", "Medailles")12 .WithUrl("{action}")13 .And()14 .WithBilingualAction("Privilege", "Privilege-benefits", "benefice-des-privileges")15 .WithUrl("{action}")16 .And()17 .WithBilingualAction("PrivilegeBuyConfirm", "Privilege-buy-confirm", "confirmation-achat-privilege")18 .WithUrl("{action}")19 .And()20 .WithBilingualAction("Login", "Login", "Identification")21 .WithUrl("{controller}/{action}")22 .And()23 .WithBilingualAction("ReSendingCreationEmail", "SendValidationMail", "EnvoieCourrielDeValidation")24 .WithUrl("{controller}/{action}/{emailAddress}/now")25 .And()26 .WithBilingualAction("ActivateAccount", "ActivateAccount", "activer-compte")27 .WithUrl("{controller}/{action}/{emailAddress}/now")28 .ToList() ;
As you can see, it's way more concise. Of course, the method used in the API focus on 2 languages but underneath you can have unlimited localization -- it's just more convenient for a lot of people to have a bilingual application, thus these helper methods. So what does this localized code will give you:
- Url that can be in an unlimited language bound to Mvc code
- Url that change the Culture and CultureUI without having to specify the local like en-us or fr-ca
- A system that handles Area, Controller and Action to be translated
- A system that generate subsequent URL with the default Asp.Net MVC Helper in the right language
- A system compatible with the current Asp.Net routing system, thus both can be run in parallel
- A Fluent API that let you write quickly routing
- Full support of current Asp.Net feature with default values, constraints and even with custom language token that can be localized within the URL
Let's start without having the Fluent API because the Fluent API it's just something you add above the custom multilanguage Asp.Net Mvc routing system we are developing.
The first class that we need is the one that will hold the localized string.
1public class LocalizedSection {2 public static CultureInfo EN = CultureInfo.GetCultureInfo("en-US");3 public static CultureInfo FR = CultureInfo.GetCultureInfo("fr-FR");4 public CultureInfo CultureInfo { get; set; }5 public string TranslatedValue { get; set; }6 public LocalizedSection(CultureInfo culture, string translatedValue) {7 CultureInfo = culture; TranslatedValue = translatedValue;8 }9}
This LocalizedSection class is pretty simple by having a single constructor that take a culture and a string that is localized. Some static properties are there because I am developing an English-French system and wanted to have the culture that I will use defined once. Even if everything explained in this article is about a bilingual system in English and French, the system is already in shape to let you use other languages than these two but also more than two. Once you have that class, you need to defines the structure of how we will keep all localized sections for the system. The structure is the same as Asp.Net MVC routing which is area, controller and action. We will build everything to support Area-Controller-Action and Controller-Action. To do so, we need three classes.
1public class AreaSectionLocalized {2 public string AreaName { get; set; }3 public List<LocalizedSection> Translation { get; set; }4 public List<ControllerSectionLocalized> ControllerTranslations { get; set; }56 public AreaSectionLocalized(string areaName, List<LocalizedSection> translation, List<ControllerSectionLocalized> controllersList) {7 this.AreaName = areaName; this.Translation = translation;8 this.ControllerTranslations = controllersList; }9 }1011 public class ControllerSectionLocalized {12 public string ControllerName { get; set; }13 public List<LocalizedSection> Translation { get; set; }14 public List<ActionSectionLocalized> ActionTranslations { get; set; }1516 public ControllerSectionLocalized(string controllerName, List<LocalizedSection> translation, List<ActionSectionLocalized> actionsList) {17 this.ControllerName = controllerName;18 this.Translation = translation;19 this.ActionTranslations = actionsList;20 }21}2223public class ActionSectionLocalized {24 public string ActionName { get; set; }25 public List<LocalizedSection> Translation { get; set; }2627 public object Values { get; set; }28 public object Constraints { get; set; }29 public string Url { get; set; }30 public ActionSectionLocalized(string actionName, List<LocalizedSection> translation, object values = null, object constraints = null, string url = "") {31 this.ActionName = actionName;32 this.Translation = translation;33 this.Values = values;34 this.Constraints = constraints;35 this.Url = url;36 }37 public Dictionary<string,List<LocalizedSection>> Tokens { get; set; }38}
The AreaSectionLocalized class is the one that contain the real area's name under the property named AreaName
. This one will be used to map when you request from Mvc Html Helper a route with the real name but also when you specify in the url a localized string from the Translation property. This list, Translation , is where you store the collection of localized area name for this area. For example, if you have an area in your code called "Order" than you can have the area to be displayed in the url has "customer-area" and "commande-du-client". Since it's a list, you can have unlimited translation -- each of them are linked to a CultureInfo
. The last important property of that class is the collection of controller. As you can see, we are building a three of routing. At the root will be a collection of area, with AreaSectionLocalized. Each of area will have a list of controller, which will have a list of action. That is why the ControllerSectionLocalized look alike the area class. The action class, ActionSectionLocalized is the class among the three that contains more information. It has also the same principle of having the action name which is the one that the controller class define and use in Asp.Net MVC Url Helper with a list of localized string but also multiple properties. Values, constraints, Url are all information used to build the URL in the standard Asp.Net MVC routing system. Here is a standard route:
1routes.MapRoute(Constants.Areas.CONTEST + "_contest_detail" , Constants.Areas.CONTEST + "/{" + Constants.RoutePortionName.ACTIVE_CURRENT_CONTEST_ID + "}/Detail"2 , new RouteValueDictionary { { Constants.RoutePortionName.ACTION, "Detail" }3 , { Constants.RoutePortionName.CONTROLLER, "UserContest" }}4 , new RouteValueDictionary {{ Constants.RoutePortionName.ACTIVE_CURRENT_CONTEST_ID,@"\\d+"} });
The second line is the URL, the third line is the value and the forth line is the constraint. We also do the same in the ActionSectionLocalized class. One custom new principle is the Tokens list. This is a dictionary of string that we will replace with a localized string. Item of the list represent a token, every value contains a list of localized text to replace in the URL. That mean that you can create an URL that look like this : /{area}/{controller}/{action}/always/{page}/{pagenumber}
where the area, controller and action will be replaced with the according value as the standard Asp.Net MVC routing is normally doing, the same is true about the hard-coded "always" that will remains the same whatever the language and the {pagenumber}
if defined in the URL will be used as a routing variable. The difference is that if we detect that {page}
is not used as a routing value that we will lookup in the token list to see if it could be replaced with a localized value. We will see it in more detail later.
The next one is one of the code class of the system, it's the new route class. This localized route class, named accordingly to its goal LocalizedClass, inherit from the Asp.Net MVC's Route class. This way, it's possible to continue to use the same routing system without having to re-invent the wheel.
1public class LocalizedRoute : Route {2 public CultureInfo Culture { get; private set; }3 public ActionSectionLocalized ActionTranslation { get; private set; }4 public ControllerSectionLocalized ControllerTranslation { get; private set; }5 public AreaSectionLocalized AreaSectionLocalized { get; private set; }67 public LocalizedRoute(AreaSectionLocalized areaSectionLocalized, ControllerSectionLocalized controllerTranslation, ActionSectionLocalized actionTranslation, string url , RouteValueDictionary defaults, RouteValueDictionary constraints, CultureInfo culture)8 : this(areaSectionLocalized, controllerTranslation, actionTranslation, url, defaults, constraints, null, new MvcRouteHandler(), culture) {910 }1112 public LocalizedRoute(AreaSectionLocalized areaSectionLocalized, ControllerSectionLocalized controllerTranslation, ActionSectionLocalized actionTranslation, string url , RouteValueDictionary defaults, RouteValueDictionary constraints, RouteValueDictionary dataTokens, IRouteHandler routeHandler, CultureInfo culture)13 : base(url, defaults, constraints, dataTokens, routeHandler) {14 this.AreaSectionLocalized = areaSectionLocalized;1516 if (controllerTranslation == null) {17 throw new ArgumentNullException("controllerTranslation");18 }19 this.ControllerTranslation = controllerTranslation;20 if (actionTranslation == null) {21 throw new ArgumentNullException("actionTranslation");22 }23 this.ActionTranslation = actionTranslation;2425 if (url == null) { throw new ArgumentNullException("url"); }2627 if (culture == null) { throw new ArgumentNullException("culture"); }2829 this.Culture = culture;3031 if (dataTokens == null) {32 base.DataTokens = new RouteValueDictionary();33 }3435 if (base.Defaults != null && base.Defaults.Keys.Contains(Constants.AREA)) {36 if (base.DataTokens == null) {37 base.DataTokens = new RouteValueDictionary();38 }39 base.DataTokens.Add(Constants.AREA, base.Defaults[Constants.AREA].ToString());40 }41}4243/// <summary>44/// Set the thread culture with the route culture45/// </summary>46/// <param name="httpContext"></param>47/// <returns></returns>48public override RouteData GetRouteData(HttpContextBase httpContext) {49 var returnRouteData = base.GetRouteData(httpContext);50 if (returnRouteData != null) {51 System.Threading.Thread.CurrentThread.CurrentCulture = this.Culture;52 System.Threading.Thread.CurrentThread.CurrentUICulture = this.Culture;53 }54 return returnRouteData;55}5657protected override bool ProcessConstraint(HttpContextBase httpContext, object constraint, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) {58 return base.ProcessConstraint(httpContext, constraint, parameterName, values, routeDirection);59}6061public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values) {62 var currentThreadCulture = System.Threading.Thread.CurrentThread.CurrentUICulture; //First step is to avoid route in the wrong culture63 if (this.Culture.Name != currentThreadCulture.Name) { return null; }6465 //Second, set the right Area/Controller/Action to have MVC generating the URL with the localized string66 bool replaceRoutingValues = true;67 LocalizedSection areaTranslated = null;68 LocalizedSection controllerTranslated = null;69 LocalizedSection actionTranslated = null;70 if (this.AreaSectionLocalized != null && values[Constants.AREA] !=null) //If added in the RouteValue, it will be just there later during GetVirtualPath (merge from MVC's route creation code)71 {72 var valueToken = values[Constants.AREA];73 areaTranslated = this.AreaSectionLocalized.Translation.FirstOrDefault(d => d.CultureInfo.Name == currentThreadCulture.Name);74 replaceRoutingValues = (areaTranslated != null && areaTranslated.TranslatedValue == valueToken);75 }7677 if (replaceRoutingValues && this.ControllerTranslation != null) {78 var valueToken = values[Constants.CONTROLLER];79 controllerTranslated = this.ControllerTranslation.Translation.FirstOrDefault(d => d.CultureInfo.Name == currentThreadCulture.Name);80 replaceRoutingValues &= (controllerTranslated != null && controllerTranslated.TranslatedValue == valueToken);81 }8283 if (replaceRoutingValues && this.ActionTranslation != null) {84 var valueToken = values[Constants.ACTION]; actionTranslated = this.ActionTranslation.Translation.FirstOrDefault(d => d.CultureInfo.Name == currentThreadCulture.Name); replaceRoutingValues &= (actionTranslated != null && actionTranslated.TranslatedValue == valueToken);85 }8687 //We need to find a translation that fit at least Controller and Action88 //if (!replaceRoutingValues)89 //{90 // return null;91 //}9293 //Switch text token to the right language94 if (this.ActionTranslation != null) {95 base.Url = ReplaceTokens(base.Url, this.ActionTranslation.Tokens);96 }9798 // Check with the new values if the system can get an URL with the values in the culture desired99 var vitualPathData = this.GetVirtualPathForLocalizedRoute(requestContext, values); //vitualPathData.DataTokens100101 // Asp.Net MVC found a URL, time to enhance the URL with localization replacement102 if (vitualPathData != null) {103 //This is to replace {action}, {controller} and {area} with the localized version104 vitualPathData.VirtualPath = LocalizedSection.ReplaceSection(this.Url, areaTranslated, controllerTranslated, actionTranslated);105106 //Enhance url with replace or append route value dictionary107 vitualPathData.VirtualPath = AdjustVirtualPathWithRoutes(vitualPathData.VirtualPath, values);108109 //Default value if not defined in the route value110 vitualPathData.VirtualPath = AdjustVirtualPathWithActionTranslationDefaultValues(vitualPathData.VirtualPath, values); vitualPathData.VirtualPath = vitualPathData.VirtualPath.TrimEnd('/');111 }112113 return vitualPathData;114}115116/// <summary>117/// Adjust virtual path with action translation default value not in the route. This is because we can define default and the118/// value of default is only used when not more specific from the route.119///120/// Route has precedence on Default Value (this.ActionTranslation.Values)121/// </summary>122/// <param name="currentVirtualPath"></param>123/// <param name="values"></param>124/// <returns></returns>125public string AdjustVirtualPathWithActionTranslationDefaultValues(string currentVirtualPath, RouteValueDictionary values) {126 if (string.IsNullOrEmpty(currentVirtualPath) || values == null) { return currentVirtualPath; }127 string finalVirtualPath = currentVirtualPath; //This is for the case that optional parameter in the action are not defined in the URL128 if (this.ActionTranslation != null) {129 var rc = new RouteValueDictionary(this.ActionTranslation.Values); // If defined {word} is not in the URL, then we use the value from the actionTranslated130131 foreach (var key in rc.Keys.Where(q => !values.ContainsKey(q))) {132 string toReplace = "{" + key + "}";133 finalVirtualPath = finalVirtualPath.Replace(toReplace, System.Net.WebUtility.UrlEncode(rc[key].ToString()));134 }135 }136 return finalVirtualPath;137}138139/// <summary>140/// Get all routes information that are not Area-Controller-Action and change the value from the URL.141/// If not in the URL, add the data in query string142/// </summary>143/// <param name="currentVirtualPath"></param>144/// <param name="values"></param>145/// <returns></returns>146public string AdjustVirtualPathWithRoutes(string currentVirtualPath, RouteValueDictionary values) {147 string finalVirtualPath = currentVirtualPath;148 if (values != null) {149 foreach (var key in values.Keys.Where(k => k != Constants.AREA && k != Constants.CONTROLLER && k != Constants.ACTION)) {150 string toReplace = "{" + key + "}"; if (values[key] != null) {151 string replaceWith = System.Net.WebUtility.UrlEncode(values[key].ToString());152 if (currentVirtualPath.Contains(toReplace)) {153 finalVirtualPath = finalVirtualPath.Replace(toReplace, replaceWith);154 } else {155 finalVirtualPath = AddKeyValueToUrlAsQueryString(finalVirtualPath, toReplace, replaceWith);156 }157 }158 }159 }160161 return finalVirtualPath;162}163164public string ReplaceTokens(string url, Dictionary<string, List<LocalizedSection>> tokens) {165 if (tokens!= null) {166 foreach (var key in tokens.Keys) {167 var tokenInCurrentCulture = tokens[key].FirstOrDefault(f => f.CultureInfo.Name == this.Culture.Name);168 if (tokenInCurrentCulture != null) {169 string toReplace = "{" + key + "}";170 return url.Replace(toReplace, System.Net.WebUtility.UrlEncode(tokenInCurrentCulture.TranslatedValue));171 }172 }173 }174 return url;175}176177public string AddKeyValueToUrlAsQueryString(string url, string key, string value) {178 if (!string.IsNullOrEmpty(key) && url!=null) {179 key = key.Replace("{", "").Replace("}", "");180 if (url.Contains("?")) {181 return url + "&" + key + "=" + value;182 } else {183 return url + "?" + key + "=" + value;184 }185 }186 return url;187}188189public virtual VirtualPathData GetVirtualPathForLocalizedRoute(RequestContext requestContext, RouteValueDictionary values) {190 return base.GetVirtualPath(requestContext, values);191 }192}
I will not describe everything since I added comment directly inside the code but the gist of the class is to manipulate the URL and routing by overriding GetVirtualPath to be sure to convert the localized route section requested into the original Area-Controller-Action name, to use the default code to get url that would be generated by ASP.Net MVC and then enhance this one by localizing this one with Area-Controller-Action. You can see the GetVirtualPath as the entry point that is called for every route defined when you use any ASP.Net MVC mechanism to get an URL. The framework loops all the route by calling GetVirtualPath and if one return not NULL, it takes this one.
This class also override GetRouteData which is used when the user enter an URL in a browser. That time, since we are still using the default route mechanism and that we store the route with the standard Area-Controller-Action + Constraint that we have almost nothing to do. We only set the current thread Culture from the culture defined by the route.
The last remaining piece to be able to have everything work is to create an helper to add localized route to the ASP.Net MVC's RouteCollection
. I decided to create a static method that take the route and a list of AreaSectionLocalized
or ControllerSectionLocalized
.
1public static class RouteCollectionExtension {23public static void AddRoutes(this RouteCollection routes, List<AreaSectionLocalized> areaRoutes) {4 foreach (var area in areaRoutes) {5 routes.AddRoutes(area.ControllerTranslations, area);6 }7}89public static void AddRoutes(this RouteCollection routes, List<ControllerSectionLocalized> controllerRoutes, AreaSectionLocalized areaSectionLocalized = null) {10 foreach (var controller in controllerRoutes) {11 foreach (var controllerTranslation in controller.Translation) {12 foreach (var action in controller.ActionTranslations) {13 var urlAction = action.Url;1415 foreach (var actionTranslation in action.Translation) {16 if (controllerTranslation.CultureInfo == actionTranslation.CultureInfo) {17 RouteValueDictionary values = null;18 if (action.Values is RouteValueDictionary) {19 values = action.Values as RouteValueDictionary;20 } else {21 values = new RouteValueDictionary(action.Values);22 }2324 LocalizedSection areaTranslation = null;25 if (areaSectionLocalized != null && areaSectionLocalized.Translation.Any(d => d.CultureInfo.Name == controllerTranslation.CultureInfo.Name)) {26 values[Constants.AREA] = areaSectionLocalized.AreaName;27 areaTranslation = areaSectionLocalized.Translation.FirstOrDefault(d => d.CultureInfo.Name == controllerTranslation.CultureInfo.Name);28 }2930 values[Constants.CONTROLLER] = controller.ControllerName;31 values[Constants.ACTION] = action.ActionName;32 RouteValueDictionary constraints = null;3334 if (action.Constraints is RouteValueDictionary) {35 constraints = action.Constraints as RouteValueDictionary;36 } else {37 constraints = new RouteValueDictionary(action.Constraints);38 }3940 var newUrl = LocalizedSection.ReplaceSection(urlAction, areaTranslation, controllerTranslation, actionTranslation);41 routes.Add(new LocalizedRoute( areaSectionLocalized , controller , action , newUrl , values , constraints , actionTranslation.CultureInfo ) );42 }43 }44 }45 }46 }47 }48}
The code is adding a route by looping through all areas, all controllers, all actions and for each language add the route. I will create a second article to describe to Fluent interface that help to have a more concise way to write the routing and also that gives Microsoft Intellisence support. So far, in this article, we have seen how to enhance the existing Asp.Net MVC routing system by having localized route. The code handles the thread culture, thus by changing the URL you have all your pages in the right local too. Finally, we saw that