Add localization support to Microsoft.Extensions.Validation#66646
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a localization extensibility point to Microsoft.Extensions.Validation (used by Minimal APIs validation and Blazor DataAnnotations validation) and adds an optional companion package (Microsoft.Extensions.Validation.Localization) that provides an IStringLocalizer-backed default implementation.
Changes:
- Added
ValidationOptions.Localizer+ new localization context types and helpers to enable localized display names and error messages in the core validation pipeline. - Introduced a new
Microsoft.Extensions.Validation.Localizationpackage with DI registration (AddValidationLocalization*), a defaultIStringLocalizerimplementation, and attribute-specific formatter support. - Updated the validations source generator and snapshots to support resource-based
[Display(..., ResourceType=...)]display names via runtime accessors.
Reviewed changes
Copilot reviewed 86 out of 86 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| AspNetCore.slnx | Adds the new localization projects to the repo solution structure. |
| eng/ProjectReferences.props | Registers Microsoft.Extensions.Validation.Localization as a project reference provider. |
| eng/SharedFramework.Local.props | Adds Microsoft.Extensions.Validation.Localization to shared framework reference/package list. |
| eng/ShippingAssemblies.props | Marks Microsoft.Extensions.Validation.Localization as a shipping assembly. |
| eng/TrimmableProjects.props | Adds the new localization assembly to the trimmable projects list. |
| src/Components/Components.slnf | Includes the new localization projects in the Components solution filter. |
| src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs | Adds a TODO note for future Blazor per-field validation localization integration. |
| src/Http/Routing/src/ValidationEndpointFilterFactory.cs | Adjusts minimal API validation context initialization to rely on parameter validation resolving (localized) display names. |
| src/Shared/RoslynUtils/WellKnownTypeData.cs | Adds System.ComponentModel.DisplayNameAttribute as a well-known type for generator analysis. |
| src/Validation/Localization/src/BuiltInFormatters.cs | Adds built-in formatters for attributes whose templates use placeholders beyond {0}. |
| src/Validation/Localization/src/DefaultValidationLocalizer.cs | Implements the default IStringLocalizer-based IValidationLocalizer. |
| src/Validation/Localization/src/IValidationAttributeFormatter.cs | Introduces the formatter interface used to format localized templates with attribute-specific arguments. |
| src/Validation/Localization/src/Microsoft.Extensions.Validation.Localization.csproj | Defines the new localization package project and references. |
| src/Validation/Localization/src/PublicAPI.Shipped.txt | Initializes shipped API baseline for the new package. |
| src/Validation/Localization/src/PublicAPI.Unshipped.txt | Declares the new public API surface for the localization package. |
| src/Validation/Localization/src/ValidationAttributeFormatterRegistry.cs | Adds a registry for mapping attributes to formatter factories (with built-in registrations). |
| src/Validation/Localization/src/ValidationLocalizationOptions.cs | Adds configurable options for localizer selection, key selection, and formatter registration. |
| src/Validation/Localization/src/ValidationLocalizationServiceCollectionExtensions.cs | Adds AddValidationLocalization() / AddValidationLocalization<TResource>() DI registration entrypoints. |
| src/Validation/Localization/src/ValidationLocalizationSetup.cs | Bridges localization options into ValidationOptions.Localizer via IConfigureOptions<ValidationOptions>. |
| src/Validation/Localization/test/BuiltInFormattersTests.cs | Tests built-in formatter behavior for standard DataAnnotations attributes. |
| src/Validation/Localization/test/Microsoft.Extensions.Validation.Localization.Tests.csproj | Adds the new test project for the localization package. |
| src/Validation/Localization/test/TestStringLocalizerFactory.cs | Provides test localizer factory/localizer implementations for localization tests. |
| src/Validation/Localization/test/ValidationAttributeFormatterRegistryTests.cs | Tests formatter registry precedence, overrides, and null-guard behavior. |
| src/Validation/Localization/test/ValidationLocalizationServiceCollectionExtensionsTests.cs | Tests DI wiring, ordering, idempotency, and shared-resource behavior. |
| src/Validation/Validation.slnf | Includes the new localization projects in the Validation solution filter. |
| src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs | Updates generated metadata emission for nullable display names and resource-based display-name accessors. |
| src/Validation/gen/Extensions/ISymbolExtensions.cs | Adds symbol inspection for [Display] / [DisplayName], including resource-based display detection. |
| src/Validation/gen/Models/ValidatableProperty.cs | Extends generator model to carry nullable display name and resource-display flag. |
| src/Validation/gen/Models/ValidatableType.cs | Extends generator model to carry type display name and resource-display flag. |
| src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs | Extracts type/member display info (including record primary-ctor parameter precedence) for generation. |
| src/Validation/src/DisplayNameLocalizationContext.cs | Adds the public context type for display-name localization. |
| src/Validation/src/ErrorMessageLocalizationContext.cs | Adds the public context type for error-message localization. |
| src/Validation/src/IValidationLocalizer.cs | Adds the public IValidationLocalizer extensibility interface. |
| src/Validation/src/LocalizationHelper.cs | Adds internal helpers to resolve display names and attribute error messages via resource accessors/localizer. |
| src/Validation/src/PublicAPI.Unshipped.txt | Updates public API baseline for new types + constructor signature changes. |
| src/Validation/src/RuntimeValidatableParameterInfoResolver.cs | Adds runtime parameter display info extraction supporting [Display] resource-path and [DisplayName]. |
| src/Validation/src/ValidatableParameterInfo.cs | Extends metadata to carry nullable display name + optional resource accessor; updates pipeline integration. |
| src/Validation/src/ValidatablePropertyInfo.cs | Extends metadata to carry nullable display name + optional resource accessor; updates pipeline integration. |
| src/Validation/src/ValidatableTypeInfo.cs | Extends metadata to carry display info; integrates localization into type-level validation and IValidatableObject path. |
| src/Validation/src/ValidationOptions.cs | Adds ValidationOptions.Localizer configuration hook and documentation. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs | New snapshot coverage for display-name emission (name-only). |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs | New snapshot coverage for resource-based display-name emission (ResourceType). |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanDiscoverGeneratedValidatableTypeAttribute#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanUseBothFrameworkAndGeneratedValidatableTypeAttributes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateClassTypesWithAttribute#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateMultipleNamespaces#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordStructTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypesWithAttribute#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypeWithParsableProperties#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateValidationAttributesOnClasses#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsClassesWithNonAccessibleTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsIndexerPropertiesOnTypes#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsNonReadableAndStaticProperties#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs | Updates snapshot for new constructor signatures and display metadata emission. |
| src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj | Adds localization references used by new/updated validation tests. |
| src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs | Updates tests to reflect nullable display-name metadata for parameters. |
| src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableInfoResolverTests.cs | Updates tests for the experimental constructor signature change. |
414bc2a to
6b2aeaf
Compare
6b2aeaf to
6c19448
Compare
6c19448 to
aa640cb
Compare
| var validationContext = new ValidationContext(argument, entry.DisplayName, context.HttpContext.RequestServices, items: null); | ||
| // ValidationContext.DisplayName is overwritten by ValidatableParameterInfo.ValidateAsync | ||
| // once the localized display name is resolved; the parameter name acts as a placeholder. | ||
| var validationContext = new ValidationContext(argument, entry.Name, context.HttpContext.RequestServices, items: null); |
There was a problem hiding this comment.
This ValidationContext is passed to IValidatableInfo.ValidateAsync (experimental public API).
But it seems like we are changing the meaning of "displayName" (initially) so that it's just the actual parameter name (to be overwritten later). Are we risking any user implementations to be accessing ValidateContext.ValidationContext.DisplayName at a point when it's not (yet) the correct display name?
There was a problem hiding this comment.
The re-setting of ValidateContext.ValidationContext.DisplayName was always happening (in ValidableParemeterInfo and the other entrypoints), even before this PR. The code I am removing here is practically no-op in the shipped code.
The workflow is such that the user's validation code (in validation attributes etc.) does not run before ValidableParemeterInfo.ValidateAsync sets the proper (possibly localized) display name on the ValidationContext instance.
The only way someone could see a different value is when someone would return custom ValidableParemeterInfo implementation with overriden ValidateAsync - and even there they would have a meaningful value (= the member name). The contract here is "if you are doing something so low level as to implement your own validation pipeline, you are responsible for localization yourself". This is, I think, cleaner then doing a partial pre-localization for such advanced user.
Finally, the practical reason why we pass any display name value here at all, is that this is the only constructor of ValidationContext that is not annotated with trimming warnings - the other ones cause warnings because the built-in validation logic might need to go through reflection path to get a display name. To avoid that we just need to pass some value here, and the parameter name is the one that makes the most sense.
| { | ||
| var parameter = global::System.Linq.Enumerable.FirstOrDefault( | ||
| constructor.GetParameters(), | ||
| p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase)); |
There was a problem hiding this comment.
I know it's not a realistic scenario, but for correctness, wouldn't this fail in cases like:
record R(string s, [Display(...)] string S);If we request to validate property S on the record, the first parameter will match because of the case insensitive comparison and we won't consider the DisplayAttribute right?
That makes me leaning more towards not trying to "infer" attributes via primary constructors and instead ask users to use [property: Display(...)] instead.
There was a problem hiding this comment.
Yes, the name comparison should be case sensitive. I made it case sensitive in the new display attribute lookup.
However, it should be noted that the already existing lookup for the validation attributes (in the generated ValidationAttributeCache) was shipped as case-insensitive. That is also a bug which I noticed in the past. I did not want to make that (albeit small) behavioral change in this unrelated PR. But we should fix it at some point.
| return _typeCache.GetOrAdd(type, static t => | ||
| global::System.Reflection.CustomAttributeExtensions | ||
| .GetCustomAttribute<global::System.ComponentModel.DataAnnotations.DisplayAttribute>(t, inherit: true)); |
There was a problem hiding this comment.
I thought reflection internally maintains some sort of cache already?
Maybe worth to have some micro benchmark so we know better the perf impact here.
There was a problem hiding this comment.
I agree benchmarking would be good. However, this is replicating the existing pattern for validation attributes. More importantly, it is all file private generated code so we can safely remove it later, if we determine that the caching is not worth it at this level.
| /// for a given declaring type. The declaring type is the type that contains the property | ||
| /// being validated. |
There was a problem hiding this comment.
The validation isn't necessarily for a property right?
In case validation of a type, this is the type itself, I think?
For parameters, if I'm following the code correctly, the DefaultValidationLocalizer will receive null and transform it to typeof(object) which also doesn't sound reasonable to me?
In Mvc, DataAnnotationLocalizerProvider is almost always passed context.ModelMetadata.ContainerType ?? context.ModelMetadata.ModelType which (if Mvc validates parameters), I assume will produce the type that contains the method that contains the parameter being validate.
In addition, in Mvc, DataAnnotationsMetadataProvider.CreateDisplayMetadata unwraps value types via Nullable.GetUnderlyingType(context.Key.ModelType) ?? context.Key.ModelType. I'm not sure if we need similar behavior here as well.
In addition, I would like us to have more clarity on the exact definition of the Type passed to this delegate in case of inheritance.
// some validation attributes on the model itself.
public class MyBaseModel
{
// some validatable properties.
}
// some validation attributes on the model itself.
public class Derived : MyBaseModel
{
// more validatable properties.
}When executing validation for an object of type Derived but we are validating a property from the base class, which Type do we expect to have in this case. I also would like us to know the Mvc behavior and document differences when possible. For sure we will just keep Mvc behavior the same and we can decide to deviate (if needed) when we think it's reasonable to deviate.
| /// <c>options.LocalizerProvider = (_, factory) => factory.Create(typeof(SharedValidationMessages));</c> | ||
| /// </para> | ||
| /// </remarks> | ||
| public Func<Type, IStringLocalizerFactory, IStringLocalizer>? LocalizerProvider { get; set; } |
There was a problem hiding this comment.
nit: Mvc naming for the equivalent property is DataAnnotationLocalizerProvider. I'm not sure if we will want to have similar naming, or if the LocalizerProvider here is actually intended to be more generic and not specific to data annotations.
There was a problem hiding this comment.
I'd defer this discussion to the API review. I don't mind changing the name, although I would be against putting DataAnnotation into the name. We do not refer to DataAnnotation in the naming used in the core M.E.Validation package even though that is arguably much more tied to the DataAnnotation types than the localization integration.
| /// that cost across calls. | ||
| /// </para> | ||
| /// <para> | ||
| /// <b>Minimal API parameter validation note:</b> for top-level method parameters there is no |
There was a problem hiding this comment.
Continuing to read, this part partially answers a previous question I had.
I think my next question is why do we want to pass a dummy typeof(object) instead of passing null and annotating this as nullable (i.e, Func<Type?, IStringLocalizerFactory, IStringLocalizer>?).
Also, still question for whether Mvc has any equivalent scenario and how does it behave today.
There was a problem hiding this comment.
Based on your feedback I changed the signature to Func<Type?, IStringLocalizerFactory, IStringLocalizer>? to allow users to select what to do with "missing" type more cleanly (e.g. have a fallback type). The code path that when LocalizerProvider is not set and IStringLocalizerFactory.Create is used directly still falls back to using typeof(object) because that API requires non-null type. Throwing instead does not seem correct to me.
MVC used the non-nullable signature for its DataAnnotationLocalizerProvider. However, due to the typical structure of an MVC app, you would always have a concrete declaring/container type for the localization resources to map from (a view, a controller). In fact, the IStringLocalizerFactory API is designed with such structure in mind - which can make using it in different context a bit awkward.
Youssef1313
left a comment
There was a problem hiding this comment.
LGTM - but prefer to merge upon a second approval.
javiercn
left a comment
There was a problem hiding this comment.
Overall looks good. That said, I want to point out the excessive amount of tests that this PR adds (mostly AI generated). It makes it incredibly difficult and time consuming for anyone to review the behavior and the tests because so much time is wasted on reviewing the same code being tested across multiple layers of the stack.
In the future take care of following the principle of the testing pyramid and minimizing the overlap across tests. If something is already well covered at a higher level, it doesn't have to be covered at lower levels. Things like service container registration and similar things are not normally aspects that we test unless we are looking for something specific as they get already coverage through E2E tests and other mechanisms.
| return; | ||
| } | ||
|
|
||
| if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo)) |
There was a problem hiding this comment.
We should consider some level of localization support without Microsoft.Extensions.Validation here.
There was a problem hiding this comment.
You already get static resource localization if you use ValidationAttribute.ErrorMessageResourceType/DisplayAttribute.ResourceType.
For us to support more, we would need to write our own validation logic using the ValidationAttribute API directly, rather than rely on the static Validator.TryValidate* methods. And if we would do that, we would be arguably in a better spot with completely replacing both Microsoft.Extensions.Validation and the static Validator.
| services.AddValidation(); | ||
| services.AddValidation(options => | ||
| { | ||
| options.Localizer = new TestValidationLocalizer(); |
There was a problem hiding this comment.
It's weird that in an E2E test we aren't testing with the real thing
There was a problem hiding this comment.
I replaced it with standard AddValidationLocalization call - i.e. the full pipeline is exercised in the tests. I just used in-memory IStringLocalizer implementation in order to avoid issues with resx files.
| }); | ||
|
|
There was a problem hiding this comment.
Same here. We should wire up the real localization pipeline
There was a problem hiding this comment.
I replaced this with standard UseRequestLocalization call
| /// <param name="messageTemplate">The error message template containing format placeholders.</param> | ||
| /// <param name="displayName">The resolved display name of the member being validated.</param> | ||
| /// <returns>The fully formatted error message.</returns> | ||
| string FormatErrorMessage(CultureInfo culture, string messageTemplate, string displayName); |
There was a problem hiding this comment.
Can we instead use FormattableString here? FormattableStringFactory already gives you the ability to create one string with the arguments inside it.
There was a problem hiding this comment.
All the built-in formatters then just disappear.
There was a problem hiding this comment.
I am not sure I understand the use of FormattableStringFactory that you refer to.
The localized template comes from IStringLocalizer as a string, not a compile-time interpolated literal, so FormattableStringFactory.Create would still need a per-attribute argument projection ({ MinimumLength, MaximumLength } for LengthAttribute etc.). This is what the built-in formatters encapsulate. The API of the interface is modelled after the existing ValidationAttribute.FormatErrorMessage - just with the added template as parameter.
Are you proposing instead of having "give me formatted string for this attribute" API to have a "give me object[] with args for this attribute" API? (We discussed these alternatives during one of our offline sessions.) I am not against taking that approach - I just wouldn't say that the formatters just disappear then, a very similiar piece of code is still needed.
| /// <param name="configureOptions">An optional callback to configure | ||
| /// <see cref="ValidationLocalizationOptions"/>.</param> | ||
| /// <returns>The <see cref="IServiceCollection"/> for chaining.</returns> | ||
| public static IServiceCollection AddValidationLocalization( |
There was a problem hiding this comment.
I raised this, but we shouldn't have this on serviceCollection. If we need to update AddValidation() to return a builder, we should
| [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type, | ||
| IReadOnlyList<ValidatablePropertyInfo> members) | ||
| IReadOnlyList<ValidatablePropertyInfo> members, | ||
| DisplayNameInfo? displayNameInfo = null) |
There was a problem hiding this comment.
One of the values of having DisplayNameInfo is that we can always provide the value for it, can we not? Even for the implementation without DisplayName we can provide an implementation that returns the property name.
| private interface IAuditable | ||
| { | ||
| DateTime CreatedAt { get; } | ||
| } | ||
|
|
||
| private class AuditableThing : IAuditable | ||
| { | ||
| public DateTime CreatedAt { get; set; } | ||
| public string Name { get; set; } = string.Empty; | ||
| } | ||
|
|
There was a problem hiding this comment.
What does the interface matter for. Don't we simply look at the type hierarchy and ignore interfaces? Do we special case interfaces in any way? (If so, we need to capture the behavior for implicit vs explicit implementations as well as usages of new if apply here)
There was a problem hiding this comment.
M.E.V. actually looks up both implemented interfaces and the base type chain for each type it creates IValidatableTypeInfo for.
See
…Localizer and request localization
…dling Resolves the conflict introduced by upstream commit c472800 ("Add localization support to Microsoft.Extensions.Validation" (dotnet#66646)), which lands the day before this merge and rewrites the same call sites in `ValidationsGenerator.TypesParser.cs` that this PR modifies, in addition to changing the generator emitter format and the `ValidatableType`/`ValidatableProperty` model constructors. Conflict resolution in `ValidationsGenerator.TypesParser.cs`: * `ITypeParameterSymbol` branch (line 83-95) auto-merges cleanly with the new `displayAttributeSymbol`/`displayNameAttributeSymbol` lookups added at line 105-106, because the two insertions sit on opposite sides of the `DeclaredAccessibility` guard with enough unchanged context between them. * Record primary-constructor property path: `ContainsTypeParameter` early-`continue` guard is placed before the new `GetDisplayInfo` calls. The order matters: a property whose type is an unresolved type parameter (e.g. `TSelf` from `RequestBase<TSelf>`) is skipped immediately rather than computing literal/resource display info that would be discarded. * Regular property path: same ordering — guard before `GetDisplayInfo`. * `ContainsTypeParameter` helper at the end of the class is untouched by the merge. Snapshot regeneration: The emitter template in commit c472800 replaces the `displayName: "..."` constructor argument with `displayNameInfo: <DisplayNameInfo>` and emits four new file-scoped helper classes (`LiteralDisplayName`, `PropertyResourceDisplayName`, `TypeResourceDisplayName`, `DisplayAttributeCache`) into every generated resolver. The two snapshots owned by this PR (`CanValidateOpenTypeParameterReachableThroughConstraint` and `CanValidateTypesWithGenericBaseClass`) are therefore regenerated from the new emitter rather than hand-merged. The bug witness in `CanValidateOpenTypeParameterReachableThroughConstraint` is preserved: the resolver still contains `typeof(global::UserRequest)` and emits the `Name`/`Age` member entries that the constraint walk discovers — without the `ITypeParameterSymbol` branch the resolver body is empty for that type. Verification: `dotnet test src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests` reports 45/45 passed locally on Debug net11.0 against the merged tree (the 15 new `DisplayName*` tests added by dotnet#66646 plus the 28 pre-existing generator tests plus the two type-parameter tests added by this PR). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Fixes #66955 |
Description
This PR adds localization support for validation error messages and display names to
Microsoft.Extensions.Validationand transitively to the consumers of the package - in particular, Minimal APIs (endpoint filter) and Blazor (DataAnnotationsValidator). It introduces a single extensibility point onValidationOptionsand ships a defaultIStringLocalizer-based implementation in a separate optional package.Closes #12158
Design doc: #65539
Motivation
System.ComponentModel.DataAnnotationsonly supports localization throughErrorMessageResourceType/ErrorMessageResourceNameandDisplayAttribute.ResourceType- verbose, recompile-bound, and tied to static resource properties. ASP.NET Core MVC works around this with attribute adapters, but the adapter system is MVC-specific and does not extend to Minimal APIs, Blazor SSR, or non-web hosts.Microsoft.Extensions.Validation(introduced in .NET 10) is the new validation infrastructure for Minimal APIs and Blazor. Until now it had no localization story beyond what the BCL provides. This PR closes that gap with a single extensibility point that any framework integration picks up automatically.How it works
flowchart LR subgraph Val["Microsoft.Extensions.Validation"] VO["<b>ValidationOptions</b><br/>Localizer property"] VPI["<b>Validatable*Info</b><br/>(Type / Property / Parameter)"] DNI["<b>DisplayNameInfo</b><br/>display-name strategy"] IVL["<b>IValidationLocalizer</b><br/>extensibility point"] VO -. provides .-> IVL VPI --> DNI DNI --> IVL end subgraph ValLoc["Microsoft.Extensions.Validation.Localization (optional)"] AVL["<b>AddValidationLocalization()</b>"] DVL["<b>DefaultValidationLocalizer</b>"] Reg["<b>ValidationAttributeFormatterRegistry</b><br/>+ built-in formatters"] AVL --> DVL DVL --> Reg end subgraph Loc["Microsoft.Extensions.Localization"] ISL["<b>IStringLocalizer</b><br/>via IStringLocalizerFactory"] end DVL --> ISL AVL -.->|"options.Localizer ??= ..."| VO DVL -. implements .-> IVLMicrosoft.Extensions.Validation) gets one new property -ValidationOptions.Localizerof typeIValidationLocalizer?. When set, the validation pipeline calls into it to resolve display names and error messages. Whennull(the default), behavior is unchanged.Microsoft.Extensions.Validation.Localization) providesAddValidationLocalization(). It registers anIConfigureOptions<ValidationOptions>bridge that uses??=to setValidationOptions.Localizerto a defaultIStringLocalizer-backed implementation. Explicit user assignment always wins.DisplayNameInfosubclass per validated member that knows how to produce its display name. For literal[Display(Name = "...")]and[DisplayName("...")]the emitted strategy carries the literal value and runs it throughIValidationLocalizer.ResolveDisplayNamewhen a localizer is configured. For[Display(Name = ..., ResourceType = ...)]the emitted strategy callsDisplayAttribute.GetName()and bypasses the localizer (the resource lookup is the canonical localized name; double-localizing throughIValidationLocalizerwould be incorrect). See [API Proposal] Improve static display name resolution in Microsoft.Extensions.Validation #66378 for theDisplayNameInfoAPI.{0}for the display name) participate viaIValidationAttributeFormatter- either by implementing the interface on the attribute itself, or by registering a factory inValidationLocalizationOptions.AttributeFormatters.sequenceDiagram participant Pipeline as Validation pipeline participant DNI as DisplayNameInfo participant Localizer as IValidationLocalizer participant Formatter as IValidationAttributeFormatter Note over Pipeline: Per-attribute validation<br/>produces a ValidationResult Pipeline->>DNI: GetDisplayName(context, memberName, declaringType) Note over DNI: Literal strategy may delegate to localizer.<br/>Resource strategy bypasses it. DNI-->>Pipeline: display name<br/>(or null → fall back to member name) Pipeline->>Localizer: ResolveErrorMessage(context) Note over Localizer: bypassed if attribute has<br/>ErrorMessageResourceType Localizer->>Formatter: FormatErrorMessage(culture, template, displayName) Formatter-->>Localizer: fully formatted message Localizer-->>Pipeline: localized error message<br/>(or null → use attribute default)New public API surface
Core:
Microsoft.Extensions.ValidationNew package:
Microsoft.Extensions.Validation.LocalizationConstructor signature changes on
Validatable*InfoValidatablePropertyInfo,ValidatableParameterInfo, andValidatableTypeInfo(all[Experimental("ASP0029")]) get an optionalDisplayNameInfo?parameter that carries the per-member display-name resolution strategy. The previousstring displayNameparameter is removed; the validation pipeline falls back to the CLR member name when no strategy is supplied. Full API shape and rationale: #66378.ValidatablePropertyInfo(Type declaringType, Type propertyType, string name, string displayName)ValidatablePropertyInfo(Type declaringType, Type propertyType, string name, DisplayNameInfo? displayNameInfo = null)ValidatableParameterInfo(Type parameterType, string name, string displayName)ValidatableParameterInfo(Type parameterType, string name, DisplayNameInfo? displayNameInfo = null)ValidatableTypeInfo(Type type, IReadOnlyList<...> members)ValidatableTypeInfo(Type type, IReadOnlyList<...> members, DisplayNameInfo? displayNameInfo = null)Code that consumes the source generator is unaffected (the generator emits the new constructor calls and the corresponding
DisplayNameInfostrategies). Code that manually subclasses these abstractions (rare) needs to passnullfordisplayNameInfo, or supply a custom strategy.Built-in attribute formatters
Registered automatically by
AddValidationLocalization(). Cover all standardDataAnnotationsattributes whose default templates use placeholders beyond{0}:RangeAttributeMinLengthAttributeMaxLengthAttributeLengthAttributeStringLengthAttributeRegularExpressionAttributeFileExtensionsAttributeCompareAttributeAttributes whose template only uses
{0}(e.g.RequiredAttribute,EmailAddressAttribute) need no formatter - the localization pipeline substitutes the display name directly.Usage
Default - per-type resource files
IStringLocalizerFactory.Create(declaringType)is called for every validated member. Resource files follow the standard convention (Resources/Models.Customer.fr.resxfor typeModels.Customer).Shared resource file (recommended for Minimal APIs)
The generic overload pre-configures
LocalizerProviderto always resolve againsttypeof(SharedValidationMessages), with a closure-cached singletonIStringLocalizerso the factory is invoked only once per app lifetime.Convention-based key selection
When
ErrorMessageis not set on the attribute, the delegate produces a fallback lookup key (e.g."RequiredAttribute_Error"). This makes it possible to translate the BCL's built-in messages without settingErrorMessageon every attribute instance.Custom validation attributes
Two ways to participate. Option 1 - self-formatting:
Option 2 - register a formatter (when you don't own the attribute):
Custom localization backend (no
IStringLocalizer)Implement
IValidationLocalizerdirectly:For DI-resolved dependencies, use
PostConfigure<ValidationOptions>.Decorator pattern (logging, fallback, composition)
Wrap an existing localizer to add cross-cutting behavior:
Key design decisions
Two packages, not one.
Microsoft.Extensions.Validationdoes not depend onMicrosoft.Extensions.Localization. Apps that don't enable localization pay nothing extra; theIConfigureOptions<ValidationOptions>bridge in the localization package is the only point of contact. This keeps the core package small and self-contained, and lets the localization package evolve independently.Single
IValidationLocalizerinterface as the extensibility point. The validation pipeline sees one abstraction with two methods (ResolveDisplayName,ResolveErrorMessage). Custom implementations are plain DI services that receive their dependencies via constructor injection. The interface form is discoverable, cleanly composable via the decorator pattern, and lets framework integrations (Minimal APIs, Blazor, future hosts) treat localization as a single unit.Microsoft.Extensions.Validation.Localizationis one of possible implementations. The defaultIStringLocalizer-based implementation lives behindAddValidationLocalization()in the localization package, but the pipeline does not require it. Users with an existing translation backend can implementIValidationLocalizerdirectly and assign it toValidationOptions.Localizer; the extensibility shape does not assumeIStringLocalizer.Resource-attribute paths bypass the localizer to avoid double-localization. When a member is decorated with
[Display(Name = ..., ResourceType = ...)], the source generator emits aDisplayNameInfostrategy that produces the already-localized display name viaDisplayAttribute.GetName();IValidationLocalizer.ResolveDisplayNameis not invoked on that path. The same applies to error messages - when the attribute carriesErrorMessageResourceType,ResolveErrorMessageis not invoked. Running an already-localized string throughIStringLocalizerwould treat it as a lookup key and silently break.IValidatableObject.Validateresults are not localized. Messages fromIValidatableObjectpass through unmodified - they are expected to be in the user's language already (typically constructed inline using DI-resolved services fromValidationContext). An opt-in pass-through can be added later if a real demand emerges.Framework integrations
Minimal APIs. The validation endpoint filter calls into the pipeline directly, so
ValidationOptions.Localizeris picked up automatically onceAddValidationLocalization()(or a customIValidationLocalizer) is configured. Because top-level Minimal API parameters have no declaring type, the validation pipeline passestypeof(object)toIStringLocalizerFactory.Createunder the default per-type lookup - this resolves to theobject-source resource file, which almost no application has. The recommended pattern for Minimal APIs isAddValidationLocalization<TSharedResource>()(or setLocalizerProviderexplicitly). This caveat is called out in the XML docs of bothAddValidationLocalization()andValidationLocalizationOptions.LocalizerProvider.Blazor
DataAnnotationsValidator. When the application callsAddValidation()and the validated model has anIValidatableInforegistered (typically via the source generator),DataAnnotationsValidatorroutes validation through theMicrosoft.Extensions.Validationpipeline and so picks upValidationOptions.Localizerautomatically - no additional Blazor-side wiring is needed. Forms whose models are validated via the legacyValidator.TryValidateObjectpath (i.e. withoutAddValidation()) do not benefit fromIValidationLocalizer; in that mode the existing BCL-only localization mechanisms apply.Testing
162 tests across three projects:
Microsoft.Extensions.Validation.Tests(existing tests, unchanged behavior + 11 new pipeline-integration tests with a recording localizer test double, includingDisplayNameInfo-throws propagation).Microsoft.Extensions.Validation.Localization.Testsproject covering:DefaultValidationLocalizercontract (literal lookup hit/miss, key provider fallback, range attribute formatting, custom attribute formatter,LocalizerProviderinvocation, per-type isolation, shared-resource pattern,LocalizerProvider-returns-null defensive throw)ValidationAttributeFormatterRegistry(built-ins registered, custom registration, override semantics, self-formatting precedence, null guards)<TResource>overload single-instance optimization, user-suppliedLocalizeralways winsIStringLocalizerfactory through the fullValidatableTypeInfopipeline;Required,Range, parameter-level shared resource, type-level attribute, per-invocation override,StringLengthtwo-template behavior, lookup miss fallback,ErrorMessageResourceTypebypass, key-provider convention.Microsoft.Extensions.Validation.GeneratorTests(existing 28 + 14 newDisplayNamesnapshot tests covering property/parameter/record/type-level xNameOnly/WithResourceType/WithDisplayNameAttribute/WithoutDisplayAttribute, plus precedence betweenDisplayandDisplayName).All snapshots are committed and match the current emitter output.
Breaking change in experimental API
ValidatableTypeInfo,ValidatablePropertyInfo, andValidatableParameterInfoare decorated with[Experimental("ASP0029")]. This PR changes their constructor signatures by removing thestring displayNameparameter and adding an optionalDisplayNameInfo? displayNameInfo = nullparameter in its place.Diff
protected ValidatablePropertyInfo( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType, Type propertyType, string name, - string displayName); + DisplayNameInfo? displayNameInfo = null); protected ValidatableParameterInfo( Type parameterType, string name, - string displayName); + DisplayNameInfo? displayNameInfo = null); protected ValidatableTypeInfo( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type, - IReadOnlyList<ValidatablePropertyInfo> members); + IReadOnlyList<ValidatablePropertyInfo> members, + DisplayNameInfo? displayNameInfo = null);The
PublicAPI.Unshipped.txtbaseline tracks this with*REMOVED*entries paired with the new constructor signatures, plus the new public abstractDisplayNameInfoclass.Reasoning
Hardcoding a literal
displayNameat metadata-construction time cannot represent the resource-based scenario ([Display(Name = X, ResourceType = T)]), which needs a runtime call toDisplayAttribute.GetName()or to a generated accessor. Modelling display-name resolution as a separate strategy object (DisplayNameInfo) lets each metadata instance carry the appropriate lookup policy - literal, resource-based, or custom - without exposing that branching to the metadata classes themselves, and lets the literal-vs-resource localization decision live in a single place (the strategy implementation). The parameter is optional and defaults tonull, so callers that don't need a custom display name can ignore it - the validation pipeline falls back to the CLR member or type name.Who is affected
DisplayNameInfostrategies.string displayNameargument with aDisplayNameInfo?(or passnull). Mostly affects test doubles. Open-source code search shows we are the only implementors of the abstract types.