diff --git a/src/Validation/src/ValidatablePropertyInfo.cs b/src/Validation/src/ValidatablePropertyInfo.cs index 94d7eb606fa3..c95d124e2eb1 100644 --- a/src/Validation/src/ValidatablePropertyInfo.cs +++ b/src/Validation/src/ValidatablePropertyInfo.cs @@ -3,6 +3,9 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Microsoft.Extensions.Validation; @@ -13,12 +16,13 @@ namespace Microsoft.Extensions.Validation; public abstract class ValidatablePropertyInfo : IValidatableInfo { private RequiredAttribute? _requiredAttribute; + private readonly bool _hasDisplayAttribute; /// /// Creates a new instance of . /// protected ValidatablePropertyInfo( - [param: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + [param: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)] Type declaringType, Type propertyType, string name, @@ -28,12 +32,18 @@ protected ValidatablePropertyInfo( PropertyType = propertyType; Name = name; DisplayName = displayName; + + // Cache the HasDisplayAttribute result to avoid repeated reflection calls + // We only check for the existence of the DisplayAttribute here and not the + // Name value itself since we rely on the source generator populating it + var property = DeclaringType.GetProperty(Name); + _hasDisplayAttribute = property is not null && HasDisplayAttribute(property); } /// /// Gets the member type. /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)] internal Type DeclaringType { get; } /// @@ -65,18 +75,24 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, var validationAttributes = GetValidationAttributes(); // Calculate and save the current path + var namingPolicy = context.SerializerOptions?.PropertyNamingPolicy; + var memberName = GetJsonPropertyName(Name, property, namingPolicy); var originalPrefix = context.CurrentValidationPath; if (string.IsNullOrEmpty(originalPrefix)) { - context.CurrentValidationPath = Name; + context.CurrentValidationPath = memberName; } else { - context.CurrentValidationPath = $"{originalPrefix}.{Name}"; + context.CurrentValidationPath = $"{originalPrefix}.{memberName}"; } - context.ValidationContext.DisplayName = DisplayName; - context.ValidationContext.MemberName = Name; + // Format the display name and member name according to JsonPropertyName attribute first, then naming policy + // If the property has a [Display] attribute (either on property or record parameter), use DisplayName directly without formatting + context.ValidationContext.DisplayName = _hasDisplayAttribute + ? DisplayName + : GetJsonPropertyName(DisplayName, property, namingPolicy); + context.ValidationContext.MemberName = memberName; // Check required attribute first if (_requiredAttribute is not null || validationAttributes.TryGetRequiredAttribute(out _requiredAttribute)) @@ -172,4 +188,61 @@ void ValidateValue(object? val, string name, string errorPrefix, ValidationAttri } } } + + /// + /// Gets the effective member name for JSON serialization, considering and naming policy. + /// + /// The target value to get the name for. + /// The property info to get the name for. + /// The JSON naming policy to apply if no is present. + /// The effective property name for JSON serialization. + private static string GetJsonPropertyName(string targetValue, PropertyInfo property, JsonNamingPolicy? namingPolicy) + { + var jsonPropertyName = property.GetCustomAttribute()?.Name; + + if (jsonPropertyName is not null) + { + return jsonPropertyName; + } + + if (namingPolicy is not null) + { + return namingPolicy.ConvertName(targetValue); + } + + return targetValue; + } + + /// + /// Determines whether the property has a , either directly on the property + /// or on the corresponding constructor parameter if the declaring type is a record. + /// + /// The property to check. + /// True if the property has a , false otherwise. + private bool HasDisplayAttribute(PropertyInfo property) + { + // Check if the property itself has the DisplayAttribute with a valid Name + if (property.GetCustomAttribute() is { Name: not null }) + { + return true; + } + + // Look for a constructor parameter matching the property name (case-insensitive) + // to account for the record scenario + foreach (var constructor in DeclaringType.GetConstructors()) + { + foreach (var parameter in constructor.GetParameters()) + { + if (string.Equals(parameter.Name, property.Name, StringComparison.OrdinalIgnoreCase)) + { + if (parameter.GetCustomAttribute() is { Name: not null }) + { + return true; + } + } + } + } + + return false; + } } diff --git a/src/Validation/src/ValidatableTypeInfo.cs b/src/Validation/src/ValidatableTypeInfo.cs index 8852f674a7e0..38d63f8ef20d 100644 --- a/src/Validation/src/ValidatableTypeInfo.cs +++ b/src/Validation/src/ValidatableTypeInfo.cs @@ -106,9 +106,13 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, // Create a validation error for each member name that is provided foreach (var memberName in validationResult.MemberNames) { + // Format the member name using JsonSerializerOptions naming policy if available + // Note: we don't respect [JsonPropertyName] here because we have no context of the property being validated. + var formattedMemberName = context.SerializerOptions?.PropertyNamingPolicy?.ConvertName(memberName) ?? memberName; + var key = string.IsNullOrEmpty(originalPrefix) ? - memberName : - $"{originalPrefix}.{memberName}"; + formattedMemberName : + $"{originalPrefix}.{formattedMemberName}"; context.AddOrExtendValidationError(memberName, key, validationResult.ErrorMessage, value); } diff --git a/src/Validation/src/ValidateContext.cs b/src/Validation/src/ValidateContext.cs index 5cce279c4be9..185edd51b7f9 100644 --- a/src/Validation/src/ValidateContext.cs +++ b/src/Validation/src/ValidateContext.cs @@ -3,6 +3,8 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Validation; @@ -60,6 +62,52 @@ public sealed class ValidateContext /// public int CurrentDepth { get; set; } + private JsonSerializerOptions? _cachedSerializerOptions; + private bool _serializerOptionsResolved; + + internal JsonSerializerOptions? SerializerOptions + { + get + { + if (_serializerOptionsResolved) + { + return _cachedSerializerOptions; + } + + _cachedSerializerOptions = ResolveSerializerOptions(); + _serializerOptionsResolved = true; + return _cachedSerializerOptions; + } + } + + /// + /// Attempts to resolve the used for serialization + /// using reflection to access JsonOptions from the ASP.NET Core shared framework. + /// + private JsonSerializerOptions? ResolveSerializerOptions() + { + var targetType = "Microsoft.AspNetCore.Http.Json.JsonOptions, Microsoft.AspNetCore.Http.Extensions"; + var jsonOptionsType = Type.GetType(targetType, throwOnError: false); + if (jsonOptionsType is null) + { + return null; + } + + var iOptionsType = typeof(IOptions<>).MakeGenericType(jsonOptionsType); + + var optionsObj = ValidationContext.GetService(iOptionsType); + if (optionsObj is null) + { + return null; + } + + var valueProp = iOptionsType.GetProperty("Value")!; + var jsonOptions = valueProp.GetValue(optionsObj); + var serializerProp = jsonOptionsType.GetProperty("SerializerOptions")!; + + return serializerProp.GetValue(jsonOptions) as JsonSerializerOptions; + } + /// /// Optional event raised when a validation error is reported. /// @@ -68,7 +116,6 @@ public sealed class ValidateContext internal void AddValidationError(string propertyName, string key, string[] error, object? container) { ValidationErrors ??= []; - ValidationErrors[key] = error; OnValidationError?.Invoke(new ValidationErrorContext { @@ -110,7 +157,7 @@ internal void AddOrExtendValidationError(string name, string key, string error, if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error)) { - ValidationErrors[key] = [.. existingErrors, error]; + ValidationErrors[key] = [..existingErrors, error]; } else { diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs index 7bdf0bea29b2..08d98d7f3464 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs @@ -126,8 +126,8 @@ async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithRange", kvp.Key); - Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single()); + Assert.Equal("integerWithRange", kvp.Key); + Assert.Equal("The field integerWithRange must be between 10 and 100.", kvp.Value.Single()); }); } @@ -145,7 +145,7 @@ async Task InvalidIntegerWithRangeAndDisplayNameProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithRangeAndDisplayName", kvp.Key); + Assert.Equal("integerWithRangeAndDisplayName", kvp.Key); Assert.Equal("The field Valid identifier must be between 10 and 100.", kvp.Value.Single()); }); } @@ -164,8 +164,8 @@ async Task MissingRequiredSubtypePropertyProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("PropertyWithMemberAttributes", kvp.Key); - Assert.Equal("The PropertyWithMemberAttributes field is required.", kvp.Value.Single()); + Assert.Equal("propertyWithMemberAttributes", kvp.Key); + Assert.Equal("The propertyWithMemberAttributes field is required.", kvp.Value.Single()); }); } @@ -187,13 +187,13 @@ async Task InvalidRequiredSubtypePropertyProducesError(Endpoint endpoint) Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("PropertyWithMemberAttributes.RequiredProperty", kvp.Key); - Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + Assert.Equal("propertyWithMemberAttributes.requiredProperty", kvp.Key); + Assert.Equal("The requiredProperty field is required.", kvp.Value.Single()); }, kvp => { - Assert.Equal("PropertyWithMemberAttributes.StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + Assert.Equal("propertyWithMemberAttributes.stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); }); } @@ -216,18 +216,18 @@ async Task InvalidSubTypeWithInheritancePropertyProducesError(Endpoint endpoint) Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("PropertyWithInheritance.EmailString", kvp.Key); - Assert.Equal("The EmailString field is not a valid e-mail address.", kvp.Value.Single()); + Assert.Equal("propertyWithInheritance.emailString", kvp.Key); + Assert.Equal("The emailString field is not a valid e-mail address.", kvp.Value.Single()); }, kvp => { - Assert.Equal("PropertyWithInheritance.RequiredProperty", kvp.Key); - Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + Assert.Equal("propertyWithInheritance.requiredProperty", kvp.Key); + Assert.Equal("The requiredProperty field is required.", kvp.Value.Single()); }, kvp => { - Assert.Equal("PropertyWithInheritance.StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + Assert.Equal("propertyWithInheritance.stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); }); } @@ -259,18 +259,18 @@ async Task InvalidListOfSubTypesProducesError(Endpoint endpoint) Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("ListOfSubTypes[0].RequiredProperty", kvp.Key); - Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + Assert.Equal("listOfSubTypes[0].requiredProperty", kvp.Key); + Assert.Equal("The requiredProperty field is required.", kvp.Value.Single()); }, kvp => { - Assert.Equal("ListOfSubTypes[0].StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + Assert.Equal("listOfSubTypes[0].stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); }, kvp => { - Assert.Equal("ListOfSubTypes[1].StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + Assert.Equal("listOfSubTypes[1].stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); }); } @@ -288,7 +288,7 @@ async Task InvalidPropertyWithDerivedValidationAttributeProducesError(Endpoint e var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithDerivedValidationAttribute", kvp.Key); + Assert.Equal("integerWithDerivedValidationAttribute", kvp.Key); Assert.Equal("Value must be an even number", kvp.Value.Single()); }); } @@ -297,7 +297,7 @@ async Task InvalidPropertyWithMultipleAttributesProducesError(Endpoint endpoint) { var payload = """ { - "PropertyWithMultipleAttributes": 5 + "propertyWithMultipleAttributes": 5 } """; var context = CreateHttpContextWithPayload(payload, serviceProvider); @@ -307,15 +307,15 @@ async Task InvalidPropertyWithMultipleAttributesProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("PropertyWithMultipleAttributes", kvp.Key); + Assert.Equal("propertyWithMultipleAttributes", kvp.Key); Assert.Collection(kvp.Value, error => { - Assert.Equal("The field PropertyWithMultipleAttributes is invalid.", error); + Assert.Equal("The field propertyWithMultipleAttributes is invalid.", error); }, error => { - Assert.Equal("The field PropertyWithMultipleAttributes must be between 10 and 100.", error); + Assert.Equal("The field propertyWithMultipleAttributes must be between 10 and 100.", error); }); }); } @@ -335,7 +335,7 @@ async Task InvalidPropertyWithCustomValidationProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithCustomValidation", kvp.Key); + Assert.Equal("integerWithCustomValidation", kvp.Key); var error = Assert.Single(kvp.Value); Assert.Equal("Can't use the same number value in two properties on the same class.", error); }); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs index 590195468298..3c0c1330df70 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs @@ -128,23 +128,23 @@ async Task ValidateMethodCalledIfPropertyValidationsFail() Assert.Collection(problemDetails.Errors, error => { - Assert.Equal("Value2", error.Key); + Assert.Equal("value2", error.Key); Assert.Collection(error.Value, - msg => Assert.Equal("The Value2 field is required.", msg)); + msg => Assert.Equal("The value2 field is required.", msg)); }, error => { - Assert.Equal("SubType.RequiredProperty", error.Key); - Assert.Equal("The RequiredProperty field is required.", error.Value.Single()); + Assert.Equal("subType.requiredProperty", error.Key); + Assert.Equal("The requiredProperty field is required.", error.Value.Single()); }, error => { - Assert.Equal("SubType.Value3", error.Key); + Assert.Equal("subType.value3", error.Key); Assert.Equal("The field ValidatableSubType must be 'some-value'.", error.Value.Single()); }, error => { - Assert.Equal("Value1", error.Key); + Assert.Equal("value1", error.Key); Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single()); }); } @@ -169,12 +169,12 @@ async Task ValidateForSubtypeInvokedFirst() Assert.Collection(problemDetails.Errors, error => { - Assert.Equal("SubType.Value3", error.Key); + Assert.Equal("subType.value3", error.Key); Assert.Equal("The field ValidatableSubType must be 'some-value'.", error.Value.Single()); }, error => { - Assert.Equal("Value1", error.Key); + Assert.Equal("value1", error.Key); Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single()); }); } @@ -199,7 +199,7 @@ async Task ValidateForTopLevelInvoked() Assert.Collection(problemDetails.Errors, error => { - Assert.Equal("Value1", error.Key); + Assert.Equal("value1", error.Key); Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single()); }); } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.MultipleNamespaces.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.MultipleNamespaces.cs index e08f8855a4a3..9d8180e6f08b 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.MultipleNamespaces.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.MultipleNamespaces.cs @@ -69,8 +69,8 @@ async Task InvalidStringWithLengthProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + Assert.Equal("stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); }); } @@ -106,8 +106,8 @@ async Task InvalidStringWithLengthProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 20.", kvp.Value.Single()); + Assert.Equal("stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 20.", kvp.Value.Single()); }); } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.NoOp.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.NoOp.cs index ba176e622450..9780353d5aa5 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.NoOp.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.NoOp.cs @@ -173,8 +173,8 @@ await VerifyEndpoint(compilation, "/complex-type", async (endpoint, serviceProvi var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithRange", kvp.Key); - Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single()); + Assert.Equal("integerWithRange", kvp.Key); + Assert.Equal("The field integerWithRange must be between 10 and 100.", kvp.Value.Single()); }); }); } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Parsable.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Parsable.cs index 6ea212ebaa16..a7263344f7c2 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Parsable.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Parsable.cs @@ -88,32 +88,32 @@ await VerifyEndpoint(compilation, "/complex-type-with-parsable-properties", asyn Assert.Collection(problemDetails.Errors.OrderBy(kvp => kvp.Key), error => { - Assert.Equal("DateOnlyWithRange", error.Key); + Assert.Equal("dateOnlyWithRange", error.Key); Assert.Contains("Date must be between 2023-01-01 and 2025-12-31", error.Value); }, error => { - Assert.Equal("DecimalWithRange", error.Key); + Assert.Equal("decimalWithRange", error.Key); Assert.Contains("Amount must be between 0.1 and 100.5", error.Value); }, error => { - Assert.Equal("TimeOnlyWithRequiredValue", error.Key); - Assert.Contains("The TimeOnlyWithRequiredValue field is required.", error.Value); + Assert.Equal("timeOnlyWithRequiredValue", error.Key); + Assert.Contains("The timeOnlyWithRequiredValue field is required.", error.Value); }, error => { - Assert.Equal("TimeSpanWithHourRange", error.Key); + Assert.Equal("timeSpanWithHourRange", error.Key); Assert.Contains("Hours must be between 0 and 12", error.Value); }, error => { - Assert.Equal("Url", error.Key); + Assert.Equal("url", error.Key); Assert.Contains("The field Url must be a valid URL.", error.Value); }, error => { - Assert.Equal("VersionWithRegex", error.Key); + Assert.Equal("versionWithRegex", error.Key); Assert.Contains("Must be a valid version number (e.g. 1.0.0)", error.Value); } ); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Polymorphism.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Polymorphism.cs index 39dbec97a78c..f72645dc7d6c 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Polymorphism.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Polymorphism.cs @@ -96,18 +96,18 @@ await VerifyEndpoint(compilation, "/basic-polymorphism", async (endpoint, servic Assert.Collection(problemDetails.Errors, error => { - Assert.Equal("Value3", error.Key); - Assert.Equal("The Value3 field is not a valid Base64 encoding.", error.Value.Single()); + Assert.Equal("value3", error.Key); + Assert.Equal("The value3 field is not a valid Base64 encoding.", error.Value.Single()); }, error => { - Assert.Equal("Value1", error.Key); + Assert.Equal("value1", error.Key); Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Value2", error.Key); - Assert.Equal("The Value2 field is not a valid e-mail address.", error.Value.Single()); + Assert.Equal("value2", error.Key); + Assert.Equal("The value2 field is not a valid e-mail address.", error.Value.Single()); }); }); @@ -127,12 +127,12 @@ await VerifyEndpoint(compilation, "/validatable-polymorphism", async (endpoint, Assert.Collection(problemDetails.Errors, error => { - Assert.Equal("Value3", error.Key); - Assert.Equal("The Value3 field is not a valid e-mail address.", error.Value.Single()); + Assert.Equal("value3", error.Key); + Assert.Equal("The value3 field is not a valid e-mail address.", error.Value.Single()); }, error => { - Assert.Equal("Value1", error.Key); + Assert.Equal("value1", error.Key); Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single()); }); @@ -150,7 +150,7 @@ await VerifyEndpoint(compilation, "/validatable-polymorphism", async (endpoint, Assert.Collection(problemDetails1.Errors, error => { - Assert.Equal("Value1", error.Key); + Assert.Equal("value1", error.Key); Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single()); }); }); @@ -179,22 +179,22 @@ await VerifyEndpoint(compilation, "/polymorphism-container", async (endpoint, se Assert.Collection(problemDetails.Errors, error => { - Assert.Equal("BaseType.Value3", error.Key); - Assert.Equal("The Value3 field is not a valid Base64 encoding.", error.Value.Single()); + Assert.Equal("baseType.value3", error.Key); + Assert.Equal("The value3 field is not a valid Base64 encoding.", error.Value.Single()); }, error => { - Assert.Equal("BaseType.Value1", error.Key); + Assert.Equal("baseType.value1", error.Key); Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("BaseType.Value2", error.Key); - Assert.Equal("The Value2 field is not a valid e-mail address.", error.Value.Single()); + Assert.Equal("baseType.value2", error.Key); + Assert.Equal("The value2 field is not a valid e-mail address.", error.Value.Single()); }, error => { - Assert.Equal("BaseValidatableType.Value1", error.Key); + Assert.Equal("baseValidatableType.value1", error.Key); Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single()); }); }); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.RecordType.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.RecordType.cs index cfc1372b9a7a..59150bbca8b8 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.RecordType.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.RecordType.cs @@ -113,8 +113,8 @@ async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithRange", kvp.Key); - Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single()); + Assert.Equal("integerWithRange", kvp.Key); + Assert.Equal("The field integerWithRange must be between 10 and 100.", kvp.Value.Single()); }); } @@ -132,7 +132,7 @@ async Task InvalidIntegerWithRangeAndDisplayNameProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithRangeAndDisplayName", kvp.Key); + Assert.Equal("integerWithRangeAndDisplayName", kvp.Key); Assert.Equal("The field Valid identifier must be between 10 and 100.", kvp.Value.Single()); }); } @@ -151,18 +151,17 @@ async Task InvalidRequiredSubtypePropertyProducesError(Endpoint endpoint) await endpoint.RequestDelegate(context); - var problemDetails = await AssertBadRequest(context); - Assert.Collection(problemDetails.Errors, - kvp => - { - Assert.Equal("PropertyWithMemberAttributes.RequiredProperty", kvp.Key); - Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); - }, - kvp => - { - Assert.Equal("PropertyWithMemberAttributes.StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); - }); + var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, + kvp => + { + Assert.Equal("propertyWithMemberAttributes.requiredProperty", kvp.Key); + Assert.Equal("The requiredProperty field is required.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("propertyWithMemberAttributes.stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }); } async Task InvalidSubTypeWithInheritancePropertyProducesError(Endpoint endpoint) @@ -180,23 +179,22 @@ async Task InvalidSubTypeWithInheritancePropertyProducesError(Endpoint endpoint) await endpoint.RequestDelegate(context); - var problemDetails = await AssertBadRequest(context); - Assert.Collection(problemDetails.Errors, - kvp => - { - Assert.Equal("PropertyWithInheritance.EmailString", kvp.Key); - Assert.Equal("The EmailString field is not a valid e-mail address.", kvp.Value.Single()); - }, - kvp => - { - Assert.Equal("PropertyWithInheritance.RequiredProperty", kvp.Key); - Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); - }, - kvp => - { - Assert.Equal("PropertyWithInheritance.StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); - }); + var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, + kvp => + { + Assert.Equal("propertyWithInheritance.emailString", kvp.Key); + Assert.Equal("The emailString field is not a valid e-mail address.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("propertyWithInheritance.requiredProperty", kvp.Key); + Assert.Equal("The requiredProperty field is required.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("propertyWithInheritance.stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }); } async Task InvalidListOfSubTypesProducesError(Endpoint endpoint) @@ -223,23 +221,22 @@ async Task InvalidListOfSubTypesProducesError(Endpoint endpoint) await endpoint.RequestDelegate(context); - var problemDetails = await AssertBadRequest(context); - Assert.Collection(problemDetails.Errors, - kvp => - { - Assert.Equal("ListOfSubTypes[0].RequiredProperty", kvp.Key); - Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); - }, - kvp => - { - Assert.Equal("ListOfSubTypes[0].StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); - }, - kvp => - { - Assert.Equal("ListOfSubTypes[1].StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); - }); + var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, + kvp => + { + Assert.Equal("listOfSubTypes[0].requiredProperty", kvp.Key); + Assert.Equal("The requiredProperty field is required.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("listOfSubTypes[0].stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("listOfSubTypes[1].stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }); } async Task InvalidPropertyWithDerivedValidationAttributeProducesError(Endpoint endpoint) @@ -256,7 +253,7 @@ async Task InvalidPropertyWithDerivedValidationAttributeProducesError(Endpoint e var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithDerivedValidationAttribute", kvp.Key); + Assert.Equal("integerWithDerivedValidationAttribute", kvp.Key); Assert.Equal("Value must be an even number", kvp.Value.Single()); }); } @@ -275,15 +272,15 @@ async Task InvalidPropertyWithMultipleAttributesProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("PropertyWithMultipleAttributes", kvp.Key); + Assert.Equal("propertyWithMultipleAttributes", kvp.Key); Assert.Collection(kvp.Value, error => { - Assert.Equal("The field PropertyWithMultipleAttributes is invalid.", error); + Assert.Equal("The field propertyWithMultipleAttributes is invalid.", error); }, error => { - Assert.Equal("The field PropertyWithMultipleAttributes must be between 10 and 100.", error); + Assert.Equal("The field propertyWithMultipleAttributes must be between 10 and 100.", error); }); }); } @@ -303,7 +300,7 @@ async Task InvalidPropertyWithCustomValidationProducesError(Endpoint endpoint) var problemDetails = await AssertBadRequest(context); Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("IntegerWithCustomValidation", kvp.Key); + Assert.Equal("integerWithCustomValidation", kvp.Key); var error = Assert.Single(kvp.Value); Assert.Equal("Can't use the same number value in two properties on the same class.", error); }); @@ -327,13 +324,13 @@ async Task InvalidPropertyOfSubtypeWithoutConstructorProducesError(Endpoint endp Assert.Collection(problemDetails.Errors, kvp => { - Assert.Equal("PropertyOfSubtypeWithoutConstructor.RequiredProperty", kvp.Key); - Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + Assert.Equal("propertyOfSubtypeWithoutConstructor.requiredProperty", kvp.Key); + Assert.Equal("The requiredProperty field is required.", kvp.Value.Single()); }, kvp => { - Assert.Equal("PropertyOfSubtypeWithoutConstructor.StringWithLength", kvp.Key); - Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + Assert.Equal("propertyOfSubtypeWithoutConstructor.stringWithLength", kvp.Key); + Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); }); } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Recursion.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Recursion.cs index 7367e2919ae0..b3d3207fc317 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Recursion.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.Recursion.cs @@ -116,43 +116,43 @@ async Task ValidatesTypeWithLimitedNesting(Endpoint endpoint) Assert.Collection(problemDetails.Errors, error => { - Assert.Equal("Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Next.Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("next.value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Next.Next.Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("next.next.value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Next.Next.Next.Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("next.next.next.value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Next.Next.Next.Next.Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("next.next.next.next.value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Next.Next.Next.Next.Next.Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("next.next.next.next.next.value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Next.Next.Next.Next.Next.Next.Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("next.next.next.next.next.next.value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }, error => { - Assert.Equal("Next.Next.Next.Next.Next.Next.Next.Value", error.Key); - Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single()); + Assert.Equal("next.next.next.next.next.next.next.value", error.Key); + Assert.Equal("The field value must be between 10 and 100.", error.Value.Single()); }); } }); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj b/src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj index f74f56690452..5e152d09f131 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj +++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs index 002ebb1582b0..2bd3143c1fd7 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs @@ -6,6 +6,9 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Validation.Tests; @@ -586,6 +589,405 @@ public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_Beh }); } + [Fact] + public async Task Validate_WithJsonPropertyNamingPolicy_FormatsNestedPropertyNames() + { + // Arrange + var addressType = new TestValidatableTypeInfo( + typeof(AddressWithNaming), + [ + CreatePropertyInfo(typeof(AddressWithNaming), typeof(string), "StreetAddress", "StreetAddress", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(AddressWithNaming), typeof(string), "ZipCode", "ZipCode", + [new RequiredAttribute()]) + ]); + + var personType = new TestValidatableTypeInfo( + typeof(PersonWithNaming), + [ + CreatePropertyInfo(typeof(PersonWithNaming), typeof(string), "FirstName", "FirstName", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(PersonWithNaming), typeof(AddressWithNaming), "HomeAddress", "HomeAddress", + []) + ]); + + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(PersonWithNaming), personType }, + { typeof(AddressWithNaming), addressType } + }); + + var person = new PersonWithNaming + { + HomeAddress = new AddressWithNaming() + }; + + var serviceProvider = CreateServiceProviderWithJsonOptions(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(person, serviceProvider, null) + }; + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("firstName", kvp.Key); + Assert.Equal("The firstName field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("homeAddress.streetAddress", kvp.Key); + Assert.Equal("The streetAddress field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("homeAddress.zipCode", kvp.Key); + Assert.Equal("The zipCode field is required.", kvp.Value.First()); + }); + } + + [Theory] + [InlineData(typeof(JsonNamingPolicy), "CamelCase", "firstName")] + [InlineData(typeof(JsonNamingPolicy), "KebabCaseLower", "first-name")] + [InlineData(typeof(JsonNamingPolicy), "SnakeCaseLower", "first_name")] + public async Task Validate_WithDifferentNamingPolicies_FormatsPropertyNamesCorrectly(Type policyType, string policyName, string expectedPropertyName) + { + // Arrange + var namingPolicy = (JsonNamingPolicy?)policyType.GetProperty(policyName)?.GetValue(null); + var personType = new TestValidatableTypeInfo( + typeof(PersonWithNaming), + [ + CreatePropertyInfo(typeof(PersonWithNaming), typeof(string), "FirstName", "FirstName", + [new RequiredAttribute()]) + ]); + + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(PersonWithNaming), personType } + }); + + var serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = namingPolicy + }; + var serviceProvider = CreateServiceProviderWithJsonOptions(serializerOptions); + + var person = new PersonWithNaming(); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(person, serviceProvider, null) + }; + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + var error = Assert.Single(context.ValidationErrors); + Assert.Equal(expectedPropertyName, error.Key); + } + + [Fact] + public async Task Validate_WithNamingPolicy_FormatsArrayIndices() + { + // Arrange + var orderItemType = new TestValidatableTypeInfo( + typeof(OrderItemWithNaming), + [ + CreatePropertyInfo(typeof(OrderItemWithNaming), typeof(string), "ProductName", "ProductName", + [new RequiredAttribute()]) + ]); + + var orderType = new TestValidatableTypeInfo( + typeof(OrderWithNaming), + [ + CreatePropertyInfo(typeof(OrderWithNaming), typeof(List), "OrderItems", "OrderItems", + []) + ]); + var serviceProvider = CreateServiceProviderWithJsonOptions(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(OrderWithNaming), orderType }, + { typeof(OrderItemWithNaming), orderItemType } + }); + + var order = new OrderWithNaming + { + OrderItems = [new OrderItemWithNaming(), new OrderItemWithNaming()] + }; + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(order, serviceProvider, null) + }; + + // Act + await orderType.ValidateAsync(order, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("orderItems[0].productName", kvp.Key); + Assert.Equal("The productName field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("orderItems[1].productName", kvp.Key); + Assert.Equal("The productName field is required.", kvp.Value.First()); + }); + } + + [Fact] + public async Task Validate_IValidatableObject_WithNamingPolicy_FormatsMemberNames() + { + // Arrange + var employeeType = new TestValidatableTypeInfo( + typeof(EmployeeWithNaming), + [ + CreatePropertyInfo(typeof(EmployeeWithNaming), typeof(string), "FirstName", "FirstName", []), + CreatePropertyInfo(typeof(EmployeeWithNaming), typeof(string), "LastName", "LastName", []), + CreatePropertyInfo(typeof(EmployeeWithNaming), typeof(decimal), "AnnualSalary", "AnnualSalary", []) + ]); + + var serviceProvider = CreateServiceProviderWithJsonOptions(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(EmployeeWithNaming), employeeType } + }); + + var employee = new EmployeeWithNaming + { + FirstName = "John", + LastName = "Doe", + AnnualSalary = -50000 + }; + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(employee, serviceProvider, null) + }; + + // Act + await employeeType.ValidateAsync(employee, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + var error = Assert.Single(context.ValidationErrors); + Assert.Equal("annualSalary", error.Key); + Assert.Equal("Annual salary must be positive.", error.Value.First()); + } + + [Fact] + public async Task Validate_JsonPropertyNameAttribute_TakesPrecedenceOverNamingPolicy() + { + // Arrange + var personType = new TestValidatableTypeInfo( + typeof(PersonWithJsonPropertyName), + [ + CreatePropertyInfo(typeof(PersonWithJsonPropertyName), typeof(string), "FirstName", "FirstName", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(PersonWithJsonPropertyName), typeof(string), "LastName", "LastName", + [new RequiredAttribute()]) + ]); + + var serviceProvider = CreateServiceProviderWithJsonOptions(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(PersonWithJsonPropertyName), personType } + }); + + var person = new PersonWithJsonPropertyName(); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(person, serviceProvider, null) + }; + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("custom_first_name", kvp.Key); // JsonPropertyName takes precedence + Assert.Equal("The custom_first_name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("lastName", kvp.Key); // Uses naming policy + Assert.Equal("The lastName field is required.", kvp.Value.First()); + }); + } + + [Fact] + public async Task Validate_DisplayAttribute_PreventsNamingPolicyFormatting() + { + // Arrange + var personType = new TestValidatableTypeInfo( + typeof(PersonWithDisplay), + [ + CreatePropertyInfo(typeof(PersonWithDisplay), typeof(string), "FirstName", "First Name", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(PersonWithDisplay), typeof(string), "LastName", "LastName", + [new RequiredAttribute()]) + ]); + + var serviceProvider = CreateServiceProviderWithJsonOptions(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(PersonWithDisplay), personType } + }); + + var person = new PersonWithDisplay(); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(person, serviceProvider, null) + }; + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("firstName", kvp.Key); + Assert.Equal("The First Name field is required.", kvp.Value.First()); // Display attribute prevents formatting + }, + kvp => + { + Assert.Equal("lastName", kvp.Key); + Assert.Equal("The lastName field is required.", kvp.Value.First()); // Uses naming policy + }); + } + + [Fact] + public async Task Validate_WithCustomErrorMessage_FormatsPropertyNameCorrectly() + { + // Arrange + var personType = new TestValidatableTypeInfo( + typeof(PersonWithNaming), + [ + CreatePropertyInfo(typeof(PersonWithNaming), typeof(string), "FirstName", "FirstName", + [new RequiredAttribute { ErrorMessage = "The {0} property is required." }]) + ]); + + var serviceProvider = CreateServiceProviderWithJsonOptions(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(PersonWithNaming), personType } + }); + + var person = new PersonWithNaming(); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(person, serviceProvider, null) + }; + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + var error = Assert.Single(context.ValidationErrors); + Assert.Equal("firstName", error.Key); + Assert.Equal("The firstName property is required.", error.Value.First()); + } + + // Test model classes for JsonSerializerOptions tests + private class PersonWithNaming + { + public string? FirstName { get; set; } + public AddressWithNaming? HomeAddress { get; set; } + } + + private class AddressWithNaming + { + public string? StreetAddress { get; set; } + public string? ZipCode { get; set; } + } + + private class OrderWithNaming + { + public List OrderItems { get; set; } = []; + } + + private class OrderItemWithNaming + { + public string? ProductName { get; set; } + } + + private class EmployeeWithNaming : IValidatableObject + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + public decimal AnnualSalary { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (AnnualSalary < 0) + { + yield return new ValidationResult("Annual salary must be positive.", [nameof(AnnualSalary)]); + } + } + } + + private class PersonWithJsonPropertyName + { + [JsonPropertyName("custom_first_name")] + public string? FirstName { get; set; } + + public string? LastName { get; set; } + } + + private class PersonWithDisplay + { + [Display(Name = "First Name")] + public string? FirstName { get; set; } + + public string? LastName { get; set; } + } + // Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739 private class GlobalErrorObject : IValidatableObject { @@ -762,6 +1164,38 @@ private class PasswordComplexityAttribute : ValidationAttribute } } + // Helper method to create a service provider with JsonOptions configured + private static IServiceProvider CreateServiceProviderWithJsonOptions(JsonSerializerOptions? serializerOptions = null) + { + var services = new ServiceCollection(); + + if (serializerOptions != null) + { + services.Configure(options => + { + options.SerializerOptions.PropertyNamingPolicy = serializerOptions.PropertyNamingPolicy; + options.SerializerOptions.PropertyNameCaseInsensitive = serializerOptions.PropertyNameCaseInsensitive; + options.SerializerOptions.DictionaryKeyPolicy = serializerOptions.DictionaryKeyPolicy; + options.SerializerOptions.DefaultIgnoreCondition = serializerOptions.DefaultIgnoreCondition; + options.SerializerOptions.IgnoreReadOnlyProperties = serializerOptions.IgnoreReadOnlyProperties; + options.SerializerOptions.IgnoreReadOnlyFields = serializerOptions.IgnoreReadOnlyFields; + options.SerializerOptions.IncludeFields = serializerOptions.IncludeFields; + options.SerializerOptions.NumberHandling = serializerOptions.NumberHandling; + options.SerializerOptions.ReadCommentHandling = serializerOptions.ReadCommentHandling; + options.SerializerOptions.WriteIndented = serializerOptions.WriteIndented; + options.SerializerOptions.MaxDepth = serializerOptions.MaxDepth; + options.SerializerOptions.AllowTrailingCommas = serializerOptions.AllowTrailingCommas; + // Copy other relevant properties as needed + }); + } + else + { + services.Configure(options => { }); // Register empty JsonOptions + } + + return services.BuildServiceProvider(); + } + // Test implementations private class TestValidatablePropertyInfo : ValidatablePropertyInfo {