Localization of the DTOs in a separate assembly in ASP.NET Core
Image from Pixabay

Localization of the DTOs in a separate assembly in ASP.NET Core

Localizing validation messages in ASP.NET Core WebAPI

Since REST services do not have the UI, there is not much space for the localization. Data is sent and retrieved and most of the decisions is made based on the status code of the response. After all, REST services are resource based which mean you are querying them for the actual resources stored either in your database or any other storage your application persistence relies on.

In the further text I will try to give answers to some of the questions related to REST API services localization. Feel free to drop a question or comments if you have any concerns about the approach.

Why localization for REST API?

One thing where you might need the localization in REST API services is providing details about the failed requests. For example, the data passed ti passed to the API failed during the model validation, you will return 400 - BAD REQUEST, but consumer client side application also might want to present the details about the response status and the client application is aware of the localization context of the client (language, culture...). In this case localization implementation is required to respond with messages in different languages based on the culture value sent from the client side. In .NET Core, localization is implemented pretty similar as it is in .NET Framework, using resource file. It is quite well described in MSDN article Globalization and localization in ASP.NET Core and it works pretty much out of the box.

The easiness of making localization work out of the box comes from the point that your resources are embedded inside your facade assembly. This means the localization values are read from the resources embedded in the ASP.NET Core WebApi project itself. The problem occurs when you need the localization to work for the resources outside the ASP.NET Core application.

Why DTOs in a separate assembly?

You might want to thing about why would someone do that, but I will give you one strong reason for that. That would be your DTOs! In one of my previous articles about DTO comments from external assembly in Swagger documentation in ASP.NET Core I explained briefly why you might want to take your DTOs outside your ASP.NET Core application.

Dto

The problem with loading localizations from the separate assembly is that you need to setup the localization dependency injection to point to the specific assembly as by default it is pointing to the current executing assembly in which it sits and which is ASP.NET Core project.

Why shared resources for different DTOs?

One thing that I want to skip in this topic and which I will explain why is localization by using class specific resources for validation messages. For one single entity, you will have more than one DTOs and they will have overlapping in validation attributes and of course in validation messages. I think this will be easier to explain by looking at the code, so here is some simple sample entity class which represents a product

    public class Product
    {
        [Key, Column(Order = 0)]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public Guid Id { get; set; }
        [Required]       
        public String Name { get; set; }
        public String Description { get; set; }
        [Timestamp]
        [ConcurrencyCheck]
        public byte[] Timestamp { get; set; }
    }
    

For this entity for example we'll have several DTO as they might be different based on the HTTP method and the action they perform on the entity

    /// <summary>
    /// New product model
    /// </summary>
    public class ProductAdd
    {
        /// <summary>
        /// Unique identifier
        /// </summary>
        [Required]
        [NotEmptyGuid]
        public Guid Id { get; set; }

        /// <summary>
        /// Product name
        /// </summary>
        [Required(ErrorMessage= "Product name is mandatory")]
        public String Name { get; set; }

        /// <summary>
        /// Product description
        /// </summary>
        public String Description { get; set; }
    }

    /// <summary>
    /// Product update model
    /// </summary>
    public class ProductUpdate
    {
        /// <summary>
        /// Product name
        /// </summary>
        [Required(ErrorMessage = "Product name is mandatory")]
        public String Name { get; set; }

        /// <summary>
        /// Product description
        /// </summary>
        public String Description { get; set; }
    }
    

You can see straigh from the first look that in both DTOs we have some overlapping properties that we are validating. Since validation is the same on the both properties in both DTOs, it is obvious that we are going to use the same resource record and therefore we use the same, shared resource file. Eventually project for assembly that holds the DTOs and the resources for translations of validation messages will have the following structure

Translation Resource

Now, we need to tell the default localization setup that we are going to use resources from another assembly for translation the validation messages which are defined with annotations/attributes on the DTO properties.

Setting up dependency injection and ASP.NET Core pipeline

Telling ASP.NET Core localization where to look for localization for the attributes in different place happens in the dependency injection setup in Startup.cs class.

        public void ConfigureServices(IServiceCollection services)
        {
            #region Add localization
            services.AddLocalization(opts => { opts.ResourcesPath = "Resources"; });
            services.Configure<RequestLocalizationOptions>(
                opts =>
                {
                   var supportedCultures = new List<CultureInfo>
                    {
                        new CultureInfo("en"),
                        new CultureInfo("sr"),
                        new CultureInfo("fr")
                    };
                    opts.DefaultRequestCulture = new RequestCulture("en", "en");
                    opts.SupportedCultures = supportedCultures;
                    opts.SupportedUICultures = supportedCultures;
                    
                });
            #endregion
			
            #region MVC          
            services.AddMvc().AddDataAnnotationsLocalization(options =>
            {
                options.DataAnnotationLocalizerProvider = (type, factory) =>
                {
                    var assemblyName = new AssemblyName(typeof(Sample.Contracts.v1.Product.ProductAdd).GetTypeInfo().Assembly.FullName);
                    return factory.Create("Translations", assemblyName.Name);
                };
            });
            #endregion
        }
    

Here I am using reflection to acquire the assembly name by getting the type of ProductAdd DTO and the assembly holding that type, but you can just use the assembly name instead of it. The reason I use reflection is that it is checked during compilation time. Although reflection is heavy operation, executing it one on the startup wont harm the performances during the runtime of the service instance since it is executed only once.

From the dependency injection setup you can see that we are going to support Serbian, French and English and that we falling back to English in case language/culture is not passed. Language value is fetched from either headers or the URL, but we'll get back to this when we test the localization from the postman.

Regrading the keys in the resource files and in the Message property value of the validation attributes, you may notice I am using full sentences. The reason for that is that if the validation fails, I can still respond with the meaningful message, even if there is a key missing in the resource file. This is good especially in cases where you want to focus on the other parts of the service, like some critical functionality, rather then filling up the resources, which you can leave for last.

In resource files, the situation is the same. Message values for different languages are stored with the keys that are matching the sentences of Message property of the validation attributes declared on the DTO properties.

Resx En

Resx Fr

Resx Sr 

We did most of the heavy lifting by setting up localization with dependency injection and adding resources. All we have to do, as a cherry on a cake, is to add the middleware to our pipeline.

Note

It is important to add localization middleware before the MVC middleware so it intercepts the request before it reached the processing by the MVC framework. If you need localization in any other middleware in the pipeline, make sure you add localization middleware before it in the pipeline because pipeline executes in the same order as the middlewares are declared in the code.

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseRequestLocalization();
            app.UseMvc();
        }
    

Testing localization with POSTMAN

We have everything in place and we can start checking how our localization works. There are two parameters that ASP.NET Core takes into consideration in order to determine client's culture:

  • Query string - passing language code in the query string value culture in a request will instruct ASP.NET Core pipeline to apply defined localization if passed language code/culture is supported
  • Headers - language/culture can be also passed with Accept-Headers value to the ASP.NET Core

I prefer to use Accept-Headers for sending the localization parameter as query string tends to complicate the request especially if you have additional parameters in query string that you may use for filtering.

Since in the previous sample DTO code we are marking property Name as required with an error message, we'll make a call that will trigger the validation and return translated message. We'll see how this work with a default localization which we have set to en (English)

Swagger Lang None

The error message we are getting in a response is the one declared for the case when language/culture is not provided. Let's try now to pass the culture in a query string and see what will be the output content for the same payload. We'll pass fr (French) as it is one of the language that we have a resource for and we have it declared in our language list.

Swagger Lang Fr Query

Error message we are getting now is in French. Let's try once more to pass French language param but this time in headers and again for the same payload in order to cause the same validation response message.

Swagger Lang Fr

Localization in our REST API is working as expected but wouldn't it be nice if we could tell our API consumers that we support multiple languages, so they could use it for the customizes requests.

Setting up Swagger to support localization headers

Since we can pass the language/culture either in a query string or headers, we can either add param to each of our controller actions or find the way to make it one single place without so many code changes. This is another reason I prefer to use headers to pass the language/culture value.

In order to pass the additional headers in a request from Swagger UI, we need to declare our own IOperationFilter interface implementation and then add it to Swagger UI options when configuring Swagger dependency injection.

    public class SwaggerLanguageHeader : IOperationFilter
    {
		readonly IServiceProvider _serviceProvider;
        public SwaggerLanguageHeader(IServiceProvider serviceProvider)
        {
            this._serviceProvider = serviceProvider;          
        }
		
        public void Apply(Operation operation, OperationFilterContext context)
        {
            if (operation.Parameters == null)
                operation.Parameters = new List<IParameter>();

            operation.Parameters.Add(new NonBodyParameter
            {
                Name = "Accept-Language",
                In = "header",
                Type = "string",
                Description="Supported languages",
                Enum = (_serviceProvider.GetService(typeof(IOptions<RequestLocalizationOptions>)) as IOptions<RequestLocalizationOptions>)?
                        .Value?.SupportedCultures?.Select(c=>c.TwoLetterISOLanguageName).ToList<Object>(),
                Required = false
            });
        }
    }
    
    

The constructor of the filter class takes IServiceProvider paramater which will be automatically injectd. We need instance of IServoceProvider to access in order to pull out localization settings with supported languages list. Now just to add this filter to the dependency injection for Swagger in Startup.cs

        public void ConfigureServices(IServiceCollection services)
        {

            services.AddSwaggerGen(
                options =>
                {
                    options.OperationFilter<SwaggerLanguageHeader>();

                    // Tells swagger to pick up the output XML document files
                    var currentAssembly = Assembly.GetExecutingAssembly();
                    var xmlDocs = currentAssembly.GetReferencedAssemblies()
                    .Union(new AssemblyName[] { currentAssembly.GetName() })
                    .Select(a => Path.Combine(Path.GetDirectoryName(currentAssembly.Location), $"{a.Name}.xml"))
                    .Where(f => File.Exists(f)).ToArray();

                    Array.ForEach(xmlDocs, (d) =>
                    {
                        options.IncludeXmlComments(d);
                    });


                });
 

            #region Add localization
            services.AddLocalization(opts => { opts.ResourcesPath = "Resources"; });
            services.Configure<RequestLocalizationOptions>(
                opts =>
                {
                   var supportedCultures = new List<CultureInfo>
                    {
                        new CultureInfo("en"),
                        new CultureInfo("sr"),
                        new CultureInfo("fr")
                    };
                    opts.DefaultRequestCulture = new RequestCulture("en", "en");
                    opts.SupportedCultures = supportedCultures;
                    opts.SupportedUICultures = supportedCultures;
                    
                });
            #endregion

            #region MVC          
            services.AddMvc(options =>
            {
            }).AddDataAnnotationsLocalization(options =>
            {
                options.DataAnnotationLocalizerProvider = (type, factory) =>
                {
                    var assemblyName = new AssemblyName(typeof(Sample.Contracts.ErrorMessage).GetTypeInfo().Assembly.FullName);
                    return factory.Create("Translations", assemblyName.Name);
                };
            });

            #endregion

        }
    

Finally let's see how this looks like rendered in Swagger UI

 Swagger Lang

References

Disclaimer

Purpose of the code contained in snippets or available for download in this article is solely for learning and demo purposes. Author will not be held responsible for any failure or damages caused due to any other usage.


About the author

DEJAN STOJANOVIC

Dejan is a passionate Software Architect/Developer. He is highly experienced in .NET programming platform including ASP.NET MVC and WebApi. He likes working on new technologies and exciting challenging projects

CONNECT WITH DEJAN  Loginlinkedin Logintwitter Logingoogleplus Logingoogleplus

JavaScript

read more

SQL/T-SQL

read more

Umbraco CMS

read more

PowerShell

read more

Comments for this article