How to Customize Default Error Messages for ASP.NET Core Model Validation
Introduction
ASP.NET Core Model Validation currently provides messages in English only. For example, the RequiredAttribute provides the error message "The {Column Name} field is required." on the backend. Manually setting the message for every "Required" field can be tedious. Over the years, many developers have requested that the ++Microsoft++ development team provide multi-language packs, but they have consistently maintained that it is not a necessary feature. However, ++Microsoft++ does provide a way to customize these messages. For detailed instructions, you can refer to this article.
Implementation
Default validation in Model Validation is divided into two parts: ModelBinding validation, which is primarily related to data formats, and ValidationMetadata validation, which is related to data content. These two features must be implemented separately.
Create Resource Files (.resx)
The access modifier for the resource file can be set to "Internal" or "Public," depending on your actual requirements.
The property settings are as follows:
| Name | Value |
|---|---|
| Build Action | Embedded Resource |
| Copy to Output Directory | Do not copy |
The ModelBindingMessage content 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 ValidationMetadataMessage content 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 invalid. |
| 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 length of {1}. |
| MinLengthAttribute_ValidationError | The {0} field must have a minimum length of {1}. |
| 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 length of {1}. |
| StringLengthAttribute_ValidationErrorIncludingMinimum | The {0} field length must be between {2} and {1}. |
| UrlAttribute_Invalid | The {0} field is not a valid HTTP, HTTPS, or FTP URL. |
Create 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 expand the handling to include resource files for other languages. 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.
Create 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==".
- Set the properties 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 "Culture Resource File" based on the culture, so setting the culture resource file to "==No code generation==" is sufficient.
Configure Culture Settings
Program.cs
WebApplication app = builder.Build();
// List the cultures for which you have set up resource 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, the culture will be selected 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 "Culture Resource File" exists. If it exists, the "Culture Resource File" is used. If theUICultureis not in theSupportedUICultureslist or the "Culture 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 date, numeric, currency formats, comparison, and sorting of the culture.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 examples from the internet set both together, 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 isculture={culture}, but it should correctly beui-culture={culture}.
Change Log
- 2022-10-05 Initial document created.
- 2024-04-04 Fixed messages in
ModelBindingMessage.
