Skip to content
View Article Network

How to Customize Default Error Messages for ASP.NET Core Model Validation

Introduction

ASP.NET Core Model Validation currently only provides English messages. For example, the error message provided by RequiredAttribute on the backend is "The {Column Name} field is required." It can be quite tedious to manually set the message for every "Required" field. Over the years, many developers have asked the ++Microsoft++ development team to provide multi-language packs, but they have consistently deemed it a non-essential feature. However, ++Microsoft++ does provide a way to customize these messages. For detailed instructions, you can refer to this article.

Implementation

The default validation in Model Validation is divided into two parts: ModelBinding validation, which is mainly related to data format, and ValidationMetadata validation, which is related to data content. These two features must be implemented separately.

Creating Resource Files (.resx)

The access modifier for the resource file can be set to "Internal" or "Public," depending on your specific needs.

Property settings are as follows:

NameValue
Build ActionEmbedded Resource
Copy to Output DirectoryDo not copy

The content of ModelBindingMessage is as follows:

NameValue
AttemptedValueIsInvalidThe value {0} is invalid for {1}.
MissingBindRequiredValueA value for the '{0}' property was not provided.
MissingKeyOrValueA value is required.
MissingRequestBodyRequiredValueA non-empty request body is required.
NonPropertyAttemptedValueIsInvalidThe value {0} is invalid.
NonPropertyUnknownValueIsInvalidThe supplied value is invalid.
NonPropertyValueMustBeANumberThe field must be a number.
UnknownValueIsInvalidThe supplied value is invalid for {0}.
ValueIsInvalidThe value {0} is invalid.
ValueMustBeANumberThe value {0} must be a number.
ValueMustNotBeNullThe value {0} must not be null.

The content of ValidationMetadataMessage is as follows:

NameValue
CompareAttribute_MustMatchThe fields {0} and {1} do not match.
CreditCardAttribute_InvalidThe {0} field is not a valid credit card number.
CustomValidationAttribute_ValidationErrorThe data in the {0} field is incorrect.
EmailAddressAttribute_InvalidThe {0} field is not a valid email format.
FileExtensionsAttribute_InvalidThe {0} field only accepts files with the following extensions: {1}.
MaxLengthAttribute_ValidationErrorThe {0} field can have a maximum of {1} characters.
MinLengthAttribute_ValidationErrorThe {0} field must have at least {1} characters.
PhoneAttribute_InvalidThe {0} field is not a valid phone number format.
RangeAttribute_ValidationErrorThe {0} field must be between {1} and {2}.
RegularExpressionAttribute_ValidationErrorThe {0} field does not match the regular expression '{1}'.
RequiredAttribute_ValidationErrorThe {0} field is required.
StringLengthAttribute_ValidationErrorThe {0} field can have a maximum of {1} characters.
StringLengthAttribute_ValidationErrorIncludingMinimumThe {0} field must be between {2} and {1} characters long.
UrlAttribute_InvalidThe {0} field is not a valid HTTP, HTTPS, or FTP URL.

Creating a Custom ValidationMetadataProvider

The purpose is to replace the error messages of ValidationAttribute.

csharp
public class LocalizationValidationMetadataProvider : IValidationMetadataProvider {
    private readonly ResourceManager resourceManager;
    private readonly Type resourceType;

    public LocalizationValidationMetadataProvider(Type type) {
        resourceType = type;
        resourceManager = new ResourceManager(type);
    }

    public void CreateValidationMetadata(ValidationMetadataProviderContext context) {
        foreach (var attribute in context.ValidationMetadata.ValidatorMetadata.OfType<ValidationAttribute>()) {
            if (attribute.ErrorMessageResourceName is null) {
                bool hasErrorMessage = attribute.ErrorMessage != null;

                if (hasErrorMessage) {
                    string? defaultErrorMessage = typeof(ValidationAttribute)
                        .GetField("_defaultErrorMessage", BindingFlags.NonPublic | BindingFlags.Instance)
                        ?.GetValue(attribute) as string;

                    // Some ValidationAttribute ErrorMessages are not null by default
                    hasErrorMessage = attribute.ErrorMessage != defaultErrorMessage;
                }

                if (hasErrorMessage) {
                    continue;
                }

                string? name = GetMessageName(attribute);
                if (name != null && resourceManager.GetString(name) != null) {
                    attribute.ErrorMessageResourceType = resourceType;
                    attribute.ErrorMessageResourceName = name;
                    attribute.ErrorMessage = null;
                }
            }
        }
    }

    private string? GetMessageName(ValidationAttribute attr) {
        switch (attr) {
            case CompareAttribute _:
                return "CompareAttribute_MustMatch";
            case StringLengthAttribute vAttr:
                if (vAttr.MinimumLength > 0) {
                    return "StringLengthAttribute_ValidationErrorIncludingMinimum";
                }
                return "StringLengthAttribute_ValidationError";
            case DataTypeAttribute _:
                return $"{attr.GetType().Name}_Invalid";
            case ValidationAttribute _:
                return $"{attr.GetType().Name}_ValidationError";
        }

        return null;
    }
}

Program.cs

csharp
builder.Services.AddRazorPages()
    .AddMvcOptions(options => {
        // Set ModelBinding error messages from resource files
        var provider = options.ModelBindingMessageProvider;
        provider.SetAttemptedValueIsInvalidAccessor((x, y) => string.Format(ModelBindingMessage.AttemptedValueIsInvalid, x, y));
        provider.SetMissingBindRequiredValueAccessor(x => string.Format(ModelBindingMessage.MissingBindRequiredValue, x));
        provider.SetMissingKeyOrValueAccessor(() => ModelBindingMessage.MissingKeyOrValue);
        provider.SetMissingRequestBodyRequiredValueAccessor(() => ModelBindingMessage.MissingRequestBodyRequiredValue);
        provider.SetNonPropertyAttemptedValueIsInvalidAccessor(x => string.Format(ModelBindingMessage.NonPropertyAttemptedValueIsInvalid, x));
        provider.SetNonPropertyUnknownValueIsInvalidAccessor(() => ModelBindingMessage.NonPropertyUnknownValueIsInvalid);
        provider.SetNonPropertyValueMustBeANumberAccessor(() => ModelBindingMessage.NonPropertyValueMustBeANumber);
        provider.SetUnknownValueIsInvalidAccessor(x => string.Format(ModelBindingMessage.UnknownValueIsInvalid, x));
        provider.SetValueIsInvalidAccessor(x => string.Format(ModelBindingMessage.ValueIsInvalid, x));
        provider.SetValueMustBeANumberAccessor(x => string.Format(ModelBindingMessage.NonPropertyValueMustBeANumber, x));
        provider.SetValueMustNotBeNullAccessor(x => string.Format(ModelBindingMessage.ValueMustNotBeNull, x));

        // Set ValidationMetadata error messages from resource files
        options.ModelMetadataDetailsProviders.Add(new LocalizationValidationMetadataProvider(typeof(ValidationMetadataMessage)));
    });

Multi-language Support

Actually, you don't need resource files to customize Model Validation error messages. The advantage of using resource files is that if you want to support multiple languages, you can extend the handling to other language-specific resource files. Since explaining multi-language support would require a long article, and I haven't implemented it myself, I will only extend the content related to multi-language support from the main topic here.

Creating Multi-language Resource Files (.resx)

  1. Create "ModelBindingMessage.{Culture}.resx" and "ValidationMetadataMessage.{Culture}.resx".
  2. Set the access modifier of the resource file to "==No code generation==".
  3. Property settings are as follows (same as the default resource file):
NameValue
Build ActionEmbedded Resource
Copy to Output DirectoryDo not copy

The code generated from the "default resource file" will read the "default resource file" and the "language-specific resource file" based on the culture, so setting the language-specific resource file to "==No code generation==" is sufficient.

Configuring the Use of Language Files

Program.cs

csharp
WebApplication app = builder.Build();

// List the cultures for which you have set up language files
string[] supportedCultures = new string[] { "zh-TW", "en-US" }
RequestLocalizationOptions localizationOptions = new RequestLocalizationOptions()
    .SetDefaultCulture(supportedCultures[0])
    .AddSupportedCultures(supportedCultures)
    .AddSupportedUICultures(supportedCultures); // This is what actually takes effect

// Set localization settings
// If not set, it will choose the culture based on Thread.CurrentThread.CurrentUICulture
app.UseRequestLocalization(localizationOptions);

INFO

Common misconceptions about Culture:

  1. RequestLocalizationOptions.DefaultRequestCulture Not Working RequestLocalizationOptions has a member called RequestCultureProviders, which defaults to the following three providers:

    1. QueryStringRequestCultureProvider.
    2. CookieRequestCultureProvider.
    3. AcceptLanguageHeaderRequestCultureProvider.

    DefaultRequestCulture can be understood as the provider with the lowest priority. Providers attempt to find the UICulture in order. Once found, they stop searching and use the found UICulture to determine if it is in the SupportedUICultures list. If it is, it then checks if the "language-specific resource file" exists. If it exists, it uses the "language-specific resource file." If the UICulture is not in the SupportedUICultures list or the "language-specific resource file" does not exist, the "default resource file" is used.

  2. Setting the wrong Culture property. In C#, any property related to Culture comes in two types:

    1. Culture: Used to determine the culture-specific date, numeric, currency formats, comparison, and sorting.
    2. UICulture: Used to determine which language resource file to load.

    Some people often don't notice the difference between the two when using them. However, most people copying online examples set both, or the API itself is designed to set both when using an API with only one parameter. For example, SetDefaultCulture() requires two parameters to set them separately, so it's hard to make a mistake. Regarding QueryStringRequestCultureProvider, some online introductions mention that the URL parameter is "culture={culture}", but it should actually be "ui-culture={culture}".

Change Log

  • 2022-10-05 Initial document creation.
  • 2024-04-04 Fixed messages in ModelBindingMessage.