When developing an application, it is important to think about multi-language support; a multilingual application allows for a wider audience reach. This is also true for web APIs: messages returned by endpoints (for example, validation errors) should be localized, and the service should be able to handle different cultures and deal with time zones. In this chapter of the book, we will talk about globalization and localization, and we will explain what features are available in minimal APIs to work with these concepts. The information and samples that will be provided will guide us when adding multi-language support to our services and correctly handling all the related behaviors so that we will be able to develop global applications.
In this chapter, we will be covering the following topics:
To follow the descriptions in this chapter, you will need to create an ASP.NET Core 6.0 Web API application. Refer to the Technical requirements section in Chapter 1, Introduction to Minimal APIs, for instructions on how to do so.
If you’re using your console, shell, or Bash terminal to create the API, remember to change your working directory to the current chapter number (Chapter09).
All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter09.
When thinking about internationalization, we must deal with globalization and localization, two terms that seem to refer to the same concepts but actually involve different areas. Globalization is the task of designing applications that can manage and support different cultures. Localization is the process of adapting an application to a particular culture, for example, by providing translated resources for each culture that will be supported.
Note
The terms internationalization, globalization, and localization are often abbreviated to I18N, G11N, and L10N, respectively.
As with all the other features that we have already introduced in the previous chapters, globalization and localization can be handled by the corresponding middleware and services that ASP.NET Core provides and work in the same way in minimal APIs and controller-based projects.
You can find a great introduction to globalization and localization in the official documentation available at https://docs.microsoft.com/dotnet/core/extensions/globalization and https://docs.microsoft.com/dotnet/core/extensions/localization, respectively. In the rest of the chapter, we will focus on how to add support for these features in a minimal API project; in this way, we’ll introduce some important concepts and explain how to leverage globalization and localization in ASP.NET Core.
To enable localization within a minimal API application, let us go through the following steps:
var builder = WebApplication.CreateBuilder(args);
//...
var supportedCultures = new CultureInfo[] { new("en"), new("it"), new("fr") };
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
options.DefaultRequestCulture = new
RequestCulture(supportedCultures.First());
});
In our example, we want to support three cultures – English, Italian, and French – so, we create an array of CultureInfo objects.
We’re defining neutral cultures, that is, cultures that have a language but are not associated with a country or region. We could also use specific cultures, such as en-US or en-GB, to represent the cultures of a particular region: for example, en-US would refer to the English culture prevalent in the United States, while en-GB would refer to the English culture prevalent in the United Kingdom. This difference is important because, depending on the scenario, we may need to use country-specific information to correctly implement localization. For example, if we want to show a date, we have to know that the date format in the United States is M/d/yyyy, while in the United Kingdom, it is dd/MM/yyyy. So, in this case, it becomes fundamental to work with specific cultures. We also use specific cultures if we need to support language differences across cultures. For example, a particular word may have different spellings depending on the country (e.g., color in the US versus colour in the UK). That said, for our scenario of minimal APIs, working with neutral cultures is just fine.
In a typical application, cultures and UI cultures are set to the same values, but of course, we can use different options if needed.
var app = builder.Build();
//...
app.UseRequestLocalization();
//...
app.Run();
In the preceding code, with UseRequestLocalization(), we’re adding RequestLocalizationMiddleware to the ASP.NET Core pipeline to set the current culture of each request. This task is performed using a list of RequestCultureProvider that can read information about the culture from various sources. Default providers comprise the following:
For each request, the system will try to use these providers in this exact order, until it finds the first one that can determine the culture. If the culture cannot be set, the one specified in the DefaultRequestCulture property of RequestLocalizationOptions will be used.
If necessary, it is also possible to change the order of the request culture providers or even define a custom provider to implement our own logic to determine the culture. More information on this topic is available at https://docs.microsoft.com/aspnet/core/fundamentals/localization#use-a-custom-provider.
Important note
The localization middleware must be inserted before any other middleware that might use the request culture.
In the case of web APIs, whether using controller-based or minimal APIs, we usually set the request culture through the Accept-Language HTTP header. In the following section, we will see how to extend Swagger with the ability to add this header when trying to invoke methods.
We want Swagger to provide us with a way to specify the Accept-Language HTTP header for each request so that we can test our globalized endpoints. Technically speaking, this means adding an operation filter to Swagger that will be able to automatically insert the language header, using the following code:
public class AcceptLanguageHeaderOperationFilter : IOperationFilter { private readonly List<IOpenApiAny>? supportedLanguages; public AcceptLanguageHeaderOperationFilter (IOptions<RequestLocalizationOptions> requestLocalizationOptions) { supportedLanguages = requestLocalizationOptions.Value. SupportedCultures?.Select(c => newOpenApiString(c.TwoLetterISOLanguageName)). Cast<IOpenApiAny>(). ToList(); } public void Apply(OpenApiOperation operation, OperationFilterContext context) { if (supportedLanguages?.Any() ?? false) { operation.Parameters ??= new List<OpenApiParameter>(); operation.Parameters.Add(new OpenApiParameter { Name = HeaderNames.AcceptLanguage, In = ParameterLocation.Header, Required = false, Schema = new OpenApiSchema { Type = "string", Enum = supportedLanguages, Default = supportedLanguages. First() } }); } } }
In the preceding code, AcceptLanguageHeaderOperationFilter takes the RequestLocalizationOptions object via dependency injection that we have defined at startup and extracts the supported languages in the format that Swagger expects from it. Then, in the Apply() method, we add a new OpenApiParameter that corresponds to the Accept-Language header. In particular, with the Schema.Enum property, we provide the list of supported languages using the values we have extracted in the constructor. This method is invoked for every operation (that is, every endpoint), meaning that the parameter will be automatically added to each of them.
Now, we need to add the new filter to Swagger:
var builder = WebApplication.CreateBuilder(args); //... builder.Services.AddSwaggerGen(options => { options.OperationFilter<AcceptLanguageHeaderOperation Filter>(); });
As we did with the preceding code, for every operation, Swagger will execute the filter, which in turn will add a parameter to specify the language of the request.
So, let’s suppose we have the following endpoint:
app.MapGet("/culture", () => Thread.CurrentThread.CurrentCulture.DisplayName);
In the preceding handler, we just return the culture of the thread. This method takes no parameter; however, after adding the preceding filter, the Swagger UI will show the following:
Figure 9.1 – The Accept-Language header added to Swagger
The operation filter has added a new parameter to the endpoint, allowing us to select the language from a dropdown. We can click the Try it out button to choose a value from the list and then click Execute to invoke the endpoint:
Figure 9.2 – The result of the execution with the Accept-Language HTTP header
This is the result of selecting it as a language request: Swagger has added the Accept-Language HTTP header, which, in turn, has been used by ASP.NET Core to set the current culture. Then, in the end, we get and return the culture display name in the route handler.
This example shows us that we have correctly added globalization support to our minimal API. In the next section, we’ll go further and work with localization, starting by providing translated resources to callers based on the corresponding languages.
Our minimal API now supports globalization, so it can switch cultures based on the request. This means that we can provide localized messages to callers, for example, when communicating validation errors. This feature is based on the so-called resource files (.resx), a particular kind of XML file that contains key-value string pairs representing messages that must be localized.
Note
These resource files are exactly the same as they have been since the early versions of .NET.
With resource files, we can easily separate strings from code and group them by culture. Typically, resource files are put in a folder called Resources. To create a file of this kind using Visual Studio, let us go through the following steps:
Important note
Unfortunately, Visual Studio Code does not provide support for handling .resx files. More information about this topic is available at https://github.com/dotnet/AspNetCore.Docs/issues/2501.
Figure 9.3 – Adding a resource file to the project
The new file will immediately open in the Visual Studio editor.
Figure 9.4 – Changing the Access Modifier of the resource file
As soon as we change this value, Visual Studio will add a Messages.Designer.cs file to the project and automatically create properties that correspond to the strings we insert in the resource file.
Resource files must follow a precise naming convention. The file that contains default culture messages can have any name (such as Messages.resx, as in our example), but the other .resx files that provide the corresponding translations must have the same name, with the specification of the culture (neutral or specific) to which they refer. So, we have Messages.resx, which will store default (English) messages.
Note
We don’t create a resource file for French culture on purpose because this way, we’ll see how APS.NET Core looks up the localized messages in practice.
In this way, Visual Studio will add a static HelloWorld property in the Messages autogenerated class that allows us to access values based on the current culture.
// using Chapter09.Resources;
app.MapGet("/helloworld", () => Messages.HelloWorld);
In the preceding route handler, we simply access the static Mesasges.HelloWorld property that, as discussed before, has been automatically created while editing the Messages.resx file.
If we now run the minimal API and try to execute this endpoint, we’ll get the following responses based on the request language that we select in Swagger:
Table 9.1 – Responses based on the request language
When accessing a property such as HelloWorld, the autogenerated Messages class internally uses ResourceManager to look up the corresponding localized string. First of all, it looks for a resource file whose name contains the requested culture. If it is not found, it reverts to the parent culture of that culture. This means that, if the requested culture is specific, ResourceManager searches for the neutral culture. If no resource file is still found, then the default one is used.
In our case, using Swagger, we can select only English, Italian, or French as a neutral culture. But what happens if a client sends other values? We can have situations such as the following:
Note
If a localized resource file exists, but it doesn’t contain the specified key, then the value of the default file will be used.
We can also use resource files to format localized messages. For example, we can add the following strings to the resource files of the project:
Table 9.2 – A custom localized message
Now, let’s define this endpoint:
// using Chapter09.Resources; app.MapGet("/hello", (string name) => { var message = string.Format(Messages.GreetingMessage, name); return message; });
As in the preceding code example, we get a string from a resource file according to the culture of the request. But, in this case, the message contains a placeholder, so we can use it to create a custom localized message using the name that is passed to the route handler. If we try to execute the endpoint, we will get results such as these:
Table 9.3 – Responses with custom localized messages based on the request language
The possibility to create localized messages with placeholders that are replaced at runtime using different values is a key point for creating truly localizable services.
In the beginning, we said that a typical use case of localization in web APIs is when we need to provide localized error messages upon validation. In the next section, we’ll see how to add this feature to our minimal API.
In Chapter 6, Exploring Validation and Mapping, we talked about how to integrate validation into a minimal API project. We learned how to use the MiniValidation library, rather than FluentValidation, to validate our models and provide validation messages to the callers. We also said that FluentValidation already provides translations for standard error messages.
However, with both libraries, we can leverage the localization support we have just added to our project to support localized and custom validation messages.
Using the MiniValidation library, we can use validation based on Data Annotations with minimal APIs. Refer to Chapter 6, Exploring Validation and Mapping, for instructions on how to add this library to the project.
Then, recreate the same Person class:
public class Person { [Required] [MaxLength(30)] public string FirstName { get; set; } [Required] [MaxLength(30)] public string LastName { get; set; } [EmailAddress] [StringLength(100, MinimumLength = 6)] public string Email { get; set; } }
Every validation attribute allows us to specify an error message, which can be a static string or a reference to a resource file. Let’s see how to correctly handle the localization for the Required attribute. Add the following values in resource files:
Table 9.4 – Localized validation error messages used by Data Annotations
We want it so that when a required validation rule fails, the localized message that corresponds to FieldRequiredAnnotation is returned. Moreover, this message contains a placeholder, because we want to use it for every required field, so we also need the translation of property names.
With these resources, we can update the Person class with the following declarations:
public class Person { [Display(Name = "FirstName", ResourceType = typeof(Messages))] [Required(ErrorMessageResourceName = "FieldRequiredAnnotation", ErrorMessageResourceType = typeof(Messages))] public string FirstName { get; set; } //... }
Each validation attribute, such as Required (as used in this example), exposes properties that allow us to specify the name of the resource to use and the type of class that contains the corresponding definition. Keep in mind that the name is a simple string, with no check at compile time, so if we write an incorrect value, we’ll only get an error at runtime.
Next, we can use the Display attribute to also specify the name of the field that must be inserted in the validation message.
Note
You can find the complete declaration of the Person class with localized data annotations on the GitHub repository at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L97.
Now we can re-add the validation code shown in Chapter 6, Exploring Validation and Mapping. The difference is that now the validation messages will be localized:
app.MapPost("/people", (Person person) => { var isValid = MiniValidator.TryValidate(person, out var errors); if (!isValid) { return Results.ValidationProblem(errors, title: Messages.ValidationErrors); } return Results.NoContent(); });
In the preceding code, the messages contained in the errors dictionary that is returned by the MiniValidator.TryValidate() method will be localized according to the request culture, as described in the previous sections. We also specify the title parameter in the Results.ValidationProblem() invocation because we want to localize this value too (otherwise, it will always be the default One or more validation errors occurred).
If instead of data annotations, we prefer using FluentValidation, we know that it supports localization of standard error messages by default from Chapter 6, Exploring Validation and Mapping. However, with this library, we can also provide our translations. In the next section, we’ll talk about implementing this solution.
With FluentValidation, we can totally decouple the validation rules from our models. As said before, refer to Chapter 6, Exploring Validation and Mapping, for instructions on how to add this library to the project and how to configure it.
Next, let us recreate the PersonValidator class:
public class PersonValidator : AbstractValidator<Person> { public PersonValidator() { RuleFor(p => p.FirstName).NotEmpty(). MaximumLength(30); RuleFor(p => p.LastName).NotEmpty(). MaximumLength(30); RuleFor(p => p.Email).EmailAddress().Length(6, 100); } }
In the case that we haven’t specified any messages, the default ones will be used. Let’s add the following resource to customize the NotEmpty validation rule:
Table 9.5 – The localized validation error messages used by FluentValidation
Note that, in this case, we also have a placeholder that will be replaced by the property name. However, different from data annotations, FluentValidation uses a placeholder with a name to better identify its meaning.
Now, we can add this message in the validator, for example, for the FirstName property:
RuleFor(p => p.FirstName).NotEmpty(). WithMessage(Messages.NotEmptyMessage). WithName(Messages.FirstName);
We use WithMessage() to specify the message that must be used when the preceding rule fails, following which we add the WithName() invocation to overwrite the default property name used for the {PropertyName} placeholder of the message.
Note
You can find the complete implementation of the PersonValidator class with localized messages in the GitHub repository at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L129.
Finally, we can leverage the localized validator in our endpoint, as we did in Chapter 6, Exploring Validation and Mapping:
app.MapPost("/people", async (Person person, IValidator<Person> validator) => { var validationResult = await validator. ValidateAsync(person); if (!validationResult.IsValid) { var errors = validationResult.ToDictionary(); return Results.ValidationProblem(errors, title: Messages.ValidationErrors); } return Results.NoContent(); });
As in the case of data annotations, the validationResult variable will contain localized error messages that we return to the caller using the Results.ValidationProblem() method (again, with the definition of the title property).
Tip
In our example, we have seen how to explicitly assign translations for each property using the WithMessage() method. FluentValidation also provides a way to replace all (or some) of its default messages. You can find more information in the official documentation at https://docs.fluentvalidation.net/en/latest/localization.html#default-messages.
This ends our overview of localization using resource files. Next, we’ll talk about an important topic when dealing with services that are meant to be used worldwide: the correct handling of different time zones.
So far, we have added globalization and localization support to our minimal API because we want it to be used by the widest audience possible, irrespective of culture. But, if we think about being accessible to a worldwide audience, we should consider several aspects related to globalization. Globalization does not only pertain to language support; there are important factors we need to consider, for example, geographic locations, as well as time zones.
So, for example, we can have our minimal API running in Italy, which follows Central European Time (CET) (GMT+1), while our clients can use browsers that execute a single-page application, rather than mobile apps, all over the world. We could also have a database server that contains our data, and this could be in another time zone. Moreover, at a certain point, it may be necessary to provide better support for worldwide users, so we’ll have to move our service to another location, which could have a new time zone. In conclusion, our system could deal with data in different time zones, and, potentially, the same services could switch time zones during their lives.
In these situations, the ideal solution is working with DateTimeOffset, a data type that includes time zones and that JsonSerializer fully supports, preserving time zone information during serialization and deserialization. If we could always use it, we’d automatically solve any problem related to globalization, because converting a DateTimeOffset value to a different time zone is straightforward. However, there are cases in which we can’t handle the DateTimeOffset type, for example:
So, in all the scenarios where we can’t or don’t want to use the DateTimeOffset data type, one of the best and simplest ways to deal with different time zones is to handle all dates using Coordinated Universal Time (UTC): the service must assume that the dates it receives are in the UTC format and, on the other hand, all the dates returned by the API must be in UTC.
Of course, we must handle this behavior in a centralized way; we don’t want to have to remember to apply the conversion to and from the UTC format every time we receive or send a date. The well-known JSON.NET library provides an option to specify how to treat the time value when working with a DateTime property, allowing it to automatically handle all dates as UTC and convert them to that format if they represent a local time. However, the current version of Microsoft JsonSerializer used in minimal APIs doesn’t include such a feature. From Chapter 2, Exploring Minimal APIs and Their Advantages, we know that we cannot change the default JSON serializer in minimal APIs, but we can overcome this lack of UTC support by creating a simple JsonConverter:
public class UtcDateTimeConverter : JsonConverter<DateTime> { public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetDateTime().ToUniversalTime(); public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) => writer.WriteStringValue((value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : value) .ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.' fffffff'Z'")); }
With this converter, we tell JsonSerializer how to treat DateTime properties:
Now, before using this converter, let’s add the following endpoint definition:
app.MapPost("/date", (DateInput date) => { return Results.Ok(new { Input = date.Value, DateKind = date.Value.Kind.ToString(), ServerDate = DateTime.Now }); }); public record DateInput(DateTime Value);
Let’s try to call it, for example, with a date formatted as 2022-03-06T16:42:37-05:00. We’ll obtain something similar to the following:
{ "input": "2022-03-06T22:42:37+01:00", "dateKind": "Local", "serverDate": "2022-03-07T18:33:17.0288535+01:00" }
The input date, containing a time zone, has automatically been converted to the local time of the server (in this case, the server is running in Italy, as stated at the beginning), as also demonstrated by the dateKind field. Moreover, serverDate contains a date that is relative to the server time zone.
Now, let’s add UtcDateTimeConverter to JsonSerializer:
var builder = WebApplication.CreateBuilder(args); //... builder.Services.Configure<Microsoft.AspNetCore.Http.Json. JsonOptions>(options => { options.SerializerOptions.Converters.Add(new UtcDateTimeConverter()); });
With this configuration, every DateTime property will be processed using our custom converters. Now, execute the endpoint again, using the same input as before. This time, the result will be as follows:
{ "input": "2022-03-06T21:42:37.0000000Z", "dateKind": "Utc", "serverDate": "2022-03-06T17:40:08.1472051Z" }
The input is the same, but our UtcDateTimeConverter has now converted the date to UTC and, on the other hand, has serialized the server date as UTC; now, our API, in a centralized way, can automatically handle all dates as UTC, no matter its time zone or the time zones of the callers.
Finally, there are two other points to make all the systems correctly work with UTC:
In this way, the minimal API is truly globalized and can work with any time zone; without having to worry about explicit conversion, all times input or output will be always in UTC, so it will be much easier to handle them.
Developing minimal APIs with globalization and localization support in mind is fundamental in an interconnected world. ASP.NET Core includes all the features needed to create services that can react to the culture of the user and provide translations based on the request language: the usage of localization middleware, resource files, and custom validation messages allows the creation of services that can support virtually every culture. We have also talked about the globalization-related problems that could arise when working with different time zones and shown how to solve it using the centralized UTC date time format so that our APIs can seamlessly work irrespective of the geographic location and time zone of clients.
In Chapter 10, Evaluating and Benchmarking the Performance of Minimal APIs, we will talk about why minimal APIs were created and analyze the performance benefits of using minimal APIs over the classic controller-based approach.