DEV Community

Cover image for Episode 018 - Internationalization - ASP.NET Core: From 0 to overkill
João Antunes
João Antunes

Posted on • Originally published at blog.codingmilitia.com on

Episode 018 - Internationalization - ASP.NET Core: From 0 to overkill

In this episode, we'll use ASP.NET Core internationalization support to make our authentication service available in multiple languages.

For the walk-through you can check out the next video, but if you prefer a quick read, skip to the written synthesis.

The playlist for the whole series is here.

Intro

Since we're playing around with Razor Pages, it's a good opportunity to explore how we can prepare the application to support multiple languages. This also applies to regular MVC.

Quick note: when I use i18n in the post, it means internationalization. That's because it starts with an i, ends with an n and has 18 characters in between. Not my idea! 😛

Configure services and middlewares for i18n

The first thing we need to do is to configure the services and middlewares to handle internationalization.

Configure services

Let's begin with the services. In Startup.ConfigureServices, we'll add 3 things:

  1. Add localization services
  2. Tell MVC we want to use certain aspects of localization (which also stands for Razor Pages, as its coupled to MVC)
  3. Add a configuration for the languages/cultures we'll use

Startup.cs

public class Startup
{
    // ...
    public void ConfigureServices(IServiceCollection services)
    {
        // (1)
        services.AddLocalization(options => options.ResourcesPath = "Resources");

        services
            .AddMvc()
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
            .AddRazorPagesOptions(options =>
            {
                options.Conventions.AuthorizeFolder("/Account");
            }) 
            // (2)
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
            .AddDataAnnotationsLocalization();

        // (3)
        services.Configure<RequestLocalizationOptions>(options =>
        {
            var cultures = new[]
            {
                new CultureInfo("en"),
                new CultureInfo("pt")
            };
            options.DefaultRequestCulture = new RequestCulture("en");
            options.SupportedCultures = cultures;
            options.SupportedUICultures = cultures;
        });
        // ...
    }    
    // ...
}
Enter fullscreen mode Exit fullscreen mode

In (1), we register the required services by calling the extension method. In the options of the method, we're indicating the location of our resource files, which we'll put in the Resources folder, on the project's root.

For (2), we're providing MVC with some extra info regarding localization. AddViewLocalization configures the requirements to use localization in views (for instance, being able to use IViewLocalizer as we'll see in a bit) and AddDataAnnotationsLocalization has the same goal, but regarding data annotations on our view models.

In (3), we're adding to the configuration a RequestLocalizationOptions instance. This object is used by the middleware we'll be registering in a moment, so we could just pass it there, but since we need a list of available cultures to present to the user, we can just register it as a configuration and use it as the source of those cultures.

Configure middleware

On the middleware side, we just need to register a new one, responsible for setting the culture of the request based on some of its properties.

Startup.cs

public class Startup
{
    // ...
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // middlewares that don't depend on localization can come before it...
        app.UseRequestLocalization(
            app.ApplicationServices.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value);
        // middlewares that depend on localization must come afterwards...
    }    
    // ...
}
Enter fullscreen mode Exit fullscreen mode

By default, the middleware checks for the culture in the query string, cookies and accept language header, in this order. This order can be changed and we can even add custom providers to get the request's culture. The ones that come out of the box (and correspond to what the middleware uses by default) are QueryStringRequestCultureProvider, CookieRequestCultureProvider and AcceptLanguageHeaderRequestCultureProvider.

Using resource files

resx files

To store our translated strings we'll use resource files (*.resx), as it's what has support out of the box. Resource files are XML that keep the strings associated with a key so we can fetch them.

Here is an example of an entry in a resource file:

<!-- ... -->    
<data name="ForgotPassword" xml:space="preserve">
    <value>Forgot your password?</value>
</data>
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

We can create custom resource providers, and maybe in the future we should take a look at that, to create something simpler, maybe with JSON. Not that I have a problem with XML, but these resource files are a bit convoluted and a pain to work with outside of Visual Studio (for instance in Rider or VS Code) where there is a dedicated editor for resx files.

resx file name convention

Not mandatory, but normally we associate a resource file with a specific view/controller/page/etc, to keep things organized and avoid massive resource files, but we'll see this in a moment. Besides that, part of the file name should indicate the culture the resource represents.

If we don't specify a culture, the resource file will be treated as the default one, being used when a supported culture is not matched to a specific file. If we want to specify the culture we can do something like SomeResource.pt-PT.resx or SomeResource.pt.resx. The first one will match specifically requests for portuguese from Portugal, while the second one is more generic, so both regular portuguese and brazilian portuguese (pt-BR) will use that file.

View specific resources

Like briefly mentioned, the common approach is to use multiple resource files, normally associating them with specific views/pages/controllers/etc. To make the association we use naming conventions.

Let's start by creating adding i18n support to our login page, starting with the view. In the root of the project we should have a folder named Resources, as mentioned when adjusting the ConfigureServices method. In this folder we will reproduce the folder structure of our pages, so we add another folder called Pages. In here we can create a couple of resource files, to support the cultures we configured, so Login.en.resx and Login.pt.resx, matching the view's name, which is Login.cshtml.

In the login view, we have a Forgot your password? string we can extract to the resource file. In the resource files, we add a new entry with the key ForgotPassword and the text Forgot your password? for the english resource, Esqueceu a palavra passe? for the portuguese one.

Note:
Another approach is to keep the english text in the page (as it's the default culture) and create only resource files for the alternate cultures, using the default culture's text as the key. I'm not a fan of that approach because if we want to adjust the text, we then need to adjust the keys in all the resource files (or worse, we forget we have to do that). Using a dedicated key, that's less of a problem.

Now we need to make the view use this new resource. At the top of Login.cshtml, we add a new line "injecting" an IViewLocalizer we can use to fetch the localized strings.

Login.cshtml

<!-- ... -->
@inject Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer Localizer
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Where the text is, we replace with @Localizer["ForgotPassword"]. Now if we make a request we'll get the english text (unless you have the accept language set to portuguese). To see the portuguese text show up, we can add ?culture=pt to the query string (we'll take care of allowing the user to change the culture later.).

Page model specific resources

Page model specific resources have much in common with the view resources. In Resources/Pages we create a couple of new resource files, named LoginModel.en.resx and LoginModel.pt.resx, which match the name of our page model class, LoginModel. In these new files, for now, we can add a single entry, with key InvalidLoginAttempt and values Invalid login attempt. for english, Tentativa de login inválida. for portuguese.

To make use of this, in the LoginModel constructor we add a new IStringLocalizer<LoginModel> parameter. This injected parameter will be associated with the created resource files, so we can use it to fetch our strings.

Now where we used the Invalid login attempt. string, we can replace with the usage of the injected localizer.

Login.cshtml.cs

public class LoginModel : PageModel
{
    // ...
    public LoginModel(
        // ...
        IStringLocalizer<LoginModel> localizer,
        //...
        )
    {
        // ...
        _localizer = localizer;
    }

    // ...

    public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        returnUrl = returnUrl ?? Url.Content("~/");

        if (ModelState.IsValid)
        {
            //...
            else
            {
                ModelState.AddModelError(string.Empty, _localizer["InvalidLoginAttempt"]);
                return Page();
            }
        }
        return Page();
    }
}
Enter fullscreen mode Exit fullscreen mode

Page model inner classes specific resources (with DataAnnotations)

As you're probably starting to see by now, there's a pattern to get the resource files and classes/views to match up. This is not different for inner classes of page models, but there was something about the naming that took me a while to figure out. We'll get there in a minute, first let's see the class, one of those InputModels we created for the pages, in this case for the LoginModel.

Login.cshtml.cs

public class LoginModel : PageModel
{
    // ...
    public class InputModel
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }

        [Required]
        [DataType(DataType.Password)]
        public string Password { get; set; }

        [Display(Name = "RememberMe")]
        public bool RememberMe { get; set; }
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The only change to the class is the text was Remember me? and now is RememberMe, so it's a better key for the resource files.

All we need to do now is create the resource files like in the other cases, so when the page is rendered the correct string is used. The question is, what should be the name of the file? After scouring the web and a lot of trial and error, finally figured out the files should be named LoginModel+InputModel.en.resx and LoginModel+InputModel.pt.resx.

Side note: on this search even bumped into a similar unanswered question on Stack Overflow (as foretold by xkcd) and was able to help out (even if over 6 months later 😛).

With this precious piece of information, we can get it over with, creating the required resource files and adding the text we want for english and portuguese.

Shared resources

Besides associating resource files with specific views/pages/controllers/etc, there may also be cases where we just want some common string we use in multiple places. The setup for this is a bit weird, but it isn't hard to get working.

In the Resources folder root, we create a new class called SharedResource. The class will remain empty, it's just going to be used so we have a way to reference the resource files, given they're not associated with a specific item.

Next to the new class' file, we can create the resource files, named SharedResource.en.resx and SharedResource.pt.resx. If you're using Visual Studio, you'll notice it groups the files as if it was one in the solution explorer, and you can expand with a click on the little arrow (same as, for instance, cshtml and cshtml.cs files).

Now we have a bunch of ways to use the shared resource. For the simple test I was doing in this application, I simply used it to log something in the OnGet method of the LoginModel class. To access the resource, in the constructor we add anIStringLocalizer<SharedResource> parameter, so it is injected by the framework. Using it is the same as in the other previous examples, so _sharedLocalizer["SampleSharedString"] gets us the string we added to the resource file.

Login.cshtml.cs

public class LoginModel : PageModel
{
    // ...
    public LoginModel(
        // ...
        IStringLocalizer<SharedResource> sharedLocalizer)
    {
        // ...
        _sharedLocalizer = sharedLocalizer;
    }

    // ...

    public void OnGet(string returnUrl = null)
    {
        _logger.LogDebug(_sharedLocalizer["SampleSharedString"]);
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Although I didn't use it in this application, a quick glance at the docs shows us other possibilities, like using it in views and data annotations.

Views

In the case of the views, we get the shared localizer by adding it to the top of the page like @inject IHtmlLocalizer<SharedResource> SharedLocalizer, then using it as previously shown.

Data annotations

For data annotations it's a bit more work. As we've seen previously, we have the annotations automatically associated with the resource (as long as we get the names right). To use the shared resources, we need to override the way the resources are associated with the annotations.

In the Startup class, when we call AddDataAnnotationsLocalization we need do extra configuration (from the docs):

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
        .AddDataAnnotationsLocalization(options => {
            options.DataAnnotationLocalizerProvider = (type, factory) =>
                factory.Create(typeof(SharedResource));
        });
}
Enter fullscreen mode Exit fullscreen mode

By doing this, we're overriding the way the data annotations and the resources are paired up, in this case by always using the shared resource, but we could use some other logic if we wanted. It doesn't seem possible to mix in the same class this and the previous approach though.

Store culture preference

Now that we have i18n mostly configured, played with resource files and used them in different ways, let's take a look at allowing the user to select the desired culture.

We're going to create a select box for the user to select the culture, POSTing the selection to a controller that stores it in a cookie which is then used in every request by the CookieRequestCultureProvider we talked about earlier, to set the culture we should use to render our response. As usual, there are a lot ways to achieve the same result, this is just a simple possibility (maybe if SEO is a concern, having the culture on the route is a better idea?).

Controller

Let's start by creating a new controller named CultureController. It will have a single action, that'll receive the user selected culture (and an url to get back to after setting the preference).

CultureController.cs

public class CultureController : Controller
{
    [HttpPost]
    public IActionResult SelectCulture(string culture, string returnUrl)
    {
        Response.Cookies.Append(
            CookieRequestCultureProvider.DefaultCookieName,
            CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
            new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
        );

        return LocalRedirect(returnUrl);
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing too fancy going on in the controller, where we're simply adding a cookie to the response with the culture preference. We use CookieRequestCultureProvider to get the cookie name the provider will look for when parsing the requests, and create the cookie value in the format the provider expects to read. The last argument, is simply setting the cookie duration to one year.

Partial view

To present the user the possibility to select the culture, we'll create a partial view with a select box.

_SelectCulturePartial.cshtml

@using Microsoft.AspNetCore.Builder
@using Microsoft.AspNetCore.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Options

@inject IViewLocalizer Localizer
@inject IOptions<RequestLocalizationOptions> LocalizationOptions

@{
    var requestCulture = Context.Features.Get<IRequestCultureFeature>();
    var cultureItems = LocalizationOptions.Value.SupportedUICultures
        .Select(c => new SelectListItem { Value = c.Name, Text = Localizer.GetString(c.Name) })
        .ToList();
    var returnUrl = string.IsNullOrEmpty(Context.Request.Path) ? "~/" : $"~{Context.Request.Path.Value}{Context.Request.QueryString}";
}
<div >
    <form id="selectLanguage" 
          asp-controller="Culture" 
          asp-action="SelectCulture" 
          asp-route-returnUrl="@returnUrl"
          method="post" 
          class="form-horizontal" 
          role="form">
        <select name="culture" 
                onchange="this.form.submit();" 
                asp-for="@requestCulture.RequestCulture.UICulture.Name" 
                asp-items="cultureItems"></select>
    </form>
</div>
Enter fullscreen mode Exit fullscreen mode

Although not complicated, there are a bunch of things in this partial we can take a closer look.

For starters, we're injecting the IOptions<RequestLocalizationOptions> we talked about in Startup.ConfigureServices to get the cultures to show to the user.

With the supported cultures, we can create a list of SelectListItem which we pass to the select box tag helper as content, using the asp-items attribute. The select item text is localized, getting the info from Resources/Pages/Shared/_SelectCulturePartial.en.resx (and its pt counterpart).

The rest is a typical form, submitting the culture when the selected value changes.

To wrap up, we head to _Layout.cshtml file, and use the partial view by adding the line @await Html.PartialAsync("_SelectCulturePartial") in there.

Outro

That does it for this quick look at internationalization in ASP.NET Core. As usual there's a lot more to explore, being the docs a great place to get more info on the subject.

Links in the post:

The source code for this post is here.

Sharing and feedback always appreciated!

Thanks for stopping by, cyaz!

Top comments (0)