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
.
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).
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.
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.
When you run your API, you will get the URL in the console window, like below:
Testing the “Hi” endpoint
This endpoint is available at https://xxx:xxxx/api/Localizer and should return “Hello” or “Hej”. Let’s test that:
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.
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.
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! 🙂