DotNetCurry Logo

ASP.NET Core: Globalization and Localization

Posted by: Daniel Jimenez Garcia , on 10/17/2016, in Category ASP.NET
Views: 28567
Abstract: Explore the globalization and localization features provided by the ASP.Net Core framework, including defining allowed cultures, content localization and the culture selection per request

Internationalization involves Globalization and Localization. Globalization and Localization are critical aspects of many web applications, which are best considered early on in a project. This article explores the internationalization features provided by the ASP.Net Core framework, including defining allowed cultures, content localization and the culture selection per request.

Apart from out of the box functionality, this article will take a look at how to use an optional url segment to localize your contents. This way your application will respond to urls like /Home/About, /en/Home/About or /es-ES/Home/About with localized contents.

 

This article will also demonstrate how the ASP.NET Core framework can be modified to match your requirements, creating new conventions and service implementations that you can easily include when composing your application.

ASP.NET Core: Defining the allowed cultures

ASP.Net Core provides a localization specific middleware which can be added to the pipeline by calling app.UseRequestLocalization.

When you add this middleware to your request pipeline, you can provide an object defining the cultures available in your application, plus the default culture.

As you will soon see in the code, there are separated lists for the Culture and for the UICulture. For those of you new to .NET and don’t know the difference between them:

  • · Culture is used when formatting or parsing culture dependent data like dates, numbers, currencies, etc
  • · UICulture is used when localizing strings, for example when using resource files.

You will also see specific cultures and neutral cultures. For example, the culture “en” is the neutral English culture, while “en-US”, “en-GB” and “en-AU” are the English specific cultures for the United States, Great Britain and Australia.

It’s probably time to see some code. Adding the following lines to your Startup.Configure method will define localization support for English and Spanish neutral cultures, with additional support for the United States and Spain specific cultures:

var supportedCultures = new[]
{
    new CultureInfo("en"),
    new CultureInfo("en-US"),
    new CultureInfo("es"),
    new CultureInfo("es-ES")
};
app.UseRequestLocalization(new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("en-US"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures
});

You might be wondering now how a culture from that list is selected when processing a request...

Setting the Request Culture

In the previous section, we have seen how to define the available cultures, but how is one of these selected for each request?

When you use app.UseRequestLocalization you are not just defining the available cultures, you are also defining the RequestCultureProviders.

These objects inspect the incoming request and try to find out which culture should be used by inspecting the request, each different provider following a different strategy.

  • The providers are evaluated in order; the culture will be taken from the first one that is able to determine it.
  • Once a provider determines a culture specified in the request, it will then be crossed with the available cultures before finally selecting the culture for that request.
  • If a specific culture which isn’t available was determined, the generic culture will be used if possible. Otherwise the default culture will be used.
  • If no provider is able to determine the culture, then the default culture will be used.

The predefined list of providers (which you can change) found in the RequestLocalizationOptions contains the following objects:

  • QueryStringRequestCultureProvider. Looks for the query string parameters culture and ui-culture. If only one is found, then that value is used for both the Culture and UICulture.
  • CookieRequestCultureProvider. Looks for a specific cookie which can be set with the static method CookieRequestCultureProvider.MakeCookieValue and whose default name “AspNetCore.Culture” is taken from CookieRequestCultureProvider.DefaultCookieName.
  • AcceptLanguageHeaderRequestCultureProvider. Looks at the Accept-Language header in the request.

These default settings might be enough for your application, especially if you provide some UX where users can pick the culture, triggering an action on your server that sets the AspNetCore.Culture cookie.

However it is also entirely possible to create your own RequestCultureProvider, for example one that uses a parameter from the url…

Creating a custom UrlRequestCultureProvider

In many cases you might want the request to be specified in the url, as in:

  • /articles/latest – should use default culture
  • /en/articles/latest – should use “en” culture if possible
  • /es-ES/articles/latest – should use “es-ES” culture if possible

This is not possible with the default providers; however you can create your own implementation and add it to the list of providers in the RequestLocalizationOptions. The pipeline of request providers would then look like:

aspnet-core-request-providers

Figure 1. Pipeline of culture providers

Define the routes

Let’s start by defining an additional culture aware route that will contain a non-optional segment for the culture string:

  • While the default route matches urls like /controller/action/id, the additional culture aware will match urls like /culture/controller/action/id.
  • The culture segment will have a regex constraint so only valid culture strings like “en” or “es-ES” are allowed.
  • It should be added before the default route so it is evaluated first. Otherwise the culture string would be taken as the controller name!

The route definition will look like:

routes.MapRoute(
    name: "cultureRoute",
    template: "{culture}/{controller}/{action}/{id?}",
    defaults: new { controller = "Home", action = "Index" },                    
    constraints: new { 
       culture = new RegexRouteConstraint("^[a-z]{2}(?:-[A-Z]{2})?$") });

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

With these routes, your application would respond to urls like the following ones:

  • /
  • /en
  • /home
  • /es-ES/home
  • /home/index
  • /es/home/index
  • /home/index/123
  • /en-US/home/index/123

You also might want to consider attribute routing. It is less straightforward than using the route tables since routes are not defined in a central place.

  • One option would be making sure you add the culture segment to every api controller using attribute routing.
  • Another option would be creating a base controller class for your api controllers that use attribute routing, where you ensure the routes will always start by /api/culture/controller or /api/controller.
  • The better option would be creating your own convention for api controllers!

Let’s take a quick look at the base controller class option. This requires a base class like this:

[Route("api/{language:regex(^[[a-z]]{{2}}(?:-[[A-Z]]{{2}})?$)}/[controller]")]
[Route("api/[controller]")]
public class BaseApiController: Controller
{
}

..which you will then inherit from in your api controllers:

public class HelloWorldController: BaseApiController
{
    [HttpGet]
    public string SayHello()
    {
        return "Hello World";
    }
}

The endpoint SayHello above can be then reached with any of these urls:

  • /api/helloworld
  • /api/en/helloworld
  • /api/es-ES/helloworld

This might be enough, but it has a few problems:

  • You need to inherit from the base class on every api controller
  • The urls will always use the controller class names. If you want to redefine it and use something other than the controller name, you would then need to add route attributes to your controller but these attributes should contain the api and api/culture prefixes too!

Create a convention for api routes

A better solution of adding your own convention is not too complicated and is the preferred way for customizing ASP.Net Core in many scenarios.

aspnet-core-route-convention

Figure 2. Route convention compare to the base class approach

Custom conventions are created by implementing the interface IApplicationModelConvention, which exposes a single Apply method. The method receives an ApplicationModel object, which contains most of the metadata concerning your application like controllers, routes, actions, etc. This metadata can be altered within the convention’s Apply method, letting you creating your own conventions.

I will then create a new convention ApiPrefixConvention which will target all controllers whose class name ends with “ApiController” like HelloWorldApiController. This convention will either:

· Add the prefixes api/ and api/culture to any existing route attributes in that controller

· Add entire new route attributes api/controller and api/culture/controller if the controller does not have any specific route attribute

The convention will internally define a few AttributeRouteModel for either the route prefixes or the default routes; it will then proceed to update any controller whose name ends with ApiController:

public ApiPrefixConvention()
{
    //These are meant to be combined with existing route attributes
    apiPrefix = new AttributeRouteModel(
        new RouteAttribute("api/"));
    apiCulturePrefix = new AttributeRouteModel(
        new RouteAttribute("api/{language:regex(^[[a-z]]{{2}}(?:-[[A-Z]]{{2}})?$)}/"));

    //These are meant to be added as routes for api controllers that do not specify any route attribute
    apiRouteWithController = new AttributeRouteModel(
        new RouteAttribute("api/[controller]"));
    apiCultureRouteWithController = new AttributeRouteModel(
        new RouteAttribute("api/{language:regex(^[[a-z]]{{2}}(?:-[[A-Z]]{{2}})?$)}/[controller]"));
}

public void Apply(ApplicationModel application)
{
    foreach (var controller in application.Controllers.Where(c => c.ControllerName.EndsWith("Api")))
    {
        ApplyControllerConvention(controller);
    }
}

Applying the convention means checking whether the controller already defines any route attributes or not. If it has them, we need to combine them with the api/ and api/culture/ prefixes. Otherwise we will add the default route attributes api/controller and api/culture/controller. We will also remove the “Api” suffix from the controller name:

private void ApplyControllerConvention(ControllerModel controller)
{
    //Remove the "Api" suffix from the controller name 
    //The "Controller" suffix is already removed by default conventions
    controller.ControllerName = 
        controller.ControllerName.Substring(0, controller.ControllerName.Length - 3);

    //Either update existing route attributes or add new ones
    if (controller.Selectors.Any(x => x.AttributeRouteModel != null))
    {
        AddPrefixesToExistingRoutes(controller);
    }
    else
    {
        AddNewRoutes(controller);
    }
}

private void AddPrefixesToExistingRoutes(ControllerModel controller)
{
    foreach (var selectorModel in controller.Selectors.Where(x => x.AttributeRouteModel != null).ToList())
    {
        var originalAttributeRoute = selectorModel.AttributeRouteModel;
        //Merge controller selector with the api prefix
        selectorModel.AttributeRouteModel = 
            AttributeRouteModel.CombineAttributeRouteModel(apiPrefix,
                originalAttributeRoute);

        //Add another selector with the culture api prefix
        var cultureSelector = new SelectorModel(selectorModel);
        cultureSelector.AttributeRouteModel = 
            AttributeRouteModel.CombineAttributeRouteModel(apiCulturePrefix,
                originalAttributeRoute);
        controller.Selectors.Add(cultureSelector);
    }
}

private void AddNewRoutes(ControllerModel controller)
{
    //The controller has no route attributes, lets add a default api convention 
    var defaultSelector = controller.Selectors.First(s => s.AttributeRouteModel == null);
    defaultSelector.AttributeRouteModel = apiRouteWithController;
    //Lets add another selector for the api with culture convention
    controller.Selectors.Add(
        new SelectorModel { AttributeRouteModel = apiCultureRouteWithController });
}

With the convention finished, all left to do is adding the convention to the MVC options:

services.AddMvc(opts => opts.Conventions.Insert(0, new ApiPrefixConvention()))

Once everything is in place, let’s revisit our small sample api controller and rename it as HelloWorldApiController so it matches our new convention:

public class HelloWorldApiController
{
    [HttpGet]
    public string SayHello()
    {
        return "Hello World";
    }
}

As you can see, we don’t need to inherit from any base class. As it does not define any route attribute, its endpoint SayHello would then be accessible with any of these routes:

· /api/helloworld

· /api/en/helloworld

· /api/es-ES/helloworld

This is the same we achieved with the base class. However, we can also update the controller and add a new route attribute like:

[Route("hello-world")]
public class HelloWorldApiController
{
    …
}

Now our convention will detect there is a route attribute and will combine it with the api/ and api/culture/ prefixes. Now the SayHello endpoint will be accessible with any of these routes:

· /api/hello-world

· /api/en/hello-world

· /api/es-ES/hello-world

The convention approach gives you greater flexibility while it does not impose any constraints on how you should create your api controller classes. All you need to do is making sure you api controller names end with …ApiController.

Create the request provider

Right now, you application supports routes with a specific culture segment like /api/es-ES/hello-world. Let’s create a new RequestCultureProvider that inspects the url of the current request and looks for the culture segment.

All we need to do is implement the interface IRequestCultureProvider, which defines single method DetermineProviderCultureResult. In this method we need to find out if it is possible to get the culture from the request:

  • Inspect the request url for a culture parameter.
  • If a culture parameter is found, return a new ProviderCultureResult with that culture.
  • If no parameter is found, return null. This means the localization middleware will try with the next provider; if no provider is able to determine the culture, then the default will be used.

The implementation is not too complicated:

public class UrlRequestCultureProvider: IRequestCultureProvider
{
    public Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
    {
        var url = httpContext.Request.Path;

        //Quick and dirty parsing of language from url path, which looks like /api/es-ES/hello-world
        var parts = httpContext.Request.Path.Value
                     .Split('/')
                     .Where(p => !String.IsNullOrWhiteSpace(p)).ToList();
        if (parts.Count == 0)
        {
            return Task.FromResult<ProviderCultureResult>(null);
        }

        var cultureSegmentIndex = parts.Contains("api") ? 1 : 0;
        var hasCulture = Regex.IsMatch(
                  parts[cultureSegmentIndex], 
                  @"^[a-z]{2}(?:-[A-Z]{2})?$");
        if (!hasCulture)
        {
            return Task.FromResult<ProviderCultureResult>(null);
        }

        var culture = parts[cultureSegmentIndex];
        return Task.FromResult(new ProviderCultureResult(culture));
    }
}

As usual, once the provider is created, you just need to register it in the Startup class. Update the Configure method to add the new provider as the first provider in the LocalizationOptions:

localizationOptions.RequestCultureProviders.Insert(0,

new UrlRequestCultureProvider());

That’s it, with our provider defined and configured the culture for the request can be determined from the url, otherwise the next providers will be probed). For example:

  • /home/about will use the default culture
  • /es-ES/home/about will use the es-ES culture
  • /home/about?culture=es-ES will use the es-ES culture
  • /api/hello-world will use the default culture
  • /api/en/hello-world will use the en culture
  • /api/hello-world will use the en default culture assuming there is an Accept-Language header with that value

Localizing content

So far we have seen how the available cultures can be defined, and how one of those will be selected for each request. Now let’s take a look at how to provide content which has been localized to that culture.

ASP.Net Core contains services that can be used to find localized strings. They can be added to your application in Startup.ConfigureServices as in:

services.AddLocalization(options => options.ResourcesPath = "Resources");

services.AddMvc(opts => opts.Conventions.Insert(0, new ApiPrefixConvention()))
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
    .AddDataAnnotationsLocalization();

Localizing content in code

One of the most interesting services registered by the code above is the IStringLocalizer service. It can be used to find localized strings from any class. For example, we could update our simple HelloWorldApiController as:

public class HelloWorldApiController
{
    private readonly IStringLocalizer<HelloWorldApiController> localizer;
    public HelloWorldApiController(IStringLocalizer<HelloWorldApiController> localizer)
    {
        this.localizer = localizer;
    }

    [HttpGet]
    public string SayHello()
    {
        return this.localizer["HelloWorld"];
    }
}

The default implementation of IStringLocalizer will look for resource files based on the class name and the namespace of T. In the case of IStringLocalizer, assuming that controller is inside the Controllers namespace, the “en” culture resources file could be located in any of these locations:

  • /Resources/Controllers/HelloWorldApiController.en.resx
  • /Resources/Controllers.HelloWorldApiController.en.resx

As you can see, one option uses folders while the other uses dots to organize your files.

Localizing content in views

A similar service IViewLocalizer is provided to get localized contents in your views. This is injected using the directive @inject IViewLocalizer Localizer which you can then user in your view as in:

@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
…

@Localizer["Message"]

The default implementation will too use resource files. It follows a similar strategy for finding the resources files for each view, based on its file path. The “en” culture resource file for the view “Views/Home/About.cshtml” can be located in any of these locations:

· /Resources/Views/Home/About.en.resx

· /Resources/Views.Home.About.en.resx

Alternatively, instead of using the IViewLocalizer service you can also create culture specific view files. This is done with the AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix) line of the StartupConfigureServices method.

Instead of creating different resource files for a particular view, you can directly create different views that contain localized content like About.es.cshtml and About.en-US.cshtml.

Conclusion

ASP.Net Core provides out of the box good support for Internationalization, which you can easily take advantage of when creating a new application.

As with the rest of the ASP.Net Core framework, it comes with sensible default options and implementations. When these defaults are not good enough, many times you can just use different options, others you will need to create your own implementations of the required services.

However extending or replacing the default functionality is not as hard as it seems. The UrlRequestProvider and ApiPrefixConvention created in this article are good examples of how to take advantage of the extension points provided by ASP.Net Core and implement your specific requirements in a very clean way.

One final word, there is more to it that what I could cover here! The asp docs site is a good starting point if you need to dig deeper.

Was this article worth reading? Share it with fellow developers too. Thanks!
Share on Google+
Further Reading - Articles You May Like!
Author
Daniel Jimenez Garcia is a passionate software developer with 10+ years of experience. He started as a Microsoft developer and learned to love C# in general and ASP.NET MVC in particular. In the latter half of his career he worked on a broader set of technologies and platforms while these days he is particularly interested in .Net Core and Node.js. He is always looking for better practices and can be seen answering questions on Stack Overflow.


Page copy protected against web site content infringement 	by Copyscape




Feedback - Leave us some adulation, criticism and everything in between!