Localizing ASP.NET Core 5 APIs (Statically Typed!)

 

Localizing ASP.NET Core APIs: From request localization to model binding localization

Localizing ASP.NET Core 5 APIs (Statically Typed!)

Localization has been around since the early days of .NET and has evolved greatly with the release of ASP.NET Core. In this post we’ll explore the mechanisms and tools the ASP.NET Core Framework provides developers for localizing ASP.NET Core 5 APIs.

General Request Localization

To be able to return localized messages to your caller, you’ll need to enable request localization. This enables your API clients to retrieve messages in their desired language. ASP.NET Core made request localization much easier: no more assembly namespace guessing, messing around with embedded resources, folder name convention magic or pulling your hair out if you want to put localization resources in a satellite assembly.

Startup

The localization configuration is found in the Startup file of your ASP.NET Core 5 web application. We’ll be altering both the ConfigureServices method and the Configure method.

Configure Services

In our ConfigureServices method, we’ll need to add the required services, set the supported cultures and default culture. This requires that the Microsoft.Extensions.Localization NuGet is already installed (which is the case with most recent ASP.NET Core 5 Visual Studio Project-Templates).

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization();
    services.AddRequestLocalization(x =>
    {
        x.DefaultRequestCulture = new RequestCulture("en");
        x.ApplyCurrentCultureToResponseHeaders = true;
        x.SupportedCultures = new List<CultureInfo> {new("de"), new("en")};
        x.SupportedUICultures = new List<CultureInfo> {new("de"), new("en")};
    });
    //...
}

Configure

The Configure method allows us to register the RequestLocalizationMiddleware with the ASP.NET pipeline using the registration method provided by ASP.NET Core.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	if (env.IsDevelopment()) {
		app.UseDeveloperExceptionPage();
		app.UseSwagger();
		app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "My Demo API v1"));
	}

	app.UseRequestLocalization();
	app.UseHttpsRedirection();
	app.UseRouting();
	app.UseAuthorization();
	app.UseEndpoints(endpoints =>
	{
		endpoints.MapControllers();
	});
}

Defining your localized resources

While we still need resource files, it doesn’t matter whether we place them in a satellite assembly or our executing assembly. The only convention here is that we create a companion .cs file for each type of resources you want to localize. For example, if we want to group our localized strings in a resource named “Strings” and want English and German translations, we’ll need to add Strings.en.resx, Strings.de.resx and the empty companion class Strings in a Strings.cs file.

Finally, we add the strings we want to translate to our resource files and are good to go. We also need to make sure that we’ve selected No code generation in the Access Modifier dropdown.

Using localized resources

All we need to do is requesting an IStringLocalizer<T> from the ASP.NET Core dependency injection system, where T is the type of your resource group (i.e. “Strings“)

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
	private readonly IStringLocalizer<Strings> _localizer;

	public TestController(IStringLocalizer<Strings> localizer) {
		_localizer = localizer;
	}

	[HttpGet(Name = nameof(GetLocalizedString))]
	[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
	public async Task<IActionResult> GetLocalizedString() {
		string msg = _localizer.GetString("Exception_ProcessingFailed");
		return Ok(msg);
	}
}

Depending on the query string, accept language header or cookie (read here for more details) our example controller method returns the translated message:

Data Annotation Localization

We need Data Annotation Localization wherever we are validating our model using a ValidationAttribute (i.e RequiredAttribute, MaxLengthAttribute) and want to return localized validation errors. Unfortunately, there is no way to just override any default localization messages, which means we have to modify our existing Attributes to at least contain a resource key.

Preparing the resources

We need to prepare our resources in the same way we did f we need to create the .resx files (DataAnnotations.de.resx, DataAnnotations.en.resx) for the languages we desire to support and a companion .cs file (DataAnnotations.cs), containing an empty class (DataAnnotations):

Startup

ConfigureServices

In our ConfigureServices method we use the AddDataAnnotationsLocalization extension method to register our IStringLocalizer<DataAnnotations>.

services.AddLocalization();
services.AddRequestLocalization(x =>
{
    //...
});

services.AddControllers()
	.AddDataAnnotationsLocalization(o =>
	{
		o.DataAnnotationLocalizerProvider = (type, factory) => factory.Create(typeof(DataAnnotations));
	});

Translating DataAnnotation Attributes

Because there is no way to override the default localization, we have to use the ErrorMessage property to specify a localization key for each attribute.

public record CreateContactRequest
{
	[Required(ErrorMessage = "ValueIsRequired")]
	public string FirstName { get; set; }
	[Required(ErrorMessage = "ValueIsRequired")]
	public string LastName { get; set; }
	[Required(ErrorMessage = "ValueIsRequired")]
	public string EMail { get; set; }
}

Model Binding Localization

Model binding comes into play when ASP.NET Core MVC tries to map the request data to the action method parameters.

We usually see model binding errors in APIs when a caller submits an unexpected value for a route property, like when a integer value is expected but the caller submits a string (i.e. for a id).

Preparing the resource files

As in the previous localization steps, translating model binding messages requires us to create the desired .resx files and a companion .cs file with an empty class in it.

Hooking into the MVC Configuration

Most tutorials suggest that we call services.BuildServiceProvider().GetService<IStringLocalizerFactory>() and build ourselves an instance of the IStringLocalizer<T> during startup. While this is technically possible we should avoid this at all cost because this leads to a couple of unwanted side effects.

The recommended way of hooking into the MVC configuration with full DI support is via IConfigureOptions<T>.

IConfigureOptions<T>

To hook into the MVC configuration, we implement the IConfigureOptions<MvcOptions> interface.

public class MvcConfiguration : IConfigureOptions<MvcOptions>
{
	private readonly IStringLocalizer<ModelBinding> _stringLocalizer;

	public MvcConfiguration(IStringLocalizer<ModelBinding> stringLocalizer)
	{
		_stringLocalizer = stringLocalizer;
	}
	public void Configure(MvcOptions options)
	{
		options.ModelBindingMessageProvider.SetValueIsInvalidAccessor(x => _stringLocalizer.GetString("ValueIsInvalidAccessor", x));
		options.ModelBindingMessageProvider.SetValueMustBeANumberAccessor(x => _stringLocalizer.GetString("ValueMustBeANumberAccessor", x));
		options.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(x => _stringLocalizer.GetString("MissingBindRequiredValueAccessor", x));
		options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor((x,y) => _stringLocalizer.GetString("AttemptedValueIsInvalidAccessor", x,y));
		options.ModelBindingMessageProvider.SetMissingKeyOrValueAccessor(() => _stringLocalizer.GetString("MissingKeyOrValueAccessor"));
		options.ModelBindingMessageProvider.SetUnknownValueIsInvalidAccessor(x => _stringLocalizer.GetString("UnknownValueIsInvalidAccessor", x));
		options.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(x => _stringLocalizer.GetString("ValueMustNotBeNullAccessor", x));
		options.ModelBindingMessageProvider.SetNonPropertyAttemptedValueIsInvalidAccessor(x => _stringLocalizer.GetString("NonPropertyAttemptedValueIsInvalidAccessor", x));
		options.ModelBindingMessageProvider.SetNonPropertyUnknownValueIsInvalidAccessor(() => _stringLocalizer.GetString("UnknownValueIsInvalidAccessor"));
		options.ModelBindingMessageProvider.SetNonPropertyValueMustBeANumberAccessor(() => _stringLocalizer.GetString("NonPropertyValueMustBeANumberAccessor"));
		options.ModelBindingMessageProvider.SetMissingRequestBodyRequiredValueAccessor(() => _stringLocalizer.GetString("MissingRequestBodyRequiredValueAccessor"));
	}
}

We can add any registered dependency to our constructor and MVC will inject it for us to use.

To setup the localized model binding messages, we have to set the accessor for each message to the corresponding message from our localized resources.

At the time of writing, there are 11 translatable messages:

AttemptedValueIsInvalidAccessorThe value ‘{0}’ is not valid for {1}.
MissingBindRequiredValueAccessorA value for the ‘{0}’ property was not provided.
MissingKeyOrValueAccessorA value is required.
MissingRequestBodyRequiredValueAccessorA non-empty request body is required.
NonPropertyAttemptedValueIsInvalidAccessorThe value ‘{0}’ is not valid.
NonPropertyUnknownValueIsInvalidAccessorThe supplied value is invalid.
NonPropertyValueMustBeANumberAccessorThe field must be a number.
UnknownValueIsInvalidAccessorThe supplied value is invalid for {0}.
ValueIsInvalidAccessorThe value ‘{0}’ is invalid.
ValueMustBeANumberAccessorThe field {0} must be a number.
ValueMustNotBeNullAccessorThe value ‘{0}’ is invalid.

Startup

ConfigureServices

To register our IConfigureOptions implementation, all we have to do is register it as a singleton in our ConfigureServices method:

services.AddLocalization();
services.AddRequestLocalization(x =>
{
    //...
});

services.AddSingleton<IConfigureOptions<MvcOptions>, MvcConfiguration>();

Localizing ASP.NET Core 5: Statically Typed ASP.NET Core Localization

ASP.NET Core allows us to retrieve our localized strings using the key names we defined in our .resx files, while this may be okay for a small application, it get’s very complicated if you have a lot of localized strings or when some keys change.

In earlier ASP.NET Versions we used several tools like resgen.exe, ResXFileCodeGenerator or PublicResXFileCodeGenerator for statically typed resource access, which allowed IDE/IntelliSense support. These tools are mostly obsolete for ASP.NET Core and would interfere with IStringLocalizer<T>, but there’s an easy fix: a good old T4 Template.

Statically Typing Resource Keys via T4 Template

The following T4 Template goes through all .resx files starting with “Strings” (defined in ResourceName), replaces any ‘.’ with ‘_’, stores them in a HashSet (to avoid duplicate keys) and writes those keys as typed constants into a c# class (Strings.cs).

<#@ template debug="true" hostspecific="true" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="$(TargetPath)" #>
<#@ assembly name="System.Windows.Forms" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Resources" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

<# var ResourceName = "Strings"; #>

namespace Signa.CRM.API.Localization
{
    public class <#=ResourceName#>
    {
<# 
	var path = Path.GetDirectoryName(Host.TemplateFile);

	var fileNames = Directory.EnumerateFiles(path, $"{ResourceName}*.resx", SearchOption.TopDirectoryOnly).ToList();

	var localizationKeys = new HashSet<String>();
	foreach ( var name in fileNames ) {
		var localeFile = Host.ResolvePath(name);
		ResXResourceSet jpResxSet = new ResXResourceSet(localeFile);
		foreach (DictionaryEntry item in jpResxSet) { 
			localizationKeys.Add(item.Key.ToString());
		}
	}
	foreach(var key in localizationKeys.OrderBy(x => x)){
		if(key.Contains(".")) { 
#>
		public const string <#=key.Replace(".", "_")#> = "<#=key#>"; 
<#	} else { 
#>
		public const string <#=key#> = nameof(<#=key#>);
<#	}
		}
#>
	}
}

T4 Template Result

You can run your T4 via [right click] -> “Run custom tool”. Make sure you’ve set the Custom Tool in the file properties to TextTemplatingFileGenerator.

public class Strings
{
	public const string Exception_APIValidation_AttachmentSizeExceeded = nameof(Exception_APIValidation_AttachmentSizeExceeded);
	public const string Exception_APIValidation_Common = nameof(Exception_APIValidation_Common);
	public const string Exception_ProcessingFailed = nameof(Exception_ProcessingFailed);
	public const string Messages_APIError_InsufficientPermissions = nameof(Messages_APIError_InsufficientPermissions);
	public const string Messages_APIError_InvalidOrMissingInformation = nameof(Messages_APIError_InvalidOrMissingInformation);
	public const string Messages_APIError_ResourceNotFound = nameof(Messages_APIError_ResourceNotFound);
	public const string Messages_APIError_UnauthorizedAccess = nameof(Messages_APIError_UnauthorizedAccess);
	public const string Messages_APIValidation_PleaseSelectAtLeastOneItem = nameof(Messages_APIValidation_PleaseSelectAtLeastOneItem);
}

Wherever we want to access a resource key we just have to use the statically typed key in our generated class like this:

string msg = _localizer.GetString(Strings.Exception_ProcessingFailed);

Note: If you have multiple T4 Templates in the same folder they end up overwriting each others generated files. We recommend a subfolder for each group of resources.

Other Resources

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-5.0