14

I'm struggling with localization in my new .NET Core project. I have 2 projects:

  • DataAccess project with Models and DataAnnotations (e.g. RequiredAttribute)
  • Web project with MVC views etc.

My wish is to localize all validation attributes globally in one single place to have the similar behavior like MVC 5. Is this possible?

I do not want to have separate language files for Models/Views etc.

Microsofts documentation is not very clear on using SharedResources.resx file with localized DataAnnotation messages.

In MVC 5 I didn't take care of it. I only needed to set the locale to my language and everything was fine.

I tried setting the ErrorMessageResourceName and ErrorMessageResourceType to my shared resource file name "Strings.resx" and "Strings.de.resx" in the DataAccess project:

[Required(ErrorMessageResourceName = "RequiredAttribute_ValidationError", ErrorMessageResourceType = typeof(Strings))]

I also tried the setting name to be RequiredAttribute_ValidationError - but it's not working.

I already added .AddDataAnnotationsLocalization() in Startup.cs - but it seems to do nothing.

I've read several articles but I couldn't find the cause why it's not working.

EDIT: What I have so far:

1.) LocService class

 public class LocService
    {
        private readonly IStringLocalizer _localizer;

        public LocService(IStringLocalizerFactory factory)
        {
            _localizer = factory.Create(typeof(Strings));
        }

        public LocalizedString GetLocalizedHtmlString(string key)
        {
            return _localizer[key];
        }
    }

2.) Added Folder "Resources" with Strings.cs (empty class with dummy constructor)

3.) Added Strings.de-DE.resx file with one item "RequiredAttribute_ValidationError"

4.) Modified my Startup.cs

public void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient<MessageService>();
            services.AddDbContext<DataContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

            services.AddSingleton<LocService>();
            services.AddLocalization(options => options.ResourcesPath = "Resources");
            services.AddMvc()
                .AddJsonOptions(options => options.SerializerSettings.ContractResolver = new DefaultContractResolver())
                .AddDataAnnotationsLocalization(
                    options =>
                    {
                        options.DataAnnotationLocalizerProvider = (type, factory) => factory.Create(typeof(Strings));
                    });

            services.Configure<RequestLocalizationOptions>(
                opts =>
                {
                    var supportedCultures = new List<CultureInfo>
                    {
                        new CultureInfo("de-DE"),
                    };

                    opts.DefaultRequestCulture = new RequestCulture("de-DE");
                    // Formatting numbers, dates, etc.
                    opts.SupportedCultures = supportedCultures;
                    // UI strings that we have localized.
                    opts.SupportedUICultures = supportedCultures;
                });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseBrowserLink();
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }

            var locOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();

            app.UseRequestLocalization(locOptions.Value);
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }

I've followed the instructions here but it doesn't work: https://damienbod.com/2017/11/01/shared-localization-in-asp-net-core-mvc/

Please keep in mind that my Models are kept in a separate project.

Sven
  • 2,345
  • 2
  • 21
  • 43
  • 1
    You might want to leave a message on the MS documentation and/or open an issue on GitHub to inform them the documentation is unclear. – NightOwl888 Feb 13 '18 at 14:38
  • You will need to add a complete Startup class if you want us to know what's happening. Please read how to create a [mcve] – Camilo Terevinto Feb 13 '18 at 14:40
  • Please take a closer look at the documentaiton. The resx file must have a special name for it to work or change the name where its searched for – Tseng Feb 13 '18 at 15:51
  • https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization#dataannotations-localization (sorry no time for detailed answer, maybe when i'm at home). It must be named by the ViewModel file or you set a shared resource (both example in docs9 – Tseng Feb 13 '18 at 15:52
  • Or if you prefer a more customized solution with fallback, see my answer [here](https://stackoverflow.com/a/48035865/455493) – Tseng Feb 13 '18 at 15:54
  • @Tseng Yes I would like to use a shared resource, but I have no luck. – Sven Feb 13 '18 at 16:09
  • But did you configure it? It doesn't work out of the box, you need to configure it like in the docs with the options delegate of `AddDataAnnotationsLocalization` – Tseng Feb 13 '18 at 17:09
  • Also please post the assembly name, default namespace and your exact location of the resx file. Its done via some partly odd conventions and doesn't work in some scenarios (where package name differs from default namespace). And if its in an extra assembly, extra registrations are required – Tseng Feb 13 '18 at 17:11
  • The easiest way to make it work is in the ASP.NET application assembly, where assembly name and default namespace are equal (there is an issue on when default namespace differs from assembly name including workaround in the comments https://github.com/aspnet/Localization/issues/340) – Tseng Feb 13 '18 at 17:14
  • And also namespace of your `Strings` class – Tseng Feb 13 '18 at 17:28
  • @Tseng My assembly name and default namespace are equal, that's not an issue. Please have a look at my Startup.cs - there is registration in the options of AddDataAnnotationsLocalization(). – Sven Feb 13 '18 at 19:35
  • 2
    @Tseng: You've pointed me in the right direction. The clue is that the resx file containing shared resources must be in the same root namespace as the application. Since I modified the namespace everything is working now. But I still wonder if Localization can work with a plain and simple `[Required]` annotation. Now I have to write `[Required(ErrorMessage = "RequiredAttribute_ValidationError")]` – Sven Feb 13 '18 at 19:58

5 Answers5

16

As @Sven points out in his comment to Tseng's answer it still requires that you specify an explicit ErrorMessage, which gets quite tedious.

The problem arises from the logic ValidationAttributeAdapter<TAttribute>.GetErrorMessage() uses to decide whether to use the provided IStringLocalizer or not. I use the following solution to get around that issue:

  1. Create a custom IValidationAttributeAdapterProvider implementation that uses the default ValidationAttributeAdapterProvider like this:

    public class LocalizedValidationAttributeAdapterProvider : IValidationAttributeAdapterProvider
    {
        private readonly ValidationAttributeAdapterProvider _originalProvider = new ValidationAttributeAdapterProvider();
    
        public IAttributeAdapter GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer stringLocalizer)
        {
            attribute.ErrorMessage = attribute.GetType().Name.Replace("Attribute", string.Empty);
            if (attribute is DataTypeAttribute dataTypeAttribute)
                attribute.ErrorMessage += "_" + dataTypeAttribute.DataType;
    
            return _originalProvider.GetAttributeAdapter(attribute, stringLocalizer);
        }
    }
    
  2. Register the adapter in Startup.ConfigureServices() Before calling AddMvc():

    services.AddSingleton<Microsoft.AspNetCore.Mvc.DataAnnotations.IValidationAttributeAdapterProvider, LocalizedValidationAttributeAdapterProvider>();
    

I prefer to use "stricter" resource names based on the actual attributes, so the code above will look for resource names like "Required" and "DataType_Password", but this can of course be customized in many ways.

If you prefer resources names based on the default messages of the Attributes you could instead write something like:

attribute.ErrorMessage = attribute.FormatErrorMessage("{0}");
Anders
  • 734
  • 8
  • 12
  • This does not seem to work ; my function `GetAttributeAdapter` is not called at all, even though I added the singleton service, before the MVC one. Any idea? – youen Sep 24 '18 at 09:07
  • @youen, that sounds strange. Have you appended the `AddDataAnnotationsLocalization()`-call to the `AddMvc()`-call? Also, the MVC framework does a lot of caching so the method is not necessarily called on every request, so breakpoints etc. may not always be hit when you expect them to. – Anders Sep 26 '18 at 12:11
  • @Anders I have tried your solution and I have a strange problem with it: the custom GetAttributeAdapter is called when I use [EmailAddress] annotation, but it's not called when I use [Required] or [RequiredAttribute]. – Máté Eke Oct 02 '18 at 14:47
  • @Anders It looks liket he same issue: https://stackoverflow.com/questions/50888963/localize-required-annotation-in-french-implicitly?noredirect=1&lq=1 – Máté Eke Oct 03 '18 at 08:11
  • I have the same issue - GetAttributeAdapter is called only for some attributes (e.g. EmailAddress) but not for Required, MaxLength etc. – JustAMartin Jul 11 '19 at 09:55
  • I used the attribute.ErrorMessage = attribute.FormatErrorMessage("{0}"). Unfortunately, it doesn't work in a couple of case like CompareAttribute that has 2 values to replace. The kind of cheated behind the scene so there is one already replaced... You can cheat too!! by using reflection: attribute.ErrorMessage = (string)typeof(ValidationAttribute).GetProperty("ErrorMessageString", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(attribute); – Yepeekai Oct 22 '19 at 16:58
  • So many things for such simple task. Could Microsoft simplify that mechanism? – ADM-IT Jul 13 '20 at 14:37
6

I tried setting the ErrorMessageResourceName and ErrorMessageResourceType to my shared resource file name "Strings.resx" and "Strings.de.resx" in the DataAccess project:

   [Required(ErrorMessageResourceName = "RequiredAttribute_ValidationError", ErrorMessageResourceType = typeof(Strings))]

I also tried the setting name to be RequiredAttribute_ValidationError - but it's not working.

You were on the right track, but you don't necessarily need to set ErrorMessageResourceName / ErrorMessageResourceType properties.

Was we can see in the source code of ValidationAttributeAdapter<TAttribute>, the conditions to use the _stringLocalizer verison is when ErrorMessage is not null and ErrorMessageResourceName/ErrorMessageResourceType are null.

In other words, when you don't set any properties or only ErrorMessage. So a plain [Required] should just work (see source where is passed to the base classes constructor).

Now, when we look at the DataAnnotations resource file we see that the name is set to "RequiredAttribute_ValidationError" and the value to "The {0} field is required." which is the default English translation.

Now if you use "RequiredAttribute_ValidationError" with the German translation in your "Strings.de-DE.resx" (or just Strings.resx as fallback), it should work with the corrected namespace from the comments.

So using the above configuration and the strings from the GitHub repository you should be able to make the localization work without extra attributes.

Tseng
  • 61,549
  • 15
  • 193
  • 205
  • Hi Tseng, thanks for you answer, it's partly correct. But it doesn't work without setting the `ErrorMessage` property. A plain `[RequiredAttribute]` is not enough to show translated validation messages. – Sven Feb 14 '18 at 16:16
  • @Sven: Did you actually really put the translation in the correct file? Remember in your resource file you have to use `RequiredAttribute_ValidationError` and its the language specific model file (Like `Models/MyModel.resx` and `Models/MyModel.de-DE.resx` – Tseng Dec 12 '18 at 12:26
5

It turned out that ValidationAttributeAdapterProvider approach doesn't work as it is meant to be used for "client side validation attributes" only (which doesn't make much sense to me because the attributes are specified on the server model).

But I found a solution that works to override all attributes with custom messages. It also is able to inject field name translations without spitting [Display] all over the place. It's convention-over-configuration in action.

Also, as a bonus, this solution overrides default model binding error texts that are used even before validation takes place. One caveat - if you receive JSON data, then Json.Net errors will be merged into ModelState errors and default binding errors won't be used. I haven't yet figured out how to prevent this from happening.

So, here are three classes you will need:

    public class LocalizableValidationMetadataProvider : IValidationMetadataProvider
    {
        private IStringLocalizer _stringLocalizer;
        private Type _injectableType;

        public LocalizableValidationMetadataProvider(IStringLocalizer stringLocalizer, Type injectableType)
        {
            _stringLocalizer = stringLocalizer;
            _injectableType = injectableType;
        }

        public void CreateValidationMetadata(ValidationMetadataProviderContext context)
        {
            // ignore non-properties and types that do not match some model base type
            if (context.Key.ContainerType == null ||
                !_injectableType.IsAssignableFrom(context.Key.ContainerType))
                return;

            // In the code below I assume that expected use of ErrorMessage will be:
            // 1 - not set when it is ok to fill with the default translation from the resource file
            // 2 - set to a specific key in the resources file to override my defaults
            // 3 - never set to a final text value
            var propertyName = context.Key.Name;
            var modelName = context.Key.ContainerType.Name;

            // sanity check 
            if (string.IsNullOrEmpty(propertyName) || string.IsNullOrEmpty(modelName))
                return;

            foreach (var attribute in context.ValidationMetadata.ValidatorMetadata)
            {
                var tAttr = attribute as ValidationAttribute;
                if (tAttr != null)
                {               
                    // at first, assume the text to be generic error
                    var errorName = tAttr.GetType().Name;
                    var fallbackName = errorName + "_ValidationError";      
                    // Will look for generic widely known resource keys like
                    // MaxLengthAttribute_ValidationError
                    // RangeAttribute_ValidationError
                    // EmailAddressAttribute_ValidationError
                    // RequiredAttribute_ValidationError
                    // etc.

                    // Treat errormessage as resource name, if it's set,
                    // otherwise assume default.
                    var name = tAttr.ErrorMessage ?? fallbackName;

                    // At first, attempt to retrieve model specific text
                    var localized = _stringLocalizer[name];

                    // Some attributes come with texts already preset (breaking the rule 3), 
                    // even if we didn't do that explicitly on the attribute.
                    // For example [EmailAddress] has entire message already filled in by MVC.
                    // Therefore we first check if we could find the value by the given key;
                    // if not, then fall back to default name.

                    // Final attempt - default name from property alone
                    if (localized.ResourceNotFound) // missing key or prefilled text
                        localized = _stringLocalizer[fallbackName];

                    // If not found yet, then give up, leave initially determined name as it is
                    var text = localized.ResourceNotFound ? name : localized;

                    tAttr.ErrorMessage = text;
                }
            }
        }
    }
    public class LocalizableInjectingDisplayNameProvider : IDisplayMetadataProvider
    {
        private IStringLocalizer _stringLocalizer;
        private Type _injectableType;

        public LocalizableInjectingDisplayNameProvider(IStringLocalizer stringLocalizer, Type injectableType)
        {
            _stringLocalizer = stringLocalizer;
            _injectableType = injectableType;
        }

        public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
        {
            // ignore non-properties and types that do not match some model base type
            if (context.Key.ContainerType == null || 
                !_injectableType.IsAssignableFrom(context.Key.ContainerType))
                return;

            // In the code below I assume that expected use of field name will be:
            // 1 - [Display] or Name not set when it is ok to fill with the default translation from the resource file
            // 2 - [Display(Name = x)]set to a specific key in the resources file to override my defaults

            var propertyName = context.Key.Name;
            var modelName = context.Key.ContainerType.Name;

            // sanity check 
            if (string.IsNullOrEmpty(propertyName) || string.IsNullOrEmpty(modelName))
                return;

            var fallbackName = propertyName + "_FieldName";
            // If explicit name is missing, will try to fall back to generic widely known field name,
            // which should exist in resources (such as "Name_FieldName", "Id_FieldName", "Version_FieldName", "DateCreated_FieldName" ...)

            var name = fallbackName;

            // If Display attribute was given, use the last of it
            // to extract the name to use as resource key
            foreach (var attribute in context.PropertyAttributes)
            {
                var tAttr = attribute as DisplayAttribute;
                if (tAttr != null)
                {
                    // Treat Display.Name as resource name, if it's set,
                    // otherwise assume default. 
                    name = tAttr.Name ?? fallbackName;
                }
            }

            // At first, attempt to retrieve model specific text
            var localized = _stringLocalizer[name];

            // Final attempt - default name from property alone
            if (localized.ResourceNotFound)
                localized = _stringLocalizer[fallbackName];

            // If not found yet, then give up, leave initially determined name as it is
            var text = localized.ResourceNotFound ? name : localized;

            context.DisplayMetadata.DisplayName = () => text;
        }

    }
    public static class LocalizedModelBindingMessageExtensions
    {
        public static IMvcBuilder AddModelBindingMessagesLocalizer(this IMvcBuilder mvc,
            IServiceCollection services, Type modelBaseType)
        {
            var factory = services.BuildServiceProvider().GetService<IStringLocalizerFactory>();
            var VL = factory.Create(typeof(ValidationMessagesResource));
            var DL = factory.Create(typeof(FieldNamesResource));

            return mvc.AddMvcOptions(o =>
            {
                // for validation error messages
                o.ModelMetadataDetailsProviders.Add(new LocalizableValidationMetadataProvider(VL, modelBaseType));

                // for field names
                o.ModelMetadataDetailsProviders.Add(new LocalizableInjectingDisplayNameProvider(DL, modelBaseType));

                // does not work for JSON models - Json.Net throws its own error messages into ModelState :(
                // ModelBindingMessageProvider is only for FromForm
                // Json works for FromBody and needs a separate format interceptor
                DefaultModelBindingMessageProvider provider = o.ModelBindingMessageProvider;

                provider.SetValueIsInvalidAccessor((v) => VL["FormatHtmlGeneration_ValueIsInvalid", v]);
                provider.SetAttemptedValueIsInvalidAccessor((v, x) => VL["FormatModelState_AttemptedValueIsInvalid", v, x]);
                provider.SetMissingBindRequiredValueAccessor((v) => VL["FormatModelBinding_MissingBindRequiredMember", v]);
                provider.SetMissingKeyOrValueAccessor(() => VL["FormatKeyValuePair_BothKeyAndValueMustBePresent" ]);
                provider.SetMissingRequestBodyRequiredValueAccessor(() => VL["FormatModelBinding_MissingRequestBodyRequiredMember"]);
                provider.SetNonPropertyAttemptedValueIsInvalidAccessor((v) => VL["FormatModelState_NonPropertyAttemptedValueIsInvalid", v]);
                provider.SetNonPropertyUnknownValueIsInvalidAccessor(() => VL["FormatModelState_UnknownValueIsInvalid"]);
                provider.SetUnknownValueIsInvalidAccessor((v) => VL["FormatModelState_NonPropertyUnknownValueIsInvalid", v]);
                provider.SetValueMustNotBeNullAccessor((v) => VL["FormatModelBinding_NullValueNotValid", v]);
                provider.SetValueMustBeANumberAccessor((v) => VL["FormatHtmlGeneration_ValueMustBeNumber", v]);
                provider.SetNonPropertyValueMustBeANumberAccessor(() => VL["FormatHtmlGeneration_NonPropertyValueMustBeNumber"]);
            });
        }
    }

In ConfigureServices in your Startup.cs file:

services.AddMvc( ... )
            .AddModelBindingMessagesLocalizer(services, typeof(IDtoModel));

I have used my custom empty IDtoModel interface here and applied it to all my API models that will need the automatic localization for errors and field names.

Create a folder Resources and put empty classes ValidationMessagesResource and FieldNamesResource inside it. Create ValidationMessagesResource.ab-CD.resx and FieldNamesResource .ab-CD.resx files (replace ab-CD with your desired culture). Fill in the values for the keys you need, e.g. FormatModelBinding_MissingBindRequiredMember, MaxLengthAttribute_ValidationError ...

When launching the API from a browser, make sure to modify accept-languages header to be your culture name, otherwise Core will use it instead of defaults. For API that needs single language only, I prefer to disable culture providers altogether using the following code:

private readonly CultureInfo[] _supportedCultures = new[] {
                            new CultureInfo("ab-CD")
                        };

...
var ci = new CultureInfo("ab-CD");

// can customize decimal separator to match your needs - some customers require to go against culture defaults and, for example, use . instead of , as decimal separator or use different date format
/*
  ci.NumberFormat.NumberDecimalSeparator = ".";
  ci.NumberFormat.CurrencyDecimalSeparator = ".";
*/

_defaultRequestCulture = new RequestCulture(ci, ci);


...

services.Configure<RequestLocalizationOptions>(options =>
            {
                options.DefaultRequestCulture = _defaultRequestCulture;
                options.SupportedCultures = _supportedCultures;
                options.SupportedUICultures = _supportedCultures;
                options.RequestCultureProviders = new List<IRequestCultureProvider>(); // empty list - use default value always
            });


JustAMartin
  • 13,165
  • 18
  • 99
  • 183
  • Its works for only one locale, but once I changed the locale it will dispaly the old values from the last locale. its seem that these display and validation values are cached somewhere during the lifetime off the application, any solution for this issue? – Wajdy Essam Jan 02 '20 at 00:48
  • @WajdyEssam does it happen with all your custom texts or does it happen only with those `provider.SetValueIsInvalidAccessor` ? Also, for multi language support you should skip the last code with `RequestLocalizationOptions` because that essentially disables multi-lang support. – JustAMartin Jan 02 '20 at 08:01
4

unfortunately, it is not that simple to localize all error messages for data attributes in one single place! because there are different types of error messages,

Error messages for standard data attributes:

[Required]
[Range]
[StringLength]
[Compare]
...etc.

Error messages for ModelBinding:

ValueIsInvalid
ValueMustNotBeNull
PropertyValueMustBeANumber
...etc.

and Identity error messages:

DuplicateEmail
DuplicateRoleName
InvalidUserName
PasswordRequiresLower
PasswordRequiresUpper
...etc

each must be configured in the startup file. Additionaly client side validation must be considered as well.

you may check these articles for more details, it contains live demo and sample project on GitHub:

Developing multicultural web application: http://www.ziyad.info/en/articles/10-Developing_Multicultural_Web_Application

Localizing data annotations: http://www.ziyad.info/en/articles/16-Localizing_DataAnnotations

Localizing ModelBinding error messages: http://www.ziyad.info/en/articles/18-Localizing_ModelBinding_Error_Messages

Localizing identity error messages: http://www.ziyad.info/en/articles/20-Localizing_Identity_Error_Messages

and client side validation: http://ziyad.info/en/articles/19-Configuring_Client_Side_Validation

hope it helps :)

LazZiya
  • 5,286
  • 2
  • 24
  • 37
0

    public class RequiredExAttribute : RequiredAttribute
    {
        public override string FormatErrorMessage(string name)
        {
            string Format = GetAFormatStringFromSomewhereAccordingToCurrentCulture();
            return string.Format(Format, name);
        }
    }

    ...

    public class MyModel
    {
       [RequiredEx]
       public string Name { get; set; }
    }

Teo Bebekis
  • 625
  • 8
  • 9