JSON Based Localization in ASP.NET Core With Caching – Super Easy Guide

In this article, we are going to learn how to implement JSON Based Localization in ASP.NET Core and club it with Caching to make it even more efficient. In a previous article (Globalization and Localization in ASP.NET Core), we learned the basics of localization where we developed an MVC solution that gives us the option to switch between various languages /cultures making use of RESX files, where we stored the corresponding language strings. But now, let’s use JSON files to store the localized strings and implement middleware to switch languages via language keys in the request header.

You can find the entire source code of this implementation here.

What we’ll build?

We will be building a simple .NET 5.0 WebAPI which returns messages based on the request header’s Accepted Language. Behind the scenes, we will also cache the string via IDistributedCache. The main objective of this implementation is to read the language strings from a JSON file rather than a RESX file. For this, we will be adding in a new implementation for the IStringLocalizer. This will be a pretty straightforward and simple article yet helps a lot in production-ready applications. Who doesn’t want JSON files? 😀

It will be as simple as adding 3 new classes and a couple of service registrations. Let’s get started!

Getting started with JSON Based Localization in ASP.NET Core

Open up your favorite IDE (I use Visual Studio 2019 Community), and create a new ASP.NET Core Web API Project. Make sure to select .NET 5.0 Framework (or the latest one at the time of reading this article. Note that .NET 6 LTS is just around the corner!).

JSON Based Localization in ASP.NET Core

To keep this implementation simple, I will not be adding in any additional class libraries. I removed the weather controllers and related files from the WebAPI solution to tidy up the project.

As mentioned earlier, we have two parts to this implementation:

  • A Middleware that can determine the language code passed in at the request header by the client (which will be 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 effecient.

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

public class JsonStringLocalizer : IStringLocalizer
{
    private readonly IDistributedCache _cache;
    private readonly JsonSerializer _serializer = new JsonSerializer();
    public JsonStringLocalizer(IDistributedCache cache)
    {
        _cache = cache;
    }
    public LocalizedString this[string name]
    {
        get
        {
            string 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)
    {
        string 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 = (string)reader.Value;
                reader.Read();
                string 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";
        string fullFilePath = Path.GetFullPath(relativeFilePath);
        if (File.Exists(fullFilePath))
        {
            string cacheKey = $"locale_{Thread.CurrentThread.CurrentCulture.Name}_{key}";
            string cacheValue = _cache.GetString(cacheKey);
            if (!string.IsNullOrEmpty(cacheValue)) return cacheValue;
            string result = GetValueFromJSON(key, Path.GetFullPath(relativeFilePath));
            if (!string.IsNullOrEmpty(result)) _cache.SetString(cacheKey, result);
            return result;
        }
        return default(string);
    }
    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 && (string)reader.Value == propertyName)
                {
                    reader.Read();
                    return _serializer.Deserialize<string>(reader);
                }
            }
            return default;
        }
    }
}

Note that we are implementing the IStringLocalizer interface.

Line #3: we use IDistributedCache here.

Line #27 – 44: GetAllStrings() here we try to read the JSON file name according to the CurrentCulture, and return a list of LocalizedString objects. Note that this list would contain both the key and values of all the entries in the found JSON file. Each of the read JSON values is deserialized.

Line #45 – 59: GetString() this is the function that is responsible for localizing strings. Here too, the file path is determined in accordance with the current culture of the request. If the file exists, a cache key is created with a pretty unique name. Ideally, the system tries to check if any value exists in the cache memory for the corresponding key. If a value is found in the cache, it is returned. Else, the application accesses the JSON file and tries to get and return the found string.

Line #60 – 78: GetValueFromJSON() as the name suggests, this method accepts the property name and file path of the JSON file, which is then opened in Read Mode. If the corresponding property is found within the JSON file, it is returned. Else a null value would be returned.

Line #9 – 16: this[string name] This is the entry method that we would be using in our controller. It accepts a key and tried to find the corresponding values from the JSON file using the previously explained method. It’s important to note that the method would return the same key if there is no value found in the JSON file.

Next, let’s add a Factory class that would be responsible for internally generating the JsonStringLocalizer instance. 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);
}

Next comes the interesting part where we create a Middleware that can read the Accept-Language key from the request header and set the language of the current thread 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 System.Globalization.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: Here we read the Accept-Language from the request header of the current HTTP context.

Line #8-13: If a valid culture is found, we set the current thread culture.

With that done, let’s add some language files. Create a new folder named Resources and add in 2 new JSON files. We will be naming the JSON files as en-US.json and de-DE.json.

Here is a sample 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 2 keys, hi and welcome which are meant to be translated by the application depending on the request header.

Now comes the important part where we do the service registrations of our middleware and the JSONLocalizer. Open up Startup.cs and add the following under ConfigureServices method.

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

Under the Configure method, add in the following. Note that we are defining en-US as the default culture of our application. You can easily make this configurable by moving it to the appsettings as well.

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 demonstrate the usage of our JSON localizer. Add in a new Controller and name it DemoController.

public class DemoController : ControllerBase
{
    private readonly ILogger<DemoController> _logger;
    private readonly IStringLocalizer<DemoController> _loc;
    public DemoController(ILogger<DemoController> logger, IStringLocalizer<DemoController> loc)
    {
        _logger = logger;
        _loc = loc;
    }
    [HttpGet]
    public IActionResult Get()
    {
        _logger.LogInformation(_loc["hi"]);
        var message = _loc["hi"].ToString();
        return Ok(message);
    }
    [HttpGet("{name}")]
    public IActionResult Get(string name)
    {
        var message = string.Format(_loc["welcome"],name);
        return Ok(message);
    }
    [HttpGet("all")]
    public IActionResult GetAll()
    {
        var message = _loc.GetAllStrings();
        return Ok(message);
    }
}

Line #3-9: Constructor Injection of ILogger and IStringLocalizer instances.

Line #11-16: Here is a simple test where we try to print the localized version of the key ‘hi’ onto the console as well as return it as a response.

Line #17-22: Here, we pass in a random name to this endpoint, and the application is expected to return a localized version of “Welcome xxxx, how are you?”. As simple as that.

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

Testing with Postman

With that done, let’s fire up Postman and run some basic tests.

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

aBXjpSDjYR JSON Based Localization in ASP.NET Core With Caching - Super Easy Guide

Accept-Language is set to en-US. Simple, yeah?

image 1 JSON Based Localization in ASP.NET Core With Caching - Super Easy Guide

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

image 2 JSON Based Localization in ASP.NET Core With Caching - Super Easy Guide

Accept-Language set to de-DE.

image 3 JSON Based Localization in ASP.NET Core With Caching - Super Easy Guide

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

image 4 JSON Based Localization in ASP.NET Core With Caching - Super Easy Guide

Pretty handy to have in your projects, right? That’s a wrap for the article.

Consider supporting me by buying me a coffee.

Thank you for visiting. You can now buy me a coffee by clicking the button below. Cheers!

Buy Me A Coffee

Summary

In this article, we learnt a rather simple way to achieve JSON Based Localization in ASP.NET Core applications. Do share this article with your colleagues and dev circles if you found this interesting. You can find the source code of these mentioned implementations here. Thanks!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

2 Comments

  1. HI there, nice article.
    And how about if we want to use the same files for the client side (in a react/angular) project ?
    Thank you. Nice Work!

    1. Hi, Ideally you would always have a different set of language files for client and server projects. But in cases, as you mention, it would make more sense to expose some kind of language service to which both the client and server app has access. You could also consider using the database to store translations and caching it as mentioned in the article. But I would always prefer to have separate resource files as server and client apps are always 2 different entities that should not share anything.

      Regards,
      Mukesh