Marius Schulz

Parametrized Localization in ASP.NET

I've recently been busy localizing an ASP.NET project I've been working on for a while. Users can now switch between German and English. The project consists of one Web API and one MVC application, both of which I localized. I'll focus on how I localized the API project in this post; it's much the same thing for the MVC website.

Some of the localized strings contain placeholders which have to be filled with actual values within the ASP.NET application. Here's how I make sure at compile time that I don't return strings without values inserted for their placeholders.

Localization 101

Take a look at the following code snippet which we're about to localize. We're defining a controller which lets a user change their password, given they've provided the correct current one:

public class ChangePasswordController : ApiBaseController
{
    public IHttpActionResult Put(ChangePasswordModel input)
    {
        if (input.OldPassword != "OpenSesame")
        {
            return BadRequest("Sorry, the specified password is incorrect.");
        }

        // Update the user's password here:
        // ...

        return Ok();
    }
}

The ChangePasswordModel input model is trivial:

public class ChangePasswordModel
{
    public string OldPassword { get; set; }
    public string NewPassword { get; set; }
}

As you can see, there's an error message hardcoded into the controller. While that works fine as long as your API only supports English as its sole language, it'll no longer be flexible enough for language switching.

The technology involved in the localization infrastructure for my Web API project is nothing new, quite the contrary: I've used plain old ResX files. They might seem a little dated, but they work reliably.

A Primer on ResX Files

If you're not familiar with ResX files, the idea is the following: You create a separate key/value pair resource file containing the localized strings for every language your application supports. From these resource files, statically typed C# string are automatically generated for use in your application:

The Visual Studio resource editor

The appropriate resource file is automatically picked at runtime by inspecting the current thread's CurrentUICulture property. One of the supported languages is the default language, which gets selected if no better match can be found. The corresponding resource file doesn't have a language-specific extension in this case.

Here, I've created two resource files holding both German and English versions of all error messages with English being the default language:

Localized Resource Files

Better Tooling Support

Because the Visual Studio resource file editor is not that pleasant to use when dealing with multiple languages, I've additionally used the Zeta Resource Editor. It allows me to open up several language files and edit the different translations for a certain word right next to each other:

The Zeta Resource Editor

Ah, that's much better already. Simply hit CTRL+S to save the current values and update the underlying XML of the .resx files. Afterwards, you'll have to open up and save the resource file in Visual Studio in order for the C# code to be generated.

Using the Localized Strings

Now that we've created two resource files for the error messages, let's put them to use. Instead of hardcoding the error message, we'll read it from the resource file:

if (input.OldPassword != "OpenSesame")
{
    return BadRequest(ErrorMessages.InvalidPassword);
}

The ErrorMessages static class has been automatically generated from the entries within the default resource file. And because all of that is just C# code, you get IntelliSense, of course:

IntelliSense for the generated C# code

Here's how the generated code for the above property looks like, by the way:

/// <summary>
///   Looks up a localized string similar to Sorry, but the specified password is incorrect..
/// </summary>
internal static string InvalidPassword {
    get {
        return ResourceManager.GetString("InvalidPassword", resourceCulture);
    }
}

Setting the Current Thread Culture

If you've carefully looked at the controller code, you'll have noticed that the ChangePasswordController derives from ApiBaseController. This is where the current thread's culture properties get set:

public class ApiBaseController : ApiController
{
    protected override void Initialize(HttpControllerContext controllerContext)
    {
        CultureInfo culture = DetermineBestCulture(Request);

        Thread.CurrentThread.CurrentCulture = culture;
        Thread.CurrentThread.CurrentUICulture = culture;

        base.Initialize(controllerContext);
    }

    private static CultureInfo DetermineBestCulture(HttpRequestMessage request)
    {
        // Somehow determine the best-suited culture for the specified request,
        // e.g. by looking at route data, passed headers, user preferences, etc.
        return request.GetRouteData().Values["lang"].ToString() == "de"
            ? CultureInfo.GetCultureInfo("de-DE")
            : CultureInfo.GetCultureInfo("en-US");
    }
}

Depending on the value of the CurrentUICulture, the ResourceManager class will pick the correct resource file for us at runtime. Lovely. Now let's move on to some more interesting localized strings.

Replacing Placeholder with Actual Values

Suppose we wanted to add a new business rule saying that passwords need to be at least 8 characters long. We add a simple check after the first one:

if (input.NewPassword.Trim().Length < 8)
{
    return BadRequest(ErrorMessages.PasswordTooShort);
}

If we inspect the response returned by the Put action method, we'll see that the placeholder {0} hasn't been populated. After all, why should it? The problem is quite easy to solve, though. I've moved all references to the ErrorMessages class into a new Errors class that formats our API's error messages:

public static class Errors
{
    public static string InvalidPassword
    {
        get { return ErrorMessages.InvalidPassword; }
    }

    public static string PasswordTooShort(int minLength)
    {
        return string.Format(ErrorMessages.PasswordTooShort, minLength);
    }
}

We've been using the same placeholder syntax as string.Format on purpose, which means that we can simply call it with appropriate arguments to get back the completed string. This is how using the PasswordTooShort method looks like in our example:

if (input.NewPassword.Trim().Length < 8)
{
    return BadRequest(Errors.PasswordTooShort(8));
}

More Compile-Time Safety

It should now be (almost) impossible to forget that a certain localized string contains placeholders that need to be filled with values. The compiler will tell you that PasswordTooShort is a method and not a property. Therefore, you'll have to provide a value for the minLength parameter in order to successfully call the method.

You can, of course, still accidentally return error messages with unfilled placeholders if you change the localized strings in the resource files and introduce new placeholders. However, adapting your code is much safer now because adding a parameter to a method will break existing calls to it, which makes it easy to fix all occurrences.

Marius Schulz

Computer science student, developer, and blogger. I love C# and JavaScript, enjoy regular expressions, and probably drink too much coffee.

Next post: Implementing a Custom Forward Pipe Operator for Function Chains in Swift
Previous post:

7 Comments

Dino

Good work, this is very nice for all of us starting asp.net projects wuth multi language support. 😊

Vince Panuccio

Great post. I would have done this slightly different. Instead of setting the culture manually on every request, you can add <globalization culture="auto" uiCulture="auto"> to your web.config. The runtime will then inspect the headers of the HTTP request to see what the users preferred culture is.

Vlad Kirienko

Approach with web.config works only if we assume that user wants to see page in the prefered culture. If user can explicitly choose language on application's user preferences page, there is nothing we can do besides to set culture explicitly on the thread.

Sharlin

Good stuff for Culture. Thanks Marius Schulz!

Idowu

very helpful, thanks for the post.

Matt

How do you move the references from the ErrorMessages class to the Errors class? In the 'Add Resource' in the .resx file?

Marius Schulz

@Matt — I manually copy the keys from the ResX file to the Errors class and either create a property or a method for each error message, depending on whether or not it contains placeholders.

Leave a comment

Marius Schulz on Twitter Marius Schulz on GitHub RSS Feed