Localized routing ASP.NET MVC Core 1.0

This is one implementation of having localized routing in ASP.NET Core MVC 1.0 (asp.net 5 mvc 6 RC 1)
Source code: github.com/saaratrix/asp.net-core-mvc-localized-routing

NOTE: A reader notified me of some breaking changes in newer ASP.NET Core versions so there is an updated blog post here for version: ASP.NET Core 2.1 MVC


Abstract

A working localized routing example for ASP.NET Core MVC 1.0 release candidate 1. The solution uses attributes on the controllers & actions to determine the localized routing for each different culture. A convention is added to the MvcOptions in startup.cs to iterate over all the controllers & actions to set their routes based on the localized attributes.

Introduction and features

I was looking at the new asp.net 5 and I wanted to play around with multiple languages as routing. NOTE: This was done on RC 1 so might be changes later on. I had prior to this never done localized routing so I'm bound to have overlooked or done something that can be improved on.

The features that I wanted in my localized routing are:
  • Multiple Cultures (Languages)
  • Use database for the different translations in a view. (This part is not implemented)
  • The url starts with the Culture, eg: /en/home/index
  • Actions and controllers should be localized
    • Like home == koti for English and finnish.
  • Default culture does not use the /culture/ prefix
    • If en is default culture then /home/index is the url and not /en/home/index
  • Default controller and default action are empty strings
    • So if home controller and index action are defaults the url would be / and for finnish /fi/


Similar Projects

With these goals in mind I started looking around the web for solutions to implement. The solution closest to what I needed I found here: www.strathweb.com/2015/11/localized-routes-with-asp-net-5-and-mvc-6/ . I did not like this for 2 reasons.
1. His way of initializing the routes. Was not how I wanted to do it.
  
  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"),
      }
    }
  };
  
  
The way I came up with looks like this:
  
   [LocalizedRoute("fi", "meistä")]
   [LocalizedRoute("sv", "om")]
   public IActionResult About()
   {
    return View();
   }
   
  

2. From what I could see it did not have the cultural part at the start of the url. The culture in that example is stored in a property of the action.

I also looked at other examples they are for mvc 5 or earlier but also had things I either didn't understand due to lack of knowledge or things that it was missing. So in the end I decided to write my own solution and thus here is how I solved localized routing!

Implementation

Here is my implementation of the localized routing. I am using 3 cultures, en, fi, sv where en is my default culture.

Startup.cs

The startupc.cs file that comes with any asp.net 5 setup. The method bodies of ConfigureServices and Configure has more code that is added by asp.net as default. I'm just showing the code I wrote for the localization.

Localized Routes

The first part in startup.cs is to tell the localization data handler what the default culture is and what the supported cultures are. Then as normal code would do is add the mvc framework to the service but here in addition we add the LocalizedRouteConvention that will iterate over all the controller and actions setting up their routing and localized routes. It will also add the routes to a dictionary used by the CultureTagHelper to get the localized href and linktext when generating the html <a> links.
  
   public void ConfigureServices(IServiceCollection services)
   {
    LocalizationDataHandler.DefaultCulture = "en";
    LocalizationDataHandler.SupportedCultures = new System.Collections.Generic.List<string>()
    {
     "en",
     "fi",
     "sv"
    };
    services.AddMvc(o =>
    {                
     o.Conventions.Insert(0, new LocalizedRouteConvention());                           
    });
    services.AddLocalization();      
   }
  
  

Request Localization

The second part in startup.cs is to use asp.net's Localization middleware which is what translates the start of the url for each request to a Culture.
  
  public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
  {
   RequestCulture defaultCulture = new RequestCulture( LocalizationDataHandler.DefaultCulture );
   RequestLocalizationOptions localizationOptions = new RequestLocalizationOptions();
    
   localizationOptions.SupportedCultures = new System.Collections.Generic.List<cultureinfo>();
   foreach (string culture in LocalizationDataHandler.SupportedCultures)
   {
     localizationOptions.SupportedCultures.Add(new CultureInfo(culture));
   }            
   localizationOptions.RequestCultureProviders = new System.Collections.Generic.List<IRequestCultureProvider>()
   {
     new UrlCultureProvider(  localizationOptions.SupportedCultures )
   };  
         
   app.UseRequestLocalization(localizationOptions, defaultCulture);
  }
  
  

UrlCultureProvider.cs

This is the class added in the Request Localization section above. Asp.net on every request uses RequestProviders to set the culture. The default ones that asp.net have are header, cookie and querystring provider. You can read more about it on this website. I looked at the source code of asp.net localization to write my UrlCultureProvider. My implementetation ONLY supports 2 letters to figure out the culture. So it does not for example support en-US but only en. So this UrlCultureProvider supports en but not en-US.
  
   /// <summary>
   /// Determines the culture information for a request via the value of the start of a url.
   /// </summary>
   public class UrlCultureProvider : RequestCultureProvider
   {       
    /// <summary>
    /// The default culture if none is found
    /// </summary>
    public string DefaultCulture { get; set; } = LocalizationDataHandler.DefaultCulture;
    /// <summary>
    /// The supported cultures from url
    /// </summary>
    public IList<CultureInfo> SupportedCultures {get; set;}
    public UrlCultureProvider(IList<CultureInfo> a_supportedCultures)
    {
     SupportedCultures = a_supportedCultures;
    }              
    public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
    {
     if (httpContext == null)
     {
      throw new ArgumentNullException(nameof(httpContext));
     }
     string url = httpContext.Request.Path.Value;
     int pathLength = url.Length;
     // example: /fi
     if (pathLength >= 3)
     {   
      if (url.Length >= 4)
      {
       // If the 4th character isn't a /   for example
       // /fi/...   then return default culture
       if (url[3] != '/' )
       {
        return Task.FromResult(new ProviderCultureResult(DefaultCulture));
       }
      }
      // Remove the /
      string startPath = url.Substring(1, 2);
      foreach (CultureInfo culture in SupportedCultures)
      {
       if (culture.Name == startPath)
       {
        return Task.FromResult(new ProviderCultureResult(culture.Name));
       }
      }                
     }
     return Task.FromResult(new ProviderCultureResult(DefaultCulture));
    }
   }
  
  

LocalizedRouteAttribute.cs

The attribute is attached to each controller or their action to set the cultural route. It does not inherit from RouteAttribute. It's used by the LocalizedRouteConvention to create the routes for the controllers & actions.
  
   [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple =true)]   
   public class LocalizedRouteAttribute : Attribute
   {
    /// <summary>
    /// The character to replace whitespace with in the input route, like 
    /// "batman rocks" => "batman_rocks" as route
    /// </summary>
    private const char WhiteSpaceReplacement = '_';
    public LocalizedRouteAttribute()
    {                        
    }
    /// <summary>
    /// Attribute used by LocalizationConvention to create all the routes.
    /// </summary>
    /// <param name="a_culture" />
    /// <param name="a_route" />
    /// <param name="a_link" />If not defined the value is route with first letter capitalized
    public LocalizedRouteAttribute(string a_culture, string a_route, string a_link = null)
    {
     Culture = a_culture;
     Route = a_route;
     // Replace all the spaces with the whitespace replacement character
     Route = Route.Replace(' ', WhiteSpaceReplacement);
    
     // If the link is null then set it to the route
     if (a_link == null)
     {
  #if DNX451
      CultureInfo cultureInfo = new CultureInfo(Culture, false);
      TextInfo textInfo = cultureInfo.TextInfo;                
      // Do the opposite that route does,  replace the whitespace replacement characters with whitespace!
      Link = textInfo.ToTitleCase(Route.Replace( WhiteSpaceReplacement, ' '));
  #else
      CultureInfo cultureInfo = new CultureInfo(Culture);
      TextInfo textInfo = cultureInfo.TextInfo;
      Link = textInfo.ToUpper(Route[0]).ToString();
      if (Route.Length > 1)
      {
       Link += Route.Substring(1);
      }
  #endif
     }
     else
     {
      Link = a_link;                
     }
       
    }
    /// <summary>
    /// The culture string representation, en, en-Us e.t.c.!
    /// </summary>
    public string Culture { get; set; }
    /// <summary>
    /// The route, no need for / 
    /// </summary>
    public string Route { get; set; }
    /// <summary>
    /// What the linktext is for a Html.ActionLink
    /// </summary>
    public string Link { get; set; }
   }
  
  

LocalizationController.cs

The localization controller is a base controller that sets ViewData["Culture"] = CurrentCulture for every action so you don't have to do that for every single action. This is also where I in the future would put the code to fetch translated strings for the view from example the database or a resource file. The code for the localization controller:
          
  public class LocalizationController : Controller
  {
     public override void OnActionExecuting(ActionExecutingContext context)
     {            
    base.OnActionExecuting(context);
    string culture = CultureInfo.CurrentCulture.Name;
    ViewData["culture"] = culture;       
    // Could do database stuff or resource file loading here       
     }
  }
   
  

CultureActionLinkTagHelper.cs

The <a> tag helper class. Currently the tag helper overwrites any link text even for default culture. One improvement could be to check if it has text and if it does don't overwrite it. Tag helper is the new cool thing from asp.net and what this particular helper does is that it listens to the attribute asp-culture to fetch the route for the input culture so for example:
  
   <a asp-action="index" asp-controller="home" asp-culture="fi"></a>   
  
  
The snippet above will output the finnish url + link text for the home controller index action. The whole code for the taghelper:
 
  
   [HtmlTargetElement("a", Attributes = CultureAttributeName)]
   public class CultureActionLinkTagHelper : TagHelper
   {
    private const string CultureAttributeName = "asp-culture";
    /// <summary>
    /// The culture attribute
    /// </summary>        
    [HtmlAttributeName(CultureAttributeName)]
    public string Culture { get; set; }        
    public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {   
     if (string.IsNullOrEmpty( Culture ))
     {
      Culture = LocalizationDataHandler.DefaultCulture;
     }
         // Get the controllerName and actionName
     string controllerName = context.AllAttributes["asp-controller"].Value as string;
     string actionName = context.AllAttributes["asp-action"].Value as string;
     LocalizedUrlResult result = LocalizationDataHandler.GetUrl(controllerName, actionName, Culture);
     output.Attributes["href"] = result.Url;
     output.Content.SetContent(result.UrlName);                
    
     return Task.FromResult(0);
    }
   }
     
  

LocalizedRouteConvention.cs

This is the heart in setting up all the routing. It iterates over all controllers and all the actions in each controller. It checks if the controller or action has the LocalizedRouteAttribute. If the controller or action has one the convention adds the localized route. The convention also checks if the action already has a RouteAttribute for potential parameters. If the action's RouteAttribute contains parameters it adds them to each localized route aswell.
  
    // Check if the route has parameters
    string[] actionComponents = action.AttributeRouteModel.Template.Split('/');
    for (int i = 0; i < actionComponents.Length; i++)
    {
     // Check if first character starts with {
     if (actionComponents[i][0] == '{')
     {
      parameterTemplate += "/" + actionComponents[i];
     }                        
    }
  
  

Adding to localization data handler

For all the controllers & actions that the convention finds it will add that data to the datahandler. To later by the CultureActionLinkTagHelper generate anchor tags like this based on culture:
  <a href="route">linkText</a>
    
The added data in the datahandler is stored in a ConcurrentDictionary so it's easy to query based on controller name or action name. The reason for choosing a ConcurrentDictionary is because it's a static variable shared between all asp.net request threads as I do not know how to share it otherwise. I could not find a way to share it inside a Tag Helper.

Code for LocalizedRouteConvention.cs

Here is the entire code for the LocalizedRouteConvention.cs file:
  
   public class LocalizedRouteConvention : IApplicationModelConvention
   {
    public string DefaultCulture { get; set; }        
    public LocalizedRouteConvention()
    {
     DefaultCulture = LocalizationDataHandler.DefaultCulture;
    }
    // This is the implemented interface function of IApplicationModelConvention
    public void Apply(ApplicationModel application)
    {
     foreach (ControllerModel controller in application.Controllers)
     {    
      // If the controllerName is the same as the base controller for localization go next since it's irrelevant!
      if (controller.ControllerName == "Localization")
      {
       continue;
      }
      // Do the controller            
      AddControllerRoutes(controller);
      // Do the actions!
      AddActionRoutes(controller);                       
     }
    }
    /// <summary>
    /// Adds the prefix local routs to each controller.
    /// Example: Culture = fi, Route = "moi"
    /// Create Route prefix: fi/moi   
    /// </summary>
    /// <param name="a_controller"></param>
    public void AddControllerRoutes(ControllerModel a_controller)
    {
     // Get all the LocalizedRouteAttributes from the controller
     var controllerLocalizations = a_controller.Attributes.OfType<LocalizedRouteAttribute>().ToList();
     // The controllerName (writing a_controler. everytime is hard yo!)
     string controllerName = a_controller.ControllerName;
     // If the controller is the default controller then add the "/" route by adding an empty ""
     if (controllerName == LocalizationDataHandler.DefaultController)
     {
      AttributeRouteModel defaultRoute = new AttributeRouteModel();
      defaultRoute.Template = "";
      a_controller.AttributeRoutes.Add(defaultRoute);
      // If it's the default controller then
      LocalizationDataHandler.AddControllerData(controllerName, DefaultCulture, "");
     }
     else
     {
      // Else add the controller name!
      LocalizationDataHandler.AddControllerData(controllerName, DefaultCulture, controllerName);
     }
     // Create the route for the controller,  since default controller should also be reachable by /default this is not done in the else statement
     // Which is not needed for the localized routing since linking to / is fine!
     AttributeRouteModel controllerRoute = new AttributeRouteModel();
     controllerRoute.Template = a_controller.ControllerName;
     a_controller.AttributeRoutes.Add(controllerRoute);
     // So that any culture that doesn't have the controller added as a route will automatically get the default culture route,
     // Example if [LocalizedRoute("sv", ""] is not on the defaultcontroller it will be added so its found!
     Dictionary<string, string> foundCultures = LocalizationDataHandler.SupportedCultures.ToDictionary(x => x, x => x);
     foundCultures.Remove(LocalizationDataHandler.DefaultCulture);
    
     // Loop over all localized attributes
     foreach (LocalizedRouteAttribute attribute in controllerLocalizations)
     {
      string template = attribute.Culture + "/" + attribute.Route;
      AttributeRouteModel localRoute = new AttributeRouteModel();
      localRoute.Template = template;
      a_controller.AttributeRoutes.Add(localRoute);
      // Add the route to the localizations dictionary
      LocalizationDataHandler.AddControllerData(controllerName, attribute.Culture, template);
      // Remove it from the dictionary having forgotten culture routes!
      // So eg:  /fi/koti   doesn't happen twice
      foundCultures.Remove(attribute.Culture);
     }
    
     // Add a route for the controllers that didn't have localization route attributes with their default name
     foreach (KeyValuePair<string, string> culture in foundCultures)
     {
      string tempName = controllerName;
      if (controllerName == LocalizationDataHandler.DefaultController)
      {
       tempName = "";
        
      }
      string template = culture.Value + "/" + tempName;
      AttributeRouteModel localRoute = new AttributeRouteModel();
      localRoute.Template = template;
      a_controller.AttributeRoutes.Add(localRoute);
      LocalizationDataHandler.AddControllerData(controllerName, culture.Value, template);
     }
    }  
   
    /// <summary>
    /// Adds the localized routes for a controller
    /// </summary>
    /// <param name="a_controller"></param>
    public void AddActionRoutes(ControllerModel a_controller)
    {
     // The controllerName (writing a_controler. everytime is hard yo!)
     string controllerName = a_controller.ControllerName;
     // All the new localized actions
     List<ActionModel> newActions = new List<ActionModel>();
     // Loop through all the actions to add their routes and also get the localized actions
     foreach (ActionModel action in a_controller.Actions)
     {
      string actionName = action.ActionName;
      // If any parameters are needed such as /{index}
      string parameterTemplate = "";                
      // If there is no [Route()] Attribute then create one for the route.
      if (action.AttributeRouteModel == null)
      {                    
       action.AttributeRouteModel = new AttributeRouteModel();
        
       if (action.ActionName != LocalizationDataHandler.DefaultAction)
       {
        action.AttributeRouteModel.Template = actionName;
        // Add the action name as it is eg: about will be about!
        LocalizationDataHandler.AddActionData(controllerName, actionName, DefaultCulture, actionName, actionName);
       }
       else
       {
        action.AttributeRouteModel.Template = "";
        // If action name is the default name then just add route as ""
        // Final result for default controller & action will be "" + ""  => /
        LocalizationDataHandler.AddActionData(controllerName, actionName, DefaultCulture, "", controllerName);                        
       }
      }
      // If a route already existed then check for parameter arguments to add to the cultural routes
      else
      {                    
       // Check if the route has parameters
       string[] actionComponents = action.AttributeRouteModel.Template.Split('/');
       for (int i = 0; i < actionComponents.Length; i++)
       {
        // Check if first character starts with {
        if (actionComponents[i][0] == '{')
        {
         parameterTemplate += "/" + actionComponents[i];
        }                        
       }                  
      }
      var actionLocalizationsAttributes = action.Attributes.OfType<LocalizedRouteAttribute>().ToList();
      foreach (LocalizedRouteAttribute attribute in actionLocalizationsAttributes)
      {
       string route = attribute.Route += parameterTemplate;
       ActionModel newLocalizedActions = new ActionModel(action);                    
       newLocalizedActions.AttributeRouteModel = new AttributeRouteModel();
       newLocalizedActions.AttributeRouteModel.Template = attribute.Route;
       newActions.Add(newLocalizedActions);
       // Add the localized route for the action
       // Example of final route:  "fi/koti" + "/" + "ota_yhteyttä"
       LocalizationDataHandler.AddActionData(controllerName, actionName, attribute.Culture, attribute.Route, attribute.Link);
      }
     }
     // Now add all the actions to the controller
     foreach (ActionModel action in newActions)
     {
      a_controller.Actions.Add(action);
     }
    }      
   }
  
  

LocalizationDataHandler.cs

This is the class where everything is stored so the CultureActionLinkTagHelper can find out what url and link text to generate. If you look at the tag helper it will ask the datahandler for a url through GetUrl(). GetUrl itself will return a LocalizedUrlResult struct. that the tag helper will use.
  
    public struct LocalizedUrlResult
   {
    /// <summary>
    /// The actual url => /home/about
    /// </summary>
    public string Url;
    /// <summary>
    /// The inner html for the anchor tag.
    /// </summary>
    public string LinkName;
   
   }
  
  
The datahandler will query the dictionary if the controller & action exists. The input should be what the controller & actions are called in code. So for the HomeController and action About it would be:
home - about
It automatically makes the input lowercase. So HomE is okay aswell. If the datahandler finds the controller and action it will first check if the culture is the default culture.
If culture is default the method will do another if to check if the controller and the action are the defaults. If controller or action is default it will be an empty string instead of the original name. This is so that the url points towards eg: localhost:80/ instead of localhost:80/Home/Index.
If the culture isn't default culture the function will query the dictionary for its localization based on the culture input. If the dictionary doesn't contain the culture it will return a result for the default culture. After calculating what the url and link text is it will return the LocalizedUrlResult

Code for LocalizationDataHandler.cs

  
   /// <summary>
   /// The class that has all the localization data like routes, supported cultures, default culture.
   /// Set this either in the class itself or Startup.cs
   /// </summary>
   public static class LocalizationDataHandler
   {
    /// <summary>
    /// The default culture
    /// </summary>
    public static string DefaultCulture { get; set; }
    /// <summary>
    /// The list of all supported cultures
    /// </summary>
    public static List<string> SupportedCultures { get; set; }
    public static string DefaultController { get; set; } = "Home";
    public static string DefaultAction { get; set; } = "Index";
    /// <summary>
    /// All the routes and their cultural representation, example:
    /// home => names { home, koti },  actions { index, about }
    ///     action about => names { about, meistä }
    /// </summary>
    // Will never get modified after initialization is done.
    private static ConcurrentDictionary<string, CultureControllerData> ControllerRoutes { get; } = new ConcurrentDictionary<string, CultureControllerData>();
    public static void AddControllerData(string a_controller, string a_culture, string a_route)
    {
     string controllerKey = a_controller.ToLower();
    
     // If the controller doesn't exist, create it!            
     if (!ControllerRoutes.ContainsKey(controllerKey))
     {                
      ControllerRoutes.TryAdd(controllerKey, new CultureControllerData());
     }            
     ControllerRoutes[controllerKey].Names.TryAdd(a_culture, a_route);
    }
    /// <summary>
    /// Add the action data.  Will throw exception if the controller doesn't exist
    /// </summary>
    /// <param name="a_controller"></param>
    /// <param name="a_action"></param>
    /// <param name="a_culture"></param>
    /// <param name="a_route"></param>
    /// <param name="a_linkName"></param>
    public static void AddActionData(string a_controller, string a_action, string a_culture, string a_route, string a_linkName)
    {            
     string actionKey = a_action.ToLower();           
     CultureControllerData controllerData = ControllerRoutes[a_controller.ToLower()];
     if (!controllerData.Actions.ContainsKey(actionKey))
     {
      controllerData.Actions.TryAdd(actionKey, new CultureActionData());
     }
     controllerData.Actions[actionKey].UrlData.TryAdd(a_culture, new CultureUrlData(a_route, a_linkName));
    }        
   
    /// <summary>
    /// Get the url for a controller & action based on culture
    /// </summary>
    /// <param name="a_controller"></param>
    /// <param name="a_action"></param>
    /// <param name="a_culture"></param>
    /// <returns></returns>
    public static LocalizedUrlResult GetUrl(string a_controller, string a_action, string a_culture)
    {
     LocalizedUrlResult result = new LocalizedUrlResult();
     string a_controllerKey = a_controller.ToLower();
     string a_actionKey = a_action.ToLower();
     if (ControllerRoutes.ContainsKey(a_controllerKey))
     {
      CultureControllerData controllerData = ControllerRoutes[a_controllerKey];
      if (controllerData.Actions.ContainsKey(a_actionKey))
      {
       // Ok now we have the controller name and action data name!
       CultureActionData actionData = controllerData.Actions[a_actionKey];
      
       // Check if culture is default culture
       if (a_culture == DefaultCulture)
       {
        // Using "".Equals for the case insensitivity because from like the tag helper it can be lower or upper case.
        // Could also use the controllerKey that is lowercase and then make defaultController lowercase
        if (a_controller.Equals(DefaultController, StringComparison.CurrentCultureIgnoreCase))
        {
         a_controller = "";
        }
        else
        {
         // If controller isn't default then add a /
         // /controller/action
         a_controller += "/";
        }
        if (a_action.Equals(DefaultAction, StringComparison.CurrentCultureIgnoreCase))
        {
         a_action = "";
        }
        result.Url = "/" + a_controller + a_action;
        result.LinkName = a_action;                        
       }
       // If the culture isn't default culture
       else
       {
       
        CultureUrlData linkData = actionData.UrlData.ContainsKey(a_culture) ? actionData.UrlData[a_culture] : actionData.UrlData[DefaultCulture];
        // If the controller doesn't exist add the culture prefix to it stays in the culture prefix space.
        string controllerName = controllerData.Names.ContainsKey(a_culture) ? controllerData.Names[a_culture] : a_culture + "/" + a_controller;
        string actionName = linkData.Route;
        // If the controllerName isn't the default one add a /
        // If not it would be for example /fi/accountLogin    instead of /fi/account/login
        if (!a_controller.Equals(DefaultController, StringComparison.CurrentCultureIgnoreCase))
        {
         // So it becomes => /culture/controller/ 
         controllerName += "/";
        }
        result.Url = "/" + controllerName + actionName;
        result.LinkName = linkData.Link;
       }                    
      }
      // Return just the controller?
      else
      {
      
      }
     }
     return result;
    }
   }
  
  

Other files

Some other small files mainly used for just holding data.

CultureControllerData.cs

For the dictionary in the datahandler. The CultureActionData keys are the action names in the default culture. Meaning the method name in the controller class.
  
   public class CultureControllerData
   {
    /// <summary>
    /// Different controllernames in different cultures
    /// </summary>
    public ConcurrentDictionary<string, string> Names { get; }
    /// <summary>
    /// The actions in the default culture
    /// </summary>
    public ConcurrentDictionary<string, CultureActionData> Actions {get;}
    public CultureControllerData()
    {
     Names = new ConcurrentDictionary<string, string>();
     Actions = new ConcurrentDictionary<string, CultureActionData>();
    }
   }
  
  

CultureActionData.cs

For the dictionary in the CultureControllerData. The UrlData keys are the cultures. So en, fi or sv in my implementation.
  
   public class CultureActionData
   {        
    public ConcurrentDictionary<string, CultureUrlData> UrlData { get; }
    public CultureActionData()
    {
     UrlData = new ConcurrentDictionary<string, CultureUrlData>();
    }
   }
  
  

CultureUrlData.cs

The class used in the CultureActionData UrlData dictionary value. It has the localized route & link text.
  
   public class CultureUrlData
   {
    public string Route { get; set; }
    public string Link { get; set; }
    public CultureUrlData(string a_route, string a_link)
    {
     Route = a_route;
     Link = a_link;
    }
   }
  
  

Examples

In my project I had the cultures en, fi and sv. Where en is the default culture. Here is an example of the home controller and an extra controller I made for testing.

HomeController

The homecontroller code I added some localization routes for.
  
   // This is not neccesary if Home is the defaultcontroller, automatically happens!    
   [LocalizedRoute("fi", "")]    
   [LocalizedRoute("sv", "")]
   public class HomeController : LocalizationController
   {   
   
    public IActionResult Index()
    {
     return View();
    }
    [LocalizedRoute("fi", "meistä")]
    [LocalizedRoute("sv", "om")]
    public IActionResult About()
    {
     ViewData["Message"] = "Your application description page.";
     return View();
    }
    // LocalizedRoute utomatically makes ota_yhteyttä => Ota Yhteyttä
    [LocalizedRoute("fi", "ota_yhteyttä")]
    // Explicitly tell the link text to be Kontakta Oss, url is kontakta-oss
    [LocalizedRoute("sv", "kontakta-oss", "Kontakta Oss")]
    public IActionResult Contact()
    {
     ViewData["Message"] = "Your contact page.";
     return View();
    }
   
    public IActionResult Error()
    {
     return View();
    }
   }
  
  

LocalController

A test controller I made to test different combinations and values.
  
   [LocalizedRoute("fi", "localFi")]
   // No need to name the route local, That's the name it automatically gets
   //[LocalizedRoute("sv", "local")]
   public class LocalController : LocalizationController
   {        
    public LocalController()
    {
     
    }      
   
    // Ignore index since it's default action
    public IActionResult Index()
    {           
     return View();
    }
    // Add the route for default culture with parameters
    [Route("parameter/{index}/{test}")]
    // Final route is : /fi/localFi/param/{index}/{test}
    [LocalizedRoute("fi", "param")]
    // Sv is automatically set as parameter/{index}/{test}
    //[LocalizedRoute("sv", "parameter")]
    public IActionResult Parameter(int index, string test)
    {
     ViewData["index"] = index;
     ViewData["test"] = test;
     return View();
    }
   }
  
  

_Layout.cshtml

A short snippet from the default layout edited to use the localized routing. This is the default navbar in a new asp.net project edited
  
   <div class="navbar-collapse collapse">
   <ul class="nav navbar-nav">
   <li><a asp-controller="Home" asp-action="Index" asp-culture="@ViewData[" culture"]">Home</a></li>
   <li><a asp-controller="Home" asp-action="About" asp-culture="@ViewData[" culture"]">About</a></li>
   <li><a asp-controller="Home" asp-action="Contact" asp-culture="@ViewData[" culture"]">Contact</a></li>
    </ul>
    @await Html.PartialAsync("_LoginPartial")
   </div>
  
  

Conclusion

It works! I havn't tested this against other cultures like russian, japanese characters if it works as nice there. But for me this works fine and I hope it does for you too if you implement it.


I did not in my example implement buttons to change between cultures. If you want to just change between cultures it's easy to add a link to the root of each culture, for my project it'd be: / , /fi/, /sv/ . If you want something more fancy where you keep the current controller & action location you need to get the current controller & action and then input that like this:
  
   <a asp-controller="@ViewData["controller"]" asp-action="@ViewData["action"]" asp-culture="fi"><img src="flag.fi.jpg" alt=""></a>
  
  
There also needs to be an extra argument to tell the tag helper not to overwrite the image in my example as the current functionality would do that.

I'm fairly certain the LocalizedRouteAttribute could inherit from RouteAttribute and instead of creating new RouteAttributes the templates could be updated instead by adding the parameters for example. What can be improved upon regarding existing functionality a potential redirect if you're at like /fi/koti/enAction to redirect the user to /fi/koti/fiAction.

After writing this piece I realise that it's best if I set up github so I can link to the source in its entirety! :) I will do that for future writings and I'll edit this post in future with a github link. It will also make this writing a lot shorter plus I can focus on the keyparts instead of showing the whole source.

3 comments:

  1. The web is certainly missing posts such as yours, great job. "I am going to try a different approach in a few weeks with my new knowledge about routing in asp.net", can you please elaborate?

    ReplyDelete
    Replies
    1. Hi, I did try it again and I failed. I'll update the blogpost to reduce any further confusion. So thanks for that and I will try in future to be more clear on what the different approach would have been.

      What I tried was to extend the asp.net's route class (IRouter) and change the behavior of the lookup of what route to choose depending on Culture there. I'm sorry I don't remember all the details as it was few months ago and I didn't take notes as it failed.

      Delete
  2. Hi haestflod. Thanks for your helpful post.

    There were some changes in the ASP.NET Core since the release: https://github.com/aspnet/Mvc/issues/4043

    `AttributeRoutes` is removed from `ControllerModel` class and `AttributeRouteModel` is removed from `ActionModel` class.

    ReplyDelete