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:
| Name | Value |
|---|---|
| Build Action | Embedded Resource |
| Copy to Output Directory | Do not copy |
The content of ModelBindingMessage is as follows:
| Name | Value |
|---|---|
| AttemptedValueIsInvalid | The value {0} is invalid for {1}. |
| MissingBindRequiredValue | A value for the '{0}' property was not provided. |
| MissingKeyOrValue | A value is required. |
| MissingRequestBodyRequiredValue | A non-empty request body is required. |
| NonPropertyAttemptedValueIsInvalid | The value {0} is invalid. |
| NonPropertyUnknownValueIsInvalid | The supplied value is invalid. |
| NonPropertyValueMustBeANumber | The field must be a number. |
| UnknownValueIsInvalid | The supplied value is invalid for {0}. |
| ValueIsInvalid | The value {0} is invalid. |
| ValueMustBeANumber | The value {0} must be a number. |
| ValueMustNotBeNull | The value {0} must not be null. |
The content of ValidationMetadataMessage is as follows:
| Name | Value |
|---|---|
| CompareAttribute_MustMatch | The fields {0} and {1} do not match. |
| CreditCardAttribute_Invalid | The {0} field is not a valid credit card number. |
| CustomValidationAttribute_ValidationError | The data in the {0} field is incorrect. |
| EmailAddressAttribute_Invalid | The {0} field is not a valid email format. |
| FileExtensionsAttribute_Invalid | The {0} field only accepts files with the following extensions: {1}. |
| MaxLengthAttribute_ValidationError | The {0} field can have a maximum of {1} characters. |
| MinLengthAttribute_ValidationError | The {0} field must have at least {1} characters. |
| PhoneAttribute_Invalid | The {0} field is not a valid phone number format. |
| RangeAttribute_ValidationError | The {0} field must be between {1} and {2}. |
| RegularExpressionAttribute_ValidationError | The {0} field does not match the regular expression '{1}'. |
| RequiredAttribute_ValidationError | The {0} field is required. |
| StringLengthAttribute_ValidationError | The {0} field can have a maximum of {1} characters. |
| StringLengthAttribute_ValidationErrorIncludingMinimum | The {0} field must be between {2} and {1} characters long. |
| UrlAttribute_Invalid | The {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.
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
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)
- Create "ModelBindingMessage.{Culture}.resx" and "ValidationMetadataMessage.{Culture}.resx".
- Set the access modifier of the resource file to "==No code generation==".
- Property settings are as follows (same as the default resource file):
| Name | Value |
|---|---|
| Build Action | Embedded Resource |
| Copy to Output Directory | Do 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
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:
RequestLocalizationOptions.DefaultRequestCulture Not Working
RequestLocalizationOptionshas a member calledRequestCultureProviders, which defaults to the following three providers:QueryStringRequestCultureProvider.CookieRequestCultureProvider.AcceptLanguageHeaderRequestCultureProvider.
DefaultRequestCulturecan be understood as the provider with the lowest priority. Providers attempt to find theUICulturein order. Once found, they stop searching and use the foundUICultureto determine if it is in theSupportedUICultureslist. If it is, it then checks if the "language-specific resource file" exists. If it exists, it uses the "language-specific resource file." If theUICultureis not in theSupportedUICultureslist or the "language-specific resource file" does not exist, the "default resource file" is used.Setting the wrong Culture property. In C#, any property related to Culture comes in two types:
Culture: Used to determine the culture-specific date, numeric, currency formats, comparison, and sorting.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. RegardingQueryStringRequestCultureProvider, 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.