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:
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.
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.
This article has been editorially reviewed by Suprotim Agarwal.
C# and .NET have been around for a very long time, but their constant growth means there’s always more to learn.
We at DotNetCurry are very excited to announce The Absolutely Awesome Book on C# and .NET. This is a 500 pages concise technical eBook available in PDF, ePub (iPad), and Mobi (Kindle).
Organized around concepts, this Book aims to provide a concise, yet solid foundation in C# and .NET, covering C# 6.0, C# 7.0 and .NET Core, with chapters on the latest .NET Core 3.0, .NET Standard and C# 8.0 (final release) too. Use these concepts to deepen your existing knowledge of C# and .NET, to have a solid grasp of the latest in C# and .NET OR to crack your next .NET Interview.
Click here to Explore the Table of Contents or Download Sample Chapters!
Was this article worth reading? Share it with fellow developers too. Thanks!
Daniel Jimenez Garciais a passionate software developer with 10+ years of experience who likes to share his knowledge and has been publishing articles since 2016. He started his career as a Microsoft developer focused mainly on .NET, C# and SQL Server. In the latter half of his career he worked on a broader set of technologies and platforms with a special interest for .NET Core, Node.js, Vue, Python, Docker and Kubernetes. You can
check out his repos.