Localization of RequiredAttribute in ASP.NET Core 2.0

前端 未结 4 601
一生所求
一生所求 2021-01-02 03:37

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

  • DataAccess project with Models and DataAnnotations (e.g. RequiredAttribute)
相关标签:
4条回答
  • 2021-01-02 04:10

    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}");
    
    0 讨论(0)
  • 2021-01-02 04:10

    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 :)

    0 讨论(0)
  • 2021-01-02 04:12

    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
                });
    
    
    
    0 讨论(0)
  • 2021-01-02 04:36

    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.

    0 讨论(0)
提交回复
热议问题