Skip to main content
.NET

How to add localization in ASP.NET Core Web APIs with Caching (.NET 6)

Christian Schou

In this tutorial, I will be teaching you how to add localization in ASP.NET Core Web APIs. Also, we are going to take a look at how we can cache it to make the response time lower and the consumers happier. The localization will be grabbing the strings from a JSON file, that will store and hold the strings for each language. To accomplish this we will be adding new middleware to our application for it to switch language based on a key in the request header.

If you are ready to implement localization to your API, then let’s get started.

The final result

By the end of this tutorial, we will have a fully functioning Web API built with .Net 6 that will return messages from the API in different languages based on a key supplied in the header. To accomplish that we will be implementing cache functionality based on IDistributedCache and IStringLoxalizer to avoid reading the JSON file each time and relying on the cache.

I will do my best to make it simple for you, by providing step-by-step explanations and explaining my code as we are diving deeper into the logic in the application. I can tell you that it only takes three classes and a couple of registrations in our program.cs file to get this service up and running.

Create a new ASP.NET Core Web API in Visual Studio

Alright, the first thing you have to do is create a new Web API based on the template in Visual Studio. If you prefer another IDE that’s totally fine. Make sure that you have .NET 6.0 installed on your development computer to select that framework version. I have named my project LocalizationAPI.

Create a new Web API in Visual Studio
Create a new Web API in Visual Studio

For demo purposes, I will not be creating an onion architecture or using any patterns to handle business logic. Everything will be in the same project all the way.

Implement the Localization logic

This solution will be made up of two pieces, some middleware, and the implementation of IStringLocalizer. The middleware is responsible for determining the language key passed in the request header and IStringLocalizer will be used to make support the JSON files that contain the translation strings. To make everything more efficient we will be adding IDistributedCache.

Create an extension class for IStringLocalizer

The first thing we gotta do is create a new folder named Localization and add a new class that inherits IStringLocalizer. Let’s name it JsonStringLocalizer.cs as it is JSON strings we will be working with. Inherit IStringLocalizer, implement the interface, and add a constructor for the class to inject IDistributedCache.

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;

namespace LocalizationAPI.Localization
{
    public class JsonStringLocalizer : IStringLocalizer
    {
        private readonly IDistributedCache _distributedCache;

        public JsonStringLocalizer(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }

        public LocalizedString this[string name] => throw new NotImplementedException();

        public LocalizedString this[string name, params object[] arguments] => throw new NotImplementedException();

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

What’s going on?

  • First, we use DI to inject IDistributedCache.
  • Then we have the entry methods that we will be using from our controllers. They accept a key (in a moment) and are responsible for finding the right value from our localization file (the JSON files).
Dependency injection in ASP.NET Core
Learn how ASP.NET Core implements dependency injection and how to use it.

Get values from JSON files

First, we should add functionality to get the values from our JSON files. This method should take two parameters, one for the property name and one for the path of the file. Let’s name that one

GetJsonValue. Before we can work with JSON we have to include a reference to the Newtonsoft package. You can install that one in the Nuget Console with the following command: Install-Package Newtonsoft.Json.

private string? GetJsonValue(string propertyName, string filePath)
        {
            // If the properte and filepath is null, return null
            if (propertyName == null) return default;
            if (filePath == null) return default;

            // Let's read some text from the JSON file
            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 we still got more lines in the JSON file
                while (reader.Read())
                {
                    // Check if the property name matches the current line
                    if (reader.TokenType == JsonToken.PropertyName && reader.Value as string == propertyName)
                    {
                        // If it's the right line, then read it and deserialize it into a string and return that
                        reader.Read();
                        return _jsonSerializer.Deserialize<string>(reader);
                    }
                }

                return default;
            }
        }

As you can see I have declared the method as nullable as there is a possibility that the method will return null. At this moment we have not added any reference to the JsonSerializer, let's do that.

private readonly IDistributedCache _distributedCache;
        private readonly JsonSerializer _jsonSerializer = new JsonSerializer();

        public JsonStringLocalizer(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }

Now we can read a JSON file and look for a property named the one passed in the request in the right JSON file. If we don’t find a property with the name, the method will return null.

Get strings from the JSON values

Now it’s time to ask for the property inside the JSON file. In other words – this method is responsible for the localization of strings. This method should determine the right file based on the request. To do that we will take a look at the culture. Let’s name this one: GetLocalizedString.

private string GetLocalizedString(string key)
        {

            // Set path for JSON files
            string relativeFilePath = $"Resources/{Thread.CurrentThread.CurrentCulture.Name}.json";
            string fullFilePath = Path.GetFullPath(relativeFilePath);

            // Check if the file exists
            if (File.Exists(fullFilePath))
            {
                // Declare cache key and the cache value to the distributed cache
                string cacheKey = $"locale_{Thread.CurrentThread.CurrentCulture.Name}_{key}";
                string cacheValue = _distributedCache.GetString(cacheKey);

                // If the string is not null/empty then return the already cached value
                if (!string.IsNullOrEmpty(cacheValue)) return cacheValue;

                // If the string was null, then we look up the property in the JSON file
                string result = GetJsonValue(key, filePath: Path.GetFullPath(relativeFilePath));

                // If we find the property inside the JSON file we update the cache with that result
                if (!string.IsNullOrEmpty(result)) _distributedCache.SetString(cacheKey, result);

                // Return the found string
                return result;
            }

            // If file was not found, return null
            return default;

        }

The code is explaining itself with my comments, but the goal here is to look up the right file, ask for the property and update the cache. If the file exists the code will add a new cache key for that string to make the response next time faster. If the property is not already in the cache, it will ask the GetJsonValue method to look for the property in the right file.

Add logic to implemented interface members

Let’s start off by updating the two entry methods (this[string name]). These are the methods we will be using in our controllers. They will (in a moment) accept a key and try to find the right values from our JSON files using the two above methods we just implemented.

public LocalizedString this[string name]
        {
            get
            {
                // Get the value from the localization JSON file
                string value = GetLocalizedString(name);

                // return that localized string
                return new LocalizedString(name, value ?? name, value == null);
            }
        }

        public LocalizedString this[string name, params object[] arguments]
        {
            get
            {
                // Set the name of the localized string as the actual value
                var theActualValue = this[name];

                // Check if the string was not found. If true an alternate string was found
                return !theActualValue.ResourceNotFound
                    ? new LocalizedString(name, string.Format(theActualValue.Value, arguments), false)
                    : theActualValue;
            }
        }

Please notice that these methods would return the same key if there is no value returned from the JSON file = the property was not found. Then it’s time for the GetAllStrings methods.

public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
        {
            // Get file path for JSON file
            string filePath = $"Resources/{Thread.CurrentThread.CurrentCulture.Name}.json";

            // Let's read some text
            using (var str = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
            using (var sr = new StreamReader(str))
            using (var reader = new JsonTextReader(sr))
            {
                // While we got more lines to read
                while (reader.Read())
                {
                    // Check if the token matches the property name
                    if (reader.TokenType != JsonToken.PropertyName)
                        continue;

                    // Read the key value as a string (might return null)
                    string? key = reader.Value as string;

                    // Read
                    reader.Read();

                    // Deserialize the found string (might return null)
                    string? value = _jsonSerializer.Deserialize<string>(reader);

                    // return an IEnumerable<> of LocalizedStrings containing the cache key and the strings. false = string was found
                    yield return new LocalizedString(key, value, false);
                }
            }
        }

This one was a little more tricky, but here we try to read a JSON file matching the culture we got here and now from the request. If we find one, we will return a list of LocalizedString objects.

🤓
When a method uses yield return, the compiler actually changes the compiled code to return an IEnumerable<>, and the code in the method will not run until other code starts iterating over the returned IEnumerable<>

That’s it for this class. You should now have a full class that looks like the following:

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;

namespace LocalizationAPI.Localization
{
    public class JsonStringLocalizer : IStringLocalizer
    {
        private readonly IDistributedCache _distributedCache;
        private readonly JsonSerializer _jsonSerializer = new JsonSerializer();

        public JsonStringLocalizer(IDistributedCache distributedCache, JsonSerializer jsonSerializer)
        {
            _distributedCache = distributedCache;
            _jsonSerializer = jsonSerializer;
        }

        public LocalizedString this[string name]
        {
            get
            {
                // Get the value from the localization JSON file
                string value = GetLocalizedString(name);

                // return that localized string
                return new LocalizedString(name, value ?? name, value == null);
            }
        }

        public LocalizedString this[string name, params object[] arguments]
        {
            get
            {
                // Set the name of the localized string as the actual value
                var theActualValue = this[name];

                // Check if the string was not found. If true an alternate string was found
                return !theActualValue.ResourceNotFound
                    ? new LocalizedString(name, string.Format(theActualValue.Value, arguments), false)
                    : theActualValue;
            }
        }

        public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
        {
            // Get file path for JSON file
            string filePath = $"Resources/{Thread.CurrentThread.CurrentCulture.Name}.json";

            // Let's read some text
            using (var str = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
            using (var sr = new StreamReader(str))
            using (var reader = new JsonTextReader(sr))
            {
                // While we got more lines to read
                while (reader.Read())
                {
                    // Check if the token matches the property name
                    if (reader.TokenType != JsonToken.PropertyName)
                        continue;

                    // Read the key value as a string (might return null)
                    string? key = reader.Value as string;

                    // Read
                    reader.Read();

                    // Deserialize the found string (might return null)
                    string? value = _jsonSerializer.Deserialize<string>(reader);

                    // return an IEnumerable<> of LocalizedStrings containing the cache key and the strings. false = string was found
                    yield return new LocalizedString(key, value, false);
                }
            }
        }

        private string? GetJsonValue(string propertyName, string filePath)
        {
            // If the properte and filepath is null, return null
            if (propertyName == null) return default;
            if (filePath == null) return default;

            // Let's read some text from the JSON file
            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 we still got more lines in the JSON file
                while (reader.Read())
                {
                    // Check if the property name matches the current line
                    if (reader.TokenType == JsonToken.PropertyName && reader.Value as string == propertyName)
                    {
                        // If it's the right line, then read it and deserialize it into a string and return that
                        reader.Read();
                        return _jsonSerializer.Deserialize<string>(reader);
                    }
                }

                // If file was not found, return null
                return default;
            }
        }

        private string GetLocalizedString(string key)
        {

            // Set path for JSON files
            string relativeFilePath = $"Resources/{Thread.CurrentThread.CurrentCulture.Name}.json";
            string fullFilePath = Path.GetFullPath(relativeFilePath);

            // Check if the file exists
            if (File.Exists(fullFilePath))
            {
                // Declare cache key and the cache value to the distributed cache
                string cacheKey = $"locale_{Thread.CurrentThread.CurrentCulture.Name}_{key}";
                string cacheValue = _distributedCache.GetString(cacheKey);

                // If the string is not null/empty then return the already cached value
                if (!string.IsNullOrEmpty(cacheValue)) return cacheValue;

                // If the string was null, then we look up the property in the JSON file
                string result = GetJsonValue(key, filePath: Path.GetFullPath(relativeFilePath));

                // If we find the property inside the JSON file we update the cache with that result
                if (!string.IsNullOrEmpty(result)) _distributedCache.SetString(cacheKey, result);

                // Return the found string
                return result;
            }

            return default;

        }
    }
}

Create a Factory

A Factory is responsible for generating an instance of our JsonStringLocalizer class. Let’s give it a name that makes sense and is related to the Localizer class, and place it within the Localizer folder. I have named it: JsonStringLocalizerFactory.cs.

The class should inherit the IStringLocalizerFactory interface and will inject the distributed cache. Let’s do that and implement the interface members to satisfy the inherited interface.

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;

namespace LocalizationAPI.Localization
{
    public class JsonStringLocalizerFactory : IStringLocalizerFactory
    {
        private readonly IDistributedCache _distributedCache;

        public JsonStringLocalizerFactory(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }

        public IStringLocalizer Create(Type resourceSource)
        {
            throw new NotImplementedException();
        }

        public IStringLocalizer Create(string baseName, string location)
        {
            throw new NotImplementedException();
        }
    }
}

As you can see It got two methods to create an instance of the resource. Let’s implement some logic to accomplish that:

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;

namespace LocalizationAPI.Localization
{
    public class JsonStringLocalizerFactory : IStringLocalizerFactory
    {
        private readonly IDistributedCache _distributedCache;

        public JsonStringLocalizerFactory(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }

        public IStringLocalizer Create(Type resourceSource)
        {
            return new JsonStringLocalizer(_distributedCache);
        }

        public IStringLocalizer Create(string baseName, string location)
        {
            return new JsonStringLocalizer(_distributedCache);
        }
    }
}

Add the middleware to handle localization

Now for the last new class to add – our middleware. This piece of code is responsible for reading the key named Accept-Language in the request header and setting the language of that one to the culture in the thread responsible for that request.

To implement this we have to create a new class named LocalizerMiddleware.cs inside the Localization folder. I like when things are running asynchronously, so let’s create an async task named InvokeAsync. This task should take in the HttpContext from the request and a RequestDelegate. In the InvokeAsync method, we will need a method to check if the culture exists, let’s start by creating that one.

using System.Globalization;

namespace LocalizationAPI.Localization
{
    public class LocalizerMiddleware : IMiddleware
    {
        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            // Logic in a moment
        }

        private static bool DoesCultureExist(string cultureName)
        {
            // Return the culture where the culture equals the culture name set
            return CultureInfo.GetCultures(CultureTypes.AllCultures).
                Any(culture => string.Equals(culture.Name, cultureName,
                                             StringComparison.CurrentCultureIgnoreCase));
        }
    }
}

So now that we got the methods that can check if the culture exists, let’s include that one in the logic for our InvokeAsync method.

using System.Globalization;

namespace LocalizationAPI.Localization
{
    public class LocalizerMiddleware : IMiddleware
    {
        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            // Set the culture key based on the request header
            var cultureKey = context.Request.Headers["Accept-Language"];

            // If there is supplied a culture
            if (!string.IsNullOrEmpty(cultureKey))
            {
                // Check if the culture exists
                if (DoesCultureExist(cultureKey))
                {
                    // Set the culture Info
                    var culture = new CultureInfo(cultureKey);

                    // Set the culture in the current thread responsible for that request
                    Thread.CurrentThread.CurrentCulture = culture;
                    Thread.CurrentThread.CurrentUICulture = culture;
                }
            }

            // Await the next request
            await next(context);
        }

        private static bool DoesCultureExist(string cultureName)
        {
            // Return the culture where the culture equals the culture name set
            return CultureInfo.GetCultures(CultureTypes.AllCultures).
                Any(culture => string.Equals(culture.Name, cultureName,
                                             StringComparison.CurrentCultureIgnoreCase));
        }
    }
}

Add language files with translations for different languages

Now it’s time for the translation of our API. I will be adding two files to a new folder named Resources, one for English and one for Danish as I’m from Denmark. I will be naming the two files en-US.json and da-DK.json. To make the requests a little dynamic, we can add support for modifying the strings on the fly, when the API request is made. Check the below JSON code for both localization files.

en-US.json

{
  "hi": "Hello",
  "welcome": "Welcome to the Localizer API {0}. How are you doing?"
}

da-DK.json

{
  "hi": "Hej",
  "welcome": "Velkommen til Localizer API'et {0}. Hvordan har du det?"
}

On line 3 you can see that I have added {0} – this makes it possible for us to change the value at location 0 in the string.

Register Services

A very important part – else the above code won’t work. Here we have to register our services and middleware to allow for the localization to work. In Program.cs we have to add these services underneath the SwaggerGen() service:

builder.Services.AddLocalization();
builder.Services.AddSingleton<LocalizerMiddleware>();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();

Now we have to add the following configuration to Program.cs just above app.UseHttpsRedirection();. To avoid errors I have set the default culture (if not supplied in the request header to be English (en-US)).

var options = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture(new CultureInfo("en-US"))
};

app.UseRequestLocalization(options);
app.UseStaticFiles();
app.UseMiddleware<LocalizerMiddleware>();

Add localization to the controller

Now the only thing we have to do is add a new controller to test out the localizer logic. I have named mine LocalizerController.cs – you can name yours whatever you like. Add the code below to the controller actions and fire up the API.

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;

namespace LocalizationAPI.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class LocalizerController : ControllerBase
    {
        private readonly IStringLocalizer<LocalizerController> _stringLocalizer;

        public LocalizerController(IStringLocalizer<LocalizerController> 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);
        }
    }
}

Testing the Localization API

Now for the final part, we all have been waiting for – is it working? To modify the request header when testing I will be using Postman to perform the test.

Postman

When you run your API, you will get the URL in the console window, like below:

Console for running Web API
Console for running Web API

Testing the “Hi” endpoint

This endpoint is available at https://xxx:xxxx/api/Localizer and should return “Hello” or “Hej”. Let’s test that:

Testing endpoint through Postman
Testing endpoint through Postman

Great, we get a “Hello” like expected when we don’t supply anything in the header and we also get the corresponding string, when the right culture for the Accept-Language key is supplied.

Testing the “Name” endpoint

This endpoint is available at https://xxx:xxxx/api/Localizer/{your-name} and should return a welcome string supplied with your name.

Testing name endpoint for localization in ASP.NET Core Web API
Testing name endpoint for localization in ASP.NET Core Web API

And it’s working! The name is added within the string on the fly – how awesome is that?!

Testing all strings endpoint

This endpoint is available at https://xxx:xxxx/api/Localizer/all and should return a list of LocalizedString objects as JSON in the response, let’s test that out.

Testing all strings endpoint for localization in ASP.NET Core Web API
Testing all strings endpoint for localization in ASP.NET Core Web API

Great! We get a list for each language when specifying that in the header.

Summary

In this tutorial, you learned how to achieve localization in an ASP.NET Core Web API with caching for making the requests more efficient. I think that it should be something that everyone should be adding support for in their APIs in the future. It’s easy to do and will give a better experience for those consuming it.

If you got any issues, questions, or suggestions, please let me know in the comments. Happy coding! 🙂