Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 55 additions & 17 deletions src/Components/Endpoints/src/Forms/DataAnnotationsLocalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,38 +54,76 @@ public string ResolveDisplayName(in ClientValidationFieldMetadata metadata, bool
return attribute.FormatErrorMessage(displayName);
}

var lookupKey = GetErrorMessageKey(attribute, memberName, type);

if (string.IsNullOrEmpty(lookupKey))
{
return attribute.FormatErrorMessage(displayName);
}

var localizer = GetStringLocalizer(type, localizerFactory);
var localizedTemplate = localizer[lookupKey];
var localizedTemplate = FindLocalizedTemplate(localizer, attribute, memberName, type);

if (localizedTemplate.ResourceNotFound)
if (localizedTemplate is null)
{
return attribute.FormatErrorMessage(displayName);
}

// Format the localized template with attribute-specific arguments
return FormatMessage(attribute, CultureInfo.CurrentCulture, localizedTemplate.Value, displayName);
return FormatMessage(attribute, CultureInfo.CurrentCulture, localizedTemplate, displayName);
}

private string? GetErrorMessageKey(ValidationAttribute attribute, string memberName, Type type)
// Resolves the localized message template for a validation attribute.
//
// An explicit ErrorMessage is used verbatim as the lookup key. Otherwise the built-in key
// convention is applied, walking from the most specific key to the least specific one:
//
// {DeclaringType}_{MemberName}_{AttributeType}_Error
// {DeclaringType}_{AttributeType}_Error
// {AttributeType}_Error
//
// Returns null when no key resolves, in which case the caller falls back to the
// non-localized message produced by the attribute itself.
//
// Keep in sync with the generated LocalizationHelpers.FindLocalizedTemplate in
// src/Validation/gen/Templates/LocalizationHelpers.cs.
private static string? FindLocalizedTemplate(
IStringLocalizer localizer,
ValidationAttribute attribute,
string memberName,
Type declaringType)
{
if (!string.IsNullOrEmpty(attribute.ErrorMessage))
{
return attribute.ErrorMessage;
var explicitMatch = localizer[attribute.ErrorMessage];

return explicitMatch.ResourceNotFound ? null : explicitMatch.Value;
}

return options.MessageKeyProvider?.Invoke(new ValidationMessageKeyContext
var attributeName = attribute.GetType().Name;
var typeName = GetKeySegment(declaringType);

// The member-specific tier is skipped for type-level attributes that report no member
// name, where the caller passes the declaring type name as the member name.
if (!string.Equals(memberName, declaringType.Name, StringComparison.Ordinal))
{
ValidatorType = attribute.GetType(),
MemberName = memberName,
DeclaringType = type,
});
var memberMatch = localizer[$"{typeName}_{memberName}_{attributeName}_Error"];
if (!memberMatch.ResourceNotFound)
{
return memberMatch.Value;
}
}

var typeMatch = localizer[$"{typeName}_{attributeName}_Error"];
if (!typeMatch.ResourceNotFound)
{
return typeMatch.Value;
}

var globalMatch = localizer[$"{attributeName}_Error"];

return globalMatch.ResourceNotFound ? null : globalMatch.Value;
}

private static string GetKeySegment(Type type)
{
var name = type.Name;
var arityIndex = name.IndexOf('`');

return arityIndex < 0 ? name : name[..arityIndex];
}

private IStringLocalizer GetStringLocalizer(Type type, IStringLocalizerFactory localizerFactory)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,9 @@ public void Localizer_LocalizesDisplayNameAndErrorMessage_OnMevPath()
var translations = new Dictionary<string, string>
{
["Custom Label"] = "Étiquette",
["req-key"] = "{0} est requis.",
["LocalizedFieldModel_Field_RequiredAttribute_Error"] = "{0} est requis.",
};
var options = CreateMevOptions(typeof(LocalizedFieldModel));
#pragma warning disable ASP0029 // Microsoft.Extensions.Validation evaluation APIs.
options.MessageKeyProvider = _ => "req-key";
#pragma warning restore ASP0029
var factory = new TestStringLocalizerFactory(translations);

var rule = SingleRule(
Expand All @@ -198,10 +195,10 @@ public void Localizer_DoesNotLocalize_OnStaticValidatorPath()
var translations = new Dictionary<string, string>
{
["Custom Label"] = "Étiquette",
["req-key"] = "{0} est requis.",
["LocalizedFieldModel_Field_RequiredAttribute_Error"] = "{0} est requis.",
};
#pragma warning disable ASP0029 // Microsoft.Extensions.Validation evaluation APIs.
var options = new ValidationOptions { MessageKeyProvider = _ => "req-key" };
var options = new ValidationOptions();
#pragma warning restore ASP0029
var factory = new TestStringLocalizerFactory(translations);

Expand All @@ -218,38 +215,29 @@ public void Localizer_ResolvesFromDeclaringType_ForInheritedProperty()
// The validated property is declared on the base type but the form model is the derived type.
// Server-side validation resolves the localizer, message key, and display name from the
// *declaring* type, so the client payload must do the same rather than use the derived
// (runtime container) type. The factory only knows translations for the base type, so a
// localized result proves the declaring type flowed through.
// (runtime container) type. The factory only knows translations for the base type, and the
// conventional key itself is built from the base type name, so a localized result proves the
// declaring type flowed through both the localizer lookup and the key convention.
var byType = new Dictionary<Type, IDictionary<string, string>>
{
[typeof(InheritedFieldBaseModel)] = new Dictionary<string, string>
{
["Base Label"] = "Étiquette",
["req-key"] = "{0} est requis.",
["InheritedFieldBaseModel_Field_RequiredAttribute_Error"] = "{0} est requis.",
},
// The derived type has no translations; if it were (incorrectly) used, both the display
// name and the message template would fall back to their non-localized values.
[typeof(DerivedFieldModel)] = new Dictionary<string, string>(),
};
var factory = new TypeAwareStringLocalizerFactory(byType);

Type? messageKeyDeclaringType = null;
var options = CreateMevOptions(typeof(DerivedFieldModel));
#pragma warning disable ASP0029 // Microsoft.Extensions.Validation evaluation APIs.
options.MessageKeyProvider = context =>
{
messageKeyDeclaringType = context.DeclaringType;
return "req-key";
};
#pragma warning restore ASP0029

var rule = SingleRule(
GetMevData<DerivedFieldModel>(options, factory, (nameof(DerivedFieldModel.Field), "Model." + nameof(DerivedFieldModel.Field)))!,
"Model." + nameof(DerivedFieldModel.Field));

// The declaring (base) type is used for the message key context...
Assert.Equal(typeof(InheritedFieldBaseModel), messageKeyDeclaringType);
// ...and for both the display-name and error-message localizer lookups.
// The declaring (base) type is used for both the display-name and error-message lookups.
Assert.Equal("Étiquette est requis.", rule.Message);
}

Expand Down
44 changes: 44 additions & 0 deletions src/Validation/gen/Templates/LocalizationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,48 @@ file static class LocalizationHelpers
=> context.ValidationOptions.LocalizerProvider(type, factory)
?? throw new global::System.InvalidOperationException(
$"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance.");

public static string? FindLocalizedTemplate(
global::Microsoft.Extensions.Localization.IStringLocalizer localizer,
global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute,
string memberName,
global::System.Type declaringType)
{
if (!string.IsNullOrEmpty(attribute.ErrorMessage))
{
var explicitMatch = localizer[attribute.ErrorMessage!];

return explicitMatch.ResourceNotFound ? null : explicitMatch.Value;
}

var attributeName = attribute.GetType().Name;
var typeName = GetKeySegment(declaringType);

if (!string.Equals(memberName, declaringType.Name, global::System.StringComparison.Ordinal))
{
var memberMatch = localizer[$"{typeName}_{memberName}_{attributeName}_Error"];
if (!memberMatch.ResourceNotFound)
{
return memberMatch.Value;
}
}

var typeMatch = localizer[$"{typeName}_{attributeName}_Error"];
if (!typeMatch.ResourceNotFound)
{
return typeMatch.Value;
}

var globalMatch = localizer[$"{attributeName}_Error"];

return globalMatch.ResourceNotFound ? null : globalMatch.Value;
}

private static string GetKeySegment(global::System.Type type)
{
var name = type.Name;
var arityIndex = name.IndexOf('`');

return arityIndex < 0 ? name : name.Substring(0, arityIndex);
}
}
20 changes: 3 additions & 17 deletions src/Validation/gen/Templates/ValidatableInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,29 +85,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo
return result.ErrorMessage;
}

var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage)
? attribute.ErrorMessage
: context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext
{
ValidatorType = attribute.GetType(),
MemberName = memberName,
DeclaringType = declaringType,
});

if (string.IsNullOrEmpty(lookupKey))
{
return result.ErrorMessage;
}

var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory);

var localizedTemplate = localizer[lookupKey!];
if (localizedTemplate.ResourceNotFound)
var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType);
if (localizedTemplate is null)
{
return result.ErrorMessage;
}

return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName);
return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName);
}

// Keep in sync with DataAnnotationsLocalizer.FormatMessage in
Expand Down
10 changes: 0 additions & 10 deletions src/Validation/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,8 @@ Microsoft.Extensions.Validation.ValidateContext.ServiceProvider.get -> System.IS
Microsoft.Extensions.Validation.ValidateContext.ServiceProvider.init -> void
Microsoft.Extensions.Validation.ValidateContext.ValidationErrors.get -> System.Collections.Generic.IReadOnlyDictionary<string!, System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.Validation.ValidationError!>!>?
Microsoft.Extensions.Validation.ValidateContext.ValidationOptions.init -> void
Microsoft.Extensions.Validation.ValidationMessageKeyContext
Microsoft.Extensions.Validation.ValidationMessageKeyContext.DeclaringType.get -> System.Type!
Microsoft.Extensions.Validation.ValidationMessageKeyContext.DeclaringType.init -> void
Microsoft.Extensions.Validation.ValidationMessageKeyContext.MemberName.get -> string!
Microsoft.Extensions.Validation.ValidationMessageKeyContext.MemberName.init -> void
Microsoft.Extensions.Validation.ValidationMessageKeyContext.ValidationMessageKeyContext() -> void
Microsoft.Extensions.Validation.ValidationMessageKeyContext.ValidatorType.get -> System.Type!
Microsoft.Extensions.Validation.ValidationMessageKeyContext.ValidatorType.init -> void
Microsoft.Extensions.Validation.ValidationOptions.LocalizerProvider.get -> System.Func<System.Type!, Microsoft.Extensions.Localization.IStringLocalizerFactory!, Microsoft.Extensions.Localization.IStringLocalizer!>!
Microsoft.Extensions.Validation.ValidationOptions.LocalizerProvider.set -> void
Microsoft.Extensions.Validation.ValidationOptions.MessageKeyProvider.get -> System.Func<Microsoft.Extensions.Validation.ValidationMessageKeyContext!, string?>?
Microsoft.Extensions.Validation.ValidationOptions.MessageKeyProvider.set -> void
Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.Extensions.Validation.IValidatableParameterInfo? validatableInfo) -> bool
Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.Extensions.Validation.IValidatableTypeInfo? validatableTypeInfo) -> bool
*REMOVED*abstract Microsoft.Extensions.Validation.ValidatableParameterInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
Expand Down
34 changes: 0 additions & 34 deletions src/Validation/src/ValidationMessageKeyContext.cs

This file was deleted.

12 changes: 0 additions & 12 deletions src/Validation/src/ValidationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,6 @@ public class ValidationOptions
public Func<Type, IStringLocalizerFactory, IStringLocalizer> LocalizerProvider { get; set; }
= (type, factory) => factory.Create(type);

/// <summary>
/// Gets or sets a delegate that computes the resource key used to look up a localized validation
/// message.
/// </summary>
/// <remarks>
/// The provider supplies the lookup key by convention (for example, keyed by
/// <see cref="ValidationMessageKeyContext.ValidatorType"/>) for validators that do not specify an
/// explicit message. When a validator specifies an explicit message, that message is used as the
/// lookup key and the provider is not consulted.
/// </remarks>
public Func<ValidationMessageKeyContext, string?>? MessageKeyProvider { get; set; }

/// <summary>
/// Attempts to get validation information for the specified type.
/// </summary>
Expand Down
Loading
Loading