Azure Functions Request Localization

Azure Functions (v3): Request Localization

This content may be outdated. Please, read this page keeping its age in your mind.

Returning a localized response, based on a language selection is one of the most common business requirements a software developer has to deal with. This article explains how to perform request localization with Azure Functions RESX files and the default asp.net core IStringLocalizer<T>.

In my asp.net core apps, I usually just add the request localization middleware put my RESX files in a folder in a satellite assembly and things just work. Because the Azure Functions runtime does not support running any middleware out of the box and I didn’t want to add any code inside my functions I had to roll my own solution like I did with response caching.

Be aware that this solution has the following consequences:

  • You cannot use model binding (which is very limited anyway)
  • The attribute uses FunctionInvocationFilters which are currently in preview
  • We are setting the language via CultureInfo.DefaultThreadCurrentCulture, this could lead to wrong localization on some requests.

The File Structure

Start by creating the the following classes and resources. The MyLocalizedStrings.cs file is just an empty wrapper class which is needed for the asp.net core IStringLocalizer<T>.

|- MyApp.csproj
|- FunctionStringLocalizer.cs
|- FunctionRequestLocalizationAttribute.cs
|- Startup.cs
|- MyFunc.cs
|- MyApp.Shared.csproj
|- \Localization
|-- MyLocalizedStrings.cs
|-- MyLocalizedStrings.de.resx
|-- MyLocalizedStrings.en.resx

Required NuGet Packages

<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.0.0" />
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="3.1.6" />

FunctionStringLocalizer

This is an implementation of the asp.net core IStringLocalizer<T> Interface and the core component where all the magic happens.

The implementation creates a .net Resource Manager based on your MyLocalizedStrings wrapper class and loads your resx files as resource sets. The gotcha here is, that you must use different paths for the Azure Function and your local environment.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Resources;
using Microsoft.Extensions.Localization;

namespace MyApp
{
    public class FunctionStringLocalizer<T> : IStringLocalizer<T>
    {
        private readonly string _assemblyName;
        private readonly string _resourceNamespace;

        private Dictionary<string, ResourceSet> _resourceSets = new Dictionary<string, ResourceSet>();
        /// <summary>
        /// StringLocalizer implementation for Azure Functions
        /// </summary>
        /// <param name="assemblyName">Name of the compiled resource file omitting .resources.dll suffix (i.e. MyApp.Shared.resources.dll => MyApp.Shared)</param>
        /// <param name="resourceNamespace">Namespace of the Resources omitting {lang}.resources suffix: MyApp.Shared.Localization.Strings.{lang}.resources => MyApp.Shared.Localization.Strings</param>
        /// <param name="resourceType">Type of the Resources, i.e. typeof(Shared.Localization.MyLocalizedStrings)</param>
        /// <param name="supportedLanguages">List of supported languages</param>
        public FunctionStringLocalizer(
            string assemblyName,
            string resourceNamespace,
            Type resourceType,
            params string[] supportedLanguages
                )
        {
            //TODO: get assembly and resource namespace from type
            _assemblyName = assemblyName;
            _resourceNamespace = resourceNamespace;

            var rm = new ResourceManager(resourceType);
            foreach (var supportedLanguage in supportedLanguages)
            {
                if (!_resourceSets.ContainsKey(supportedLanguage))
                {
                    _resourceSets.Add(supportedLanguage, Load(supportedLanguage));
                }
            }
            
        }

        private ResourceSet Load(string lang)
        {
            var home = Environment.GetEnvironmentVariable("HOME"); //or HOME_EXPANDED, see notes in blog post
            var path = "";
            if (!string.IsNullOrWhiteSpace(home))
            {
                var root = Path.Combine(home, "site", "wwwroot", "bin");
                path = Path.Combine(root, lang, $"{_assemblyName}.resources.dll");
            }

            Assembly asm;
            
            if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
            {
                asm = Assembly.LoadFrom(path);
            }
            else
            {
                asm = Assembly.LoadFrom(Path.Combine(Environment.CurrentDirectory, lang,
                    $"{_assemblyName}.resources.dll"));
            }

            var resourceName = $"{_resourceNamespace}.{lang}.resources";
            var tt = asm.GetManifestResourceNames();
            return new ResourceSet(asm.GetManifestResourceStream(resourceName));
        }

        private string GetString(string key)
        {
            return _resourceSets[CultureInfo.CurrentUICulture.TwoLetterISOLanguageName]?.GetString(key) ?? key;
        }


        public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
        {
            throw new NotImplementedException();
        }

        public IStringLocalizer WithCulture(CultureInfo culture)
        {
            throw new NotImplementedException();
        }

        public LocalizedString this[string name] => new LocalizedString(name, GetString(name));

        public LocalizedString this[string name, params object[] arguments] => new LocalizedString(name, string.Format(GetString(name), arguments));
    }
}

FunctionRequestLocalizationAttribute

The attribute helps setting the current language without adding code in every function. I use the asp.net core default RequestCulture providers here.

In my case, the execution of the attribute almost always happened in another thread, so setting the CultureInfo.CurrentCulture, Thread.CurrentThread.CurrentCulture, etc. had no effect. The only way I got this to work was setting CultureInfo.DefaultThreadCurrentCulture which could lead to wrong localization in some requests. But this worked good enough in my apps, so I didn’t investigate here any further.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
using Microsoft.Azure.WebJobs.Host;

namespace MyApp
{
    public class FunctionRequestLocalizationAttribute : FunctionInvocationFilterAttribute
    {
        private readonly List<IRequestCultureProvider> _requestCultureProviders;

        public FunctionRequestLocalizationAttribute()
        {
            _requestCultureProviders = new List<IRequestCultureProvider>
            {
                new QueryStringRequestCultureProvider(), 
                new CookieRequestCultureProvider(),
                new AcceptLanguageHeaderRequestCultureProvider()
            };
        }

        public FunctionRequestLocalizationAttribute(IRequestCultureProvider customCultureProvider)
        {
            _requestCultureProviders = new List<IRequestCultureProvider>
            {
                customCultureProvider,
                new QueryStringRequestCultureProvider(),
                new CookieRequestCultureProvider(),
                new AcceptLanguageHeaderRequestCultureProvider()
            };
        }

        public override async Task OnExecutingAsync(
            FunctionExecutingContext executingContext, 
            CancellationToken cancellationToken)
        {
            if (!(executingContext.Arguments.First().Value is HttpRequest request))
                throw new ApplicationException(
                    "HttpRequest is null. ModelBinding is not supported, please use HttpRequest as input parameter and deserialize using helper functions.");

            var selectedCulture = CultureInfo.InvariantCulture;
            var selectedUiCulture = CultureInfo.InvariantCulture;

            foreach (var requestCultureProvider in _requestCultureProviders)
            {
                var result = await requestCultureProvider.DetermineProviderCultureResult(request.HttpContext);
                if (result == null) continue;

                var culture = result.Cultures.FirstOrDefault().Value;
                var uiCulture = result.UICultures.FirstOrDefault().Value;

                if (!string.IsNullOrWhiteSpace(culture)) selectedCulture = new CultureInfo(culture);
                if (!string.IsNullOrWhiteSpace(uiCulture)) selectedUiCulture = new CultureInfo(uiCulture);

                break;
            }

            //the only setting having a effect is the DefaultThreadCurrentCulture 
            //i'm not sure if this messes up other requests because it modifies everything in the appdomain
            //-> works good enough for now
            Thread.CurrentThread.CurrentCulture = selectedCulture;
            CultureInfo.CurrentCulture = Thread.CurrentThread.CurrentCulture;
            CultureInfo.DefaultThreadCurrentCulture = Thread.CurrentThread.CurrentCulture;

            Thread.CurrentThread.CurrentUICulture = selectedUiCulture;
            CultureInfo.CurrentUICulture = Thread.CurrentThread.CurrentUICulture;
            CultureInfo.DefaultThreadCurrentUICulture = Thread.CurrentThread.CurrentUICulture;

            await base.OnExecutingAsync(executingContext, cancellationToken);
        }
    }
}

Configuration

To setup the Azure Functions request localization, just add the following line to your Startup class:

builder.Services.AddTransient<IStringLocalizer<MyLocalizedStrings>>(x =>
                new FunctionStringLocalizer<MyLocalizedStrings>(
                    "MyApp.Shared",
                    "MyApp.Shared.Localization.Strings",
                    typeof(MyLocalizedStrings),
                    "en", "de"));

Usage

Just add the FunctionRequestLocalization attribute and you’re good to go.

[FunctionName(nameof(GetCars))]
[FunctionRequestLocalization]
[ProducesResponseType(typeof(IEnumerable<CarListItemPresenter>), (int)System.Net.HttpStatusCode.OK)]
public async Task<IActionResult> GetCars(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "v1/cars")]
	HttpRequest req,
	ILogger log)
{
	return await _carService.GetCarList(ps);
}

As we are using the default asp.net request culture providers, you can easily test your implementation using either the query string, cookies or the accept-language HTTP header:

Query String: http://localhost:5000/?culture=de
Cookie: Value: c=en-US
Accept-Language: en-US,en;q=0.5

Notes

The code still needs some love, as you could see in the TODOs:

  • All the configuration information could be read via reflection from the type parameter in the FunctionStringLocalizer<T>.
  • I’m sure there is a better solution than setting the AppDomain default language for every request.
  • HOME vs. HOME_EXPANDED: Sometimes the Azure environment behaves quite strange and gives me “/c/home” in Windows environments, which leads to FileNotFound Exceptions. I fixed this by using HOME_EXPANDED instead of HOME.

Recommended Reads

This post contains affiliate links. Clicking on those links helps me running my blog and does not add any additional costs! Thank you for your help!

Leave a Reply