Localization with JSON files in .NET 6

Localization with JSON files in .NET 6

We'll learn how to use JSON-based localization in.NET 6 and how to combine it with caching to make it even more efficient. We'll save the localized strings in JSON files and utilize middleware to swap languages using language keys in the request header.

Source code can be found here

What we'll Build?

We'll create a .NET 6 Web API that provides messages based on the Accepted Language in the request header. We'll use IDistributedCache to cache the string. The main purpose of this approach is to read language strings from a JSON file instead of a RESX file. We'll do this by adding a new IStringLocalizer implementation. This will be straightforward, but it will be extremely useful. Who doesn't want to work with JSON files?

It'll just need three new classes and a few service registrations. Let's get this started!

Getting started with JSON based localization in .NET 6

Create a new ASP.NET Core Web API project in your favorite IDE (I use Visual Studio 2022 Community). Make sure you're using the .NET 6.0 Framework.

Localization with JSON files in .NET 6

I'm not going to include any other class files in this implementation because I want it to be as basic as possible. To clear up the project, I deleted the weather controllers and related files from the WebAPI solution.

There are two aspects to this implementation:

  • A Middleware that can determine the language code passed in at the request header by the client (which will be a postman in our case).
  • An implementation of the IStringLocalizer to support JSON files. I intend to store the JSON file by the locale name (en-US.json) under a Resources folder. Note that we will also use IDistributedCache to make our system more efficient.

Let’s create a new class and name it JsonStringLocalizer.cs

    public class JsonStringLocalizer : IStringLocalizer
    {
        private readonly IDistributedCache _cache;
        private readonly JsonSerializer _serializer = new();

        public JsonStringLocalizer(IDistributedCache cache)
        {
            _cache = cache;
        }

        public LocalizedString this[string name]
        {
            get
            {
                var value = GetString(name);
                return new LocalizedString(name, value ?? name, value == null);
            }
        }

        public LocalizedString this[string name, params object[] arguments]
        {
            get
            {
                var actualValue = this[name];
                return !actualValue.ResourceNotFound
                    ? new LocalizedString(name, string.Format(actualValue.Value, arguments), false)
                    : actualValue;
            }
        }

        public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
        {
            var filePath = $"Resources/{Thread.CurrentThread.CurrentCulture.Name}.json";
            using var str = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
            using var sReader = new StreamReader(str);
            using var reader = new JsonTextReader(sReader);
            while (reader.Read())
            {
                if (reader.TokenType != JsonToken.PropertyName)
                    continue;
                string? key = reader.Value as string;
                reader.Read();
                var value = _serializer.Deserialize<string>(reader);
                yield return new LocalizedString(key, value, false);
            }
        }
        private string? GetString(string key)
        {
            string? relativeFilePath = $"Resources/{Thread.CurrentThread.CurrentCulture.Name}.json";
            var fullFilePath = Path.GetFullPath(relativeFilePath);
            if (File.Exists(fullFilePath))
            {
                var cacheKey = $"locale_{Thread.CurrentThread.CurrentCulture.Name}_{key}";
                var cacheValue = _cache.GetString(cacheKey);
                if (!string.IsNullOrEmpty(cacheValue))
                {
                    return cacheValue;
                }

                var result = GetValueFromJSON(key, Path.GetFullPath(relativeFilePath));

                if (!string.IsNullOrEmpty(result))
                {
                    _cache.SetString(cacheKey, result);

                }
                return result;
            }
            return default;
        }
        private string? GetValueFromJSON(string propertyName, string filePath)
        {
            if (propertyName == null) {
                return default;
            }
            if (filePath == null) {
                return default;
            }
            using var str = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
            using var sReader = new StreamReader(str);
            using var reader = new JsonTextReader(sReader);
            while (reader.Read())
            {
                if (reader.TokenType == JsonToken.PropertyName && reader.Value as string == propertyName)
                {
                    reader.Read();
                    return _serializer.Deserialize<string>(reader);
                }
            }
            return default;
        }
    }

Line #3: We use IDistributedCache here.

Line #31 - 46: With GetAllStrings(), we try to read the JSON file name according to CurrentCulture and return a list of LocalizedString objects. The key and values of all the entries in the retrieved JSON file would be included in this list. Deserialization is performed on each of the read JSON values.

Line #47 - 70: String localization is handled by the GetString() method. In this case, the file path is also determined by the request's current culture. If the file exists, a cache key with a unique name is generated. The system would look in cache memory to determine if the matched key contains any value. If a value is discovered in the cache, it is returned. Otherwise, the app opens the JSON file and tries to get and return the string it finds.

Line #11 - 18: this[string name] This is the entry method in our controller that we will use. It accepts a key and uses the previously described approach to try to find the associated values in the JSON file. It's worth noting that if no value is discovered in the JSON file, the method will return the same key.

Next, we'll create a Factory class that will be responsible for generating the JsonStringLocalizer instance internally. Name the new class as JsonStringLocalizerFactory.

    public class JsonStringLocalizerFactory : IStringLocalizerFactory
    {
        private readonly IDistributedCache _cache;

        public JsonStringLocalizerFactory(IDistributedCache cache)
        {
            _cache = cache;
        }

        public IStringLocalizer Create(Type resourceSource) =>
            new JsonStringLocalizer(_cache);

        public IStringLocalizer Create(string baseName, string location) =>
            new JsonStringLocalizer(_cache);
    }

Then comes the fun part: writing a Middleware that can read the Accept-Language key from the request header and set the current thread's language if the culture is valid.

Create a new class and name it LocalizationMiddleware.

    public class LocalizationMiddleware : IMiddleware
    {
        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            var cultureKey = context.Request.Headers["Accept-Language"];
            if (!string.IsNullOrEmpty(cultureKey))
            {
                if (DoesCultureExist(cultureKey))
                {
                    var culture = new CultureInfo(cultureKey);
                    Thread.CurrentThread.CurrentCulture = culture;
                    Thread.CurrentThread.CurrentUICulture = culture;
                }
            }
            await next(context);
        }
        private static bool DoesCultureExist(string cultureName)
        {
            return CultureInfo.GetCultures(CultureTypes.AllCultures)
                .Any(culture => string.Equals(culture.Name, cultureName,
              StringComparison.CurrentCultureIgnoreCase));
        }
    }

Line #5: The Accept-Language of the current HTTP context is read here from the request header.

Line #8-13: The current thread culture is set if a valid culture is found.

Let's start by adding some language files. Create a new Resources folder and add two new JSON files to it. The JSON files will be named en-US.json and de-DE.json

Sample of en-US.json

{
  "hi": "Hello",
  "welcome": "Welcome {0}, How are you?"
}

Next,de-DE.json. PS, the following was translated via Google Translate.

{
  "hi": "Hallo",
  "welcome": "Willkommen {0}, wie geht es dir?"
}

As you can see, we have two keys, hello and welcome, that the program is supposed to translate based on the request header.

Now comes the crucial phase, where we register our middleware and the JSONLocalizer as services. Add the following to the ConfigureServices function in Startup.cs.

services.AddLocalization();
services.AddSingleton<LocalizationMiddleware>();
services.AddDistributedMemoryCache();
services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();

Add the following to the Configure method. It's worth noting that we've set en-US as our application's default culture. This can also be made configurable by moving it to the appsettings.

var options = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture(new CultureInfo("en-US"))
};
app.UseRequestLocalization(options);
app.UseStaticFiles();
app.UseMiddleware<LocalizationMiddleware>();

Finally, let's create a new API Controller to show how our JSON localizer works. Create a new Controller with the name HomeController.

    public class HomeController : ControllerBase
    {
        private readonly IStringLocalizer<HomeController> _stringLocalizer;

        public HomeController(IStringLocalizer<HomeController> stringLocalizer)
        {
            _stringLocalizer = stringLocalizer;
        }
        [HttpGet]
        public IActionResult Get()
        {
            var message = _stringLocalizer["hi"].ToString();
            return Ok(message);
        }
        [HttpGet("{name}")]
        public IActionResult Get(string name)
        {
            var message = string.Format(_stringLocalizer["welcome"], name);
            return Ok(message);
        }
        [HttpGet("all")]
        public IActionResult GetAll()
        {
            var message = _stringLocalizer.GetAllStrings();
            return Ok(message);
        }
    }

Line #3: Constructor Injection of IStringLocalizer instance.

Line #10 - 14: Here's a test to see whether we can output the localized version of the key 'hi' to the console and then return it as a response.

Line #16 - 20: The application should return a localized version of "Welcome xxxx, how are you?" when we give this endpoint a random name. That's all there is to it.

Line #22 - 26: This method would ideally return all the keys and values found in the corresponding JSON file.

Testing with Postman

Let's fire up Postman and perform some basic tests.

Here is what you get as a response when you send a GET request to the /api/home endpoint with the Accept-Language as de-DE

Localization with JSON files in .NET 6

Accept-Language is set to en-US.

Localization with JSON files in .NET 6

Now, I try to send a GET request to the /api/home endpoint along with the name. Accept-Language set to en-US

Localization with JSON files in .NET 6

Accept-Language set to de-DE.

Localization with JSON files in .NET 6

Finally, when you send a GET request to the /api/home/all endpoint, you get to see all the key/value pairs of our strings in the JSON file related to the relevant accept-language.

Localization with JSON files in .NET 6

Isn't it great to have in your projects?

Summary

We learned a quick and easy technique to implement JSON-based localization in .NET 6 applications in this article. The source code can be found here.