Web API Localization

This article shows how to set up a Web API service which can support multiple languages and keep it simple. When localizing an application, it is important that all texts or references to language specific resources are not directly referenced in your business methods or code. Only the source or the presentation layer should convert the language keys to localized objects.

Code: https://github.com/damienbod/WebAPILocalization

Setting up a service which supports multiple languages in web API

To add this feature to a web API service, a new MessageHandler can be created. The message handler validates the request header for localized languages. If the request supplies a supported language, the first one found is used. If none are found, a global search is activated. If a language globalization matches a supported globalization, the first found supported localization for this language is used. For example in the demo app de-DE => de-CH. If none are found, the default localization is used.

using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;

namespace WebAPILocalization
{
    public class LanguageMessageHandler : DelegatingHandler
    {
        private const string LangdeCH = "de-CH";
        private const string LangfrFR = "fr-FR";
        private const string LangenGB = "en-GB";

        private readonly List<string> _supportedLanguages = new List<string> { LangdeCH, LangfrFR, LangenGB };

        private bool SetHeaderIfAcceptLanguageMatchesSupportedLanguage(HttpRequestMessage request)
        {
            foreach (var lang in request.Headers.AcceptLanguage)
            {
                if (_supportedLanguages.Contains(lang.Value))
                {
                    SetCulture(request, lang.Value);
                    return true;
                }
            }

            return false;
        }

        private bool SetHeaderIfGlobalAcceptLanguageMatchesSupportedLanguage(HttpRequestMessage request)
        {
            foreach (var lang in request.Headers.AcceptLanguage)
            {
                var globalLang = lang.Value.Substring(0, 2);
                if (_supportedLanguages.Any(t => t.StartsWith(globalLang)))
                {
                    SetCulture(request, _supportedLanguages.FirstOrDefault(i => i.StartsWith(globalLang)));
                    return true;
                }
            }

            return false;
        }

        private void SetCulture(HttpRequestMessage request, string lang)
        {
            request.Headers.AcceptLanguage.Clear();
            request.Headers.AcceptLanguage.Add(new StringWithQualityHeaderValue(lang));
            Thread.CurrentThread.CurrentCulture = new CultureInfo(lang);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(lang);
        }

        protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            if (!SetHeaderIfAcceptLanguageMatchesSupportedLanguage(request))
            {
                // Whoops no localization found. Lets try Globalisation
                if (!SetHeaderIfGlobalAcceptLanguageMatchesSupportedLanguage(request))
                {
                    // no global or localization found
                    SetCulture(request, LangenGB);
                }
            }

            var response = await base.SendAsync(request, cancellationToken);          
            return response;
        }
    }
}

The LanguageMessageHandler class is then added to the global config for the Web API.

using System.Web.Http;

namespace WebAPILocalization
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            config.MapHttpAttributeRoutes();
            config.MessageHandlers.Add(new LanguageMessageHandler());
        }
    }
}

The translations are added to Resource files in the application. The demo app supports de-CH, fr-FR and the default language en-GB. Resource files don’t have to be used, translations could be in a database.

webApiLocalization01

The Model class MyPayload used the translations for its validation. If a required validation exception occurs, the validation message will be displayed in the localized culture.

using System;
using System.ComponentModel.DataAnnotations;
using WebAPILocalization.Resources;

namespace WebAPILocalization.Models
{
    public class MyPayload
    {
        [Required(ErrorMessageResourceType = typeof(AmazingResource), AllowEmptyStrings = false, ErrorMessageResourceName = "NameRequired")]      
        public string Name { get; set; }

        [Required(ErrorMessageResourceType = typeof(AmazingResource), AllowEmptyStrings = false, ErrorMessageResourceName = "DescriptionRequired")] 
        public string Description { get; set; }

        [Required(ErrorMessageResourceType = typeof(AmazingResource), AllowEmptyStrings = false, ErrorMessageResourceName = "TimestampRequired")]
        public DateTime Timestamp { get; set; }
    }
}

The action controller does not required any specific language methods. The get works for all cultures.

[HttpGet]
[Route("")]
public IEnumerable<MyPayload> Get()
{
 var myPayLoad = new MyPayload
 {
  Description = Resources.AmazingResource.Description,
  Timestamp = DateTime.UtcNow,
  Name = Resources.AmazingResource.Name
 };
 return new [] { myPayLoad };
}

Here’s a test result of a Get in the fr-FR localization.
webApiLocalization03

And here’s a test result of a Get in the de-DE localization. This is changed to a de-CH.

webApiLocalization02

The action controller uses the ModelState to validate the create object request in the Post method. If the Model is invalid, a HttpError object is created from the ModelState. The BadRequest(ModelState) provided by the framework cannot be used, because this method results in non-localized strings.

[HttpPost]
[Route("")]
public HttpResponseMessage Post([FromBody]MyPayload value)
{
  if (!ModelState.IsValid)
  {
     HttpError error = GetErrors(ModelState, true);
     return Request.CreateResponse(HttpStatusCode.BadRequest, error);
  }

  return new HttpResponseMessage(HttpStatusCode.Created);
}

Because the BadRequest cannot be used, the HttpError is created in a private method.

// This method is required because the default BadRequest(Modelstate) adds a english message...
private HttpError GetErrors(IEnumerable<KeyValuePair<string, ModelState>> modelState, bool includeErrorDetail)
{
   var modelStateError = new HttpError();
   foreach (KeyValuePair<string, ModelState> keyModelStatePair in modelState)
   {
                string key = keyModelStatePair.Key;
                ModelErrorCollection errors = keyModelStatePair.Value.Errors;
                if (errors != null && errors.Count > 0)
                {
                    IEnumerable<string> errorMessages = errors.Select(error =>
                    {
                        if (includeErrorDetail && error.Exception != null)
                        {
                            return error.Exception.Message;
                        }
                        return String.IsNullOrEmpty(error.ErrorMessage) ? "ErrorOccurred" : error.ErrorMessage;
                    }).ToArray();
                    modelStateError.Add(key, errorMessages);
                }
   }

  return modelStateError;
}

The following diagram shows that the model validation works and returns a localized string.

webApiLocalization04

The delete method returns a HttpResponseMessage with a localized string.


        [HttpDelete]
        [Route("{id}")]
        public void Delete(int id)
        {
            var resp = new HttpResponseMessage(HttpStatusCode.BadRequest)
            {
                Content = new StringContent(Resources.AmazingResource.IdDoesNotExistException + " Id: " +  id),
            };
            throw new HttpResponseException(resp);
        }

Here’ a test result with validation errors.
webApiLocalization05

If required, the validation logic could be added to an ActionController and other checks could be implemented, for example Model = null or whatever.

Here’s some rules to follow when supporting multiple languages:

  1. You should NOT implement one controller per language. DRY
  2. You should use 5 character encoding and not 2 char globalization ISO code. De-DE and not DE or fr-FR and not FR
  3. DateTime objects should not be formatted anywhere except in the presentation layers. Always culture invariant
  4. Don’t used ToString() unless you require a specific Cultured string
  5. Exceptions which are displayed should be translated. (Care must be taken when displaying or logging operating system exceptions.)
  6. Don’t use or rely on the browser language settings as this is for a lot of end users incorrect. In most countries where English is not the default language, this problem is quiet common. For example Person X preferred language is en-US but lives in Switzerland and works for company X. IT from company X supplies a de-CH computer for work. The browser settings are wrong and cannot be changed because only the company has the admin rights to change this…
  7. Define a fix set of localizations to support. Do not try to support everything. Choose one localization for each global language unless the software requirements require different.
  8. You should support runtime language switches. If an application requires a restart or after a language change, the user ends up at a different URL, that’s just not USER friendly software, just bad software design and bad software architecture. Get professional!
  9. Be careful when guessing a user language for the end user. And if doing this make it VERY EASY for the end user to change back to the preferred language. For example the automatic translation tools based on location are terrible. Some don’t even use the browser headers. If doing this, then cache the user settings.
  10. If programming a MVC or browser application, use language parameters in the URL and not browser headers. This is not the case for Web API, as it’s supplying a service. You can use it how you want.

Links:
http://stackoverflow.com/questions/20601578/async-webapi-thread-currentculture

https://aspnetwebstack.codeplex.com/SourceControl/latest

13 comments

  1. Hello. If you’re interested in an easy to use, collaborative tool to localize an application, have a look at the online translation platform https://poeditor.com/
    The interface is really intuitive and they’ve got great support!

  2. Very good! I was looking for means like these to trigger WebAPI localization and your implementation of `LanguageMessageHandler` pointed me to the right approach.

    1. Thanks

      Greetings Damien

  3. Thanks for the article! It works great!

  4. Thomas Pancoast · · Reply

    Paying it forward. If you want to do this in OWIN, try this. (I hope the formatting works)

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using Microsoft.Owin;
    using System.Globalization;
    using System.Threading;

    namespace Authentication
    {

    public class AcceptLanguageMiddleware : OwinMiddleware
    {
    private const string DEFAULT_LANGUAGE = “en-US”;
    private readonly List _supportedLanguages = new List { DEFAULT_LANGUAGE, “en”, “es” };

    private bool SetHeaderIfAcceptLanguageMatchesSupportedLanguage(string acceptLanguages)
    {
    string[] languages = acceptLanguages.Split(“,”.ToCharArray(), StringSplitOptions.RemoveEmptyEntries);
    foreach (string value in languages)
    {
    string lang = value.Trim();
    if (_supportedLanguages.Contains(lang))
    {
    SetCulture(lang);
    return true;
    }
    }

    return false;
    }

    private bool SetHeaderIfGlobalAcceptLanguageMatchesSupportedLanguage(string acceptLanguages)
    {
    string[] languages = acceptLanguages.Split(“,”.ToCharArray(), StringSplitOptions.RemoveEmptyEntries);
    foreach (string value in languages)
    {
    var globalLang = value.Trim().Substring(0, 2);
    if (_supportedLanguages.Any(t => t.StartsWith(globalLang)))
    {
    SetCulture(_supportedLanguages.FirstOrDefault(i => i.StartsWith(globalLang)));
    return true;
    }
    }

    return false;
    }

    private void SetCulture(string lang)
    {
    Thread.CurrentThread.CurrentCulture = new CultureInfo(lang);
    Thread.CurrentThread.CurrentUICulture = new CultureInfo(lang);
    }

    public AcceptLanguageMiddleware(OwinMiddleware next)
    : base(next)
    {
    }

    public async override Task Invoke(IOwinContext context)
    {
    var headers = context.Request.Headers as IDictionary;

    if (headers.ContainsKey(“Accept-Language”))
    {
    string[] acceptLanguage = headers[“Accept-Language”];
    if (acceptLanguage.Length > 0)
    {
    string languages = acceptLanguage[0];

    if (!SetHeaderIfAcceptLanguageMatchesSupportedLanguage(languages))
    {
    if (!SetHeaderIfGlobalAcceptLanguageMatchesSupportedLanguage(languages))
    {
    // no global or localization found
    SetCulture(DEFAULT_LANGUAGE);
    }
    }
    }
    }

    // Set the language in the response header for easier debugging.
    string cultureName = System.Threading.Thread.CurrentThread.CurrentCulture.ToString();
    context.Response.Headers.Add(“Content-Language”, new string[] { cultureName });

    await Next.Invoke(context);
    }
    }
    }

    Then add “app.Use(typeof(AcceptLanguageMiddleware));” to your OWIN startup class.

    1. thanks for the example, greetings Damien

  5. Ruslan · · Reply

    little optimization for SetHeaderIfGlobalAcceptLanguageMatchesSupportedLanguage:
    var globalLang = _supportedLanguages.FirstOrDefault(i => i.StartsWith(lang.Value.Substring(0, 2)));
    if (globalLang != null)
    {
    SetCulture(request, globalLang);
    return true;
    }

    1. nice, thanks

      Greetings Damien

  6. razonrus · · Reply

    Thank you for article!

    My browser sent next value: ru,en-US;q=0.8,en;q=0.6,bg;q=0.4
    first laguage has only Global mark “ru”. Thats why I modify code to single method:

    private bool SetHeaderIfAcceptLanguageMatchesSupportedLanguage(HttpRequestMessage request)
    {
    foreach (var lang in request.Headers.AcceptLanguage)
    {
    if (_supportedLanguages.Contains(lang.Value))
    {
    SetCulture(request, lang.Value);
    return true;
    }

    // Whoops no localization found. Lets try Globalisation
    var globalLang = _supportedLanguages.FirstOrDefault(i => i.StartsWith(lang.Value.Substring(0, 2)));
    if (globalLang != null)
    {
    SetCulture(request, globalLang);
    return true;
    }
    }

    return false;
    }

  7. The GetErrors private method that you implemented is already in the constructor of the HttpError class, why reimplement it? In my case I just instantiated a new one passing it my ModelStateDictionary and it worked as expected.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: