diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 8cb332fa9f20..85273ab111e7 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -28,6 +28,8 @@ Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.get -> Sy Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.set -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationErrors.get -> System.Collections.Generic.Dictionary? Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationErrors.set -> void +Microsoft.AspNetCore.Http.Validation.ValidateContext.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions? +Microsoft.AspNetCore.Http.Validation.ValidateContext.SerializerOptions.set -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationOptions.get -> Microsoft.AspNetCore.Http.Validation.ValidationOptions! Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationOptions.set -> void Microsoft.AspNetCore.Http.Validation.ValidationOptions diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs index 48de32c0daff..f168c471fa56 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs @@ -66,7 +66,14 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, } context.ValidationContext.DisplayName = DisplayName; - context.ValidationContext.MemberName = Name; + + // Format member name according to naming policy if available + var memberName = Name; + if (context.SerializerOptions?.PropertyNamingPolicy is not null) + { + memberName = context.SerializerOptions.PropertyNamingPolicy.ConvertName(Name); + } + context.ValidationContext.MemberName = memberName; var validationAttributes = GetValidationAttributes(); diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs index 0b16e34d1dc9..2cb6e45d0bb3 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs @@ -76,7 +76,14 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, } context.ValidationContext.DisplayName = DisplayName; - context.ValidationContext.MemberName = Name; + + // Format member name according to naming policy if available + var memberName = Name; + if (context.SerializerOptions?.PropertyNamingPolicy is not null) + { + memberName = context.SerializerOptions.PropertyNamingPolicy.ConvertName(Name); + } + context.ValidationContext.MemberName = memberName; // Check required attribute first if (_requiredAttribute is not null || validationAttributes.TryGetRequiredAttribute(out _requiredAttribute)) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index d38ada2ddeb1..58a4b39ed4e5 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -3,6 +3,8 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; namespace Microsoft.AspNetCore.Http.Validation; @@ -59,28 +61,38 @@ public sealed class ValidateContext /// This is used to prevent stack overflows from circular references. /// public int CurrentDepth { get; set; } + + /// + /// Gets or sets the JSON serializer options to use for property name formatting. + /// When available, property names in validation errors will be formatted according to the + /// PropertyNamingPolicy and JsonPropertyName attributes. + /// + public JsonSerializerOptions? SerializerOptions { get; set; } - internal void AddValidationError(string key, string[] error) + internal void AddValidationError(string key, string[] errors) { ValidationErrors ??= []; - ValidationErrors[key] = error; + var formattedKey = FormatKey(key); + ValidationErrors[formattedKey] = errors; } internal void AddOrExtendValidationErrors(string key, string[] errors) { ValidationErrors ??= []; - if (ValidationErrors.TryGetValue(key, out var existingErrors)) + var formattedKey = FormatKey(key); + + if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors)) { var newErrors = new string[existingErrors.Length + errors.Length]; existingErrors.CopyTo(newErrors, 0); errors.CopyTo(newErrors, existingErrors.Length); - ValidationErrors[key] = newErrors; + ValidationErrors[formattedKey] = newErrors; } else { - ValidationErrors[key] = errors; + ValidationErrors[formattedKey] = errors; } } @@ -88,13 +100,109 @@ internal void AddOrExtendValidationError(string key, string error) { ValidationErrors ??= []; - if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error)) + var formattedKey = FormatKey(key); + + if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(error)) { - ValidationErrors[key] = [.. existingErrors, error]; + ValidationErrors[formattedKey] = [.. existingErrors, error]; } else { - ValidationErrors[key] = [error]; + ValidationErrors[formattedKey] = [error]; } } + + private string FormatKey(string key) + { + if (string.IsNullOrEmpty(key) || SerializerOptions?.PropertyNamingPolicy is null) + { + return key; + } + + // If the key contains a path (e.g., "Address.Street" or "Items[0].Name"), + // apply the naming policy to each part of the path + if (key.Contains('.') || key.Contains('[')) + { + return FormatComplexKey(key); + } + + // Apply the naming policy directly + return SerializerOptions.PropertyNamingPolicy.ConvertName(key); + } + + private string FormatComplexKey(string key) + { + // Use a more direct approach for complex keys with dots and array indices + var result = new System.Text.StringBuilder(); + int lastIndex = 0; + int i = 0; + bool inBracket = false; + var propertyNamingPolicy = SerializerOptions?.PropertyNamingPolicy; + + while (i < key.Length) + { + char c = key[i]; + + if (c == '[') + { + // Format the segment before the bracket + if (i > lastIndex) + { + string segment = key.Substring(lastIndex, i - lastIndex); + string formattedSegment = propertyNamingPolicy is not null + ? propertyNamingPolicy.ConvertName(segment) + : segment; + result.Append(formattedSegment); + } + + // Start collecting the bracket part + inBracket = true; + result.Append(c); + lastIndex = i + 1; + } + else if (c == ']') + { + // Add the content inside the bracket as-is + if (i > lastIndex) + { + string segment = key.Substring(lastIndex, i - lastIndex); + result.Append(segment); + } + result.Append(c); + inBracket = false; + lastIndex = i + 1; + } + else if (c == '.' && !inBracket) + { + // Format the segment before the dot + if (i > lastIndex) + { + string segment = key.Substring(lastIndex, i - lastIndex); + string formattedSegment = propertyNamingPolicy is not null + ? propertyNamingPolicy.ConvertName(segment) + : segment; + result.Append(formattedSegment); + } + result.Append(c); + lastIndex = i + 1; + } + + i++; + } + + // Format the last segment if there is one + if (lastIndex < key.Length) + { + string segment = key.Substring(lastIndex); + if (!inBracket && propertyNamingPolicy is not null) + { + segment = propertyNamingPolicy.ConvertName(segment); + } + result.Append(segment); + } + + return result.ToString(); + } + + } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index a6123bb11c67..afac7e5bb8eb 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -6,6 +6,8 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Microsoft.AspNetCore.Http.Validation.Tests; @@ -81,7 +83,210 @@ [new RequiredAttribute()]) } [Fact] - public async Task Validate_HandlesIValidatableObject_Implementation() + public async Task Validate_ValidatesComplexType_WithNestedProperties_AppliesJsonPropertyNamingPolicy() + { + // Arrange + var personType = new TestValidatableTypeInfo( + typeof(Person), + [ + CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(Person), typeof(int), "Age", "Age", + [new RangeAttribute(0, 120)]), + CreatePropertyInfo(typeof(Person), typeof(Address), "Address", "Address", + []) + ]); + + var addressType = new TestValidatableTypeInfo( + typeof(Address), + [ + CreatePropertyInfo(typeof(Address), typeof(string), "Street", "Street", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(Address), typeof(string), "City", "City", + [new RequiredAttribute()]) + ]); + + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(Person), personType }, + { typeof(Address), addressType } + }); + + var personWithMissingRequiredFields = new Person + { + Age = 150, // Invalid age + Address = new Address() // Missing required City and Street + }; + + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(personWithMissingRequiredFields), + SerializerOptions = jsonOptions + }; + + // Act + await personType.ValidateAsync(personWithMissingRequiredFields, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("name", kvp.Key); + Assert.Equal("The name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("age", kvp.Key); + Assert.Equal("The field age must be between 0 and 120.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("address.street", kvp.Key); + Assert.Equal("The street field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("address.city", kvp.Key); + Assert.Equal("The city field is required.", kvp.Value.First()); + }); + } + + [Theory] + [InlineData("CamelCase", "firstName", "lastName")] + [InlineData("KebabCaseLower", "first-name", "last-name")] + [InlineData("SnakeCaseLower", "first_name", "last_name")] + public async Task Validate_AppliesJsonPropertyNamingPolicy_ForDifferentNamingPolicies(string policy, string expectedFirstName, string expectedLastName) + { + // Arrange + var personType = new TestValidatableTypeInfo( + typeof(PersonWithJsonNames), + [ + CreatePropertyInfo(typeof(PersonWithJsonNames), typeof(string), "FirstName", "FirstName", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(PersonWithJsonNames), typeof(string), "LastName", "LastName", + [new RequiredAttribute()]) + ]); + + var validationOptions = new TestValidationOptions(new Dictionary + { + { typeof(PersonWithJsonNames), personType } + }); + + var person = new PersonWithJsonNames(); // Missing required fields + + var jsonOptions = new JsonSerializerOptions(); + jsonOptions.PropertyNamingPolicy = policy switch + { + "CamelCase" => JsonNamingPolicy.CamelCase, + "KebabCaseLower" => JsonNamingPolicy.KebabCaseLower, + "SnakeCaseLower" => JsonNamingPolicy.SnakeCaseLower, + _ => null + }; + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(person), + SerializerOptions = jsonOptions + }; + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal(expectedFirstName, kvp.Key); + Assert.Equal($"The FirstName field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal(expectedLastName, kvp.Key); + Assert.Equal($"The LastName field is required.", kvp.Value.First()); + }); + } + + [Fact] + public async Task Validate_HandlesArrayIndices_WithJsonPropertyNamingPolicy() + { + // Arrange + var orderType = new TestValidatableTypeInfo( + typeof(Order), + [ + CreatePropertyInfo(typeof(Order), typeof(string), "OrderNumber", "OrderNumber", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(Order), typeof(List), "Items", "Items", + []) + ]); + + var itemType = new TestValidatableTypeInfo( + typeof(OrderItem), + [ + CreatePropertyInfo(typeof(OrderItem), typeof(string), "ProductName", "ProductName", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(OrderItem), typeof(int), "Quantity", "Quantity", + [new RangeAttribute(1, 100)]) + ]); + + var order = new Order + { + // Missing OrderNumber + Items = + [ + new OrderItem { /* Missing ProductName */ Quantity = 0 }, // Invalid quantity + ] + }; + + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(OrderItem), itemType }, + { typeof(Order), orderType } + }), + ValidationContext = new ValidationContext(order), + SerializerOptions = jsonOptions + }; + + // Act + await orderType.ValidateAsync(order, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("orderNumber", kvp.Key); + Assert.Equal("The orderNumber field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("items[0].productName", kvp.Key); + Assert.Equal("The productName field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("items[0].quantity", kvp.Key); + Assert.Equal("The field quantity must be between 1 and 100.", kvp.Value.First()); + }); + } + + [Fact] + public async Task Validate_HandlesIValidatableObject_WithJsonPropertyNamingPolicy() { // Arrange var employeeType = new TestValidatableTypeInfo( @@ -101,13 +306,20 @@ [new RequiredAttribute()]), Department = "IT", Salary = -5000 // Negative salary will trigger IValidatableObject validation }; + + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary { { typeof(Employee), employeeType } }), - ValidationContext = new ValidationContext(employee) + ValidationContext = new ValidationContext(employee), + SerializerOptions = jsonOptions }; // Act @@ -116,10 +328,51 @@ [new RequiredAttribute()]), // Assert Assert.NotNull(context.ValidationErrors); var error = Assert.Single(context.ValidationErrors); - Assert.Equal("Salary", error.Key); + Assert.Equal("salary", error.Key); Assert.Equal("Salary must be a positive value.", error.Value.First()); } + [Fact] + public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_WithJsonNamingPolicy() + { + var multiType = new TestValidatableTypeInfo( + typeof(MultiMemberErrorObject), + [ + CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "FirstName", "FirstName", []), + CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "LastName", "LastName", []) + ]); + + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(MultiMemberErrorObject), multiType } + }), + ValidationContext = new ValidationContext(new MultiMemberErrorObject { FirstName = "", LastName = "" }), + SerializerOptions = jsonOptions + }; + + await multiType.ValidateAsync(context.ValidationContext.ObjectInstance, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("firstName", kvp.Key); + Assert.Equal("FirstName and LastName are required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("lastName", kvp.Key); + Assert.Equal("FirstName and LastName are required.", kvp.Value.First()); + }); + } + [Fact] public async Task Validate_HandlesPolymorphicTypes_WithSubtypes() { @@ -598,6 +851,73 @@ private class Person public Address? Address { get; set; } } + private class PersonWithJsonNames + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + } + +[Fact] + public async Task Validate_RespectsJsonPropertyNameAttribute_ForValidationErrors() + { + // Arrange + var modelType = new TestValidatableTypeInfo( + typeof(ModelWithJsonPropertyNames), + [ + CreatePropertyInfo(typeof(ModelWithJsonPropertyNames), typeof(string), "UserName", "UserName", + [new RequiredAttribute()]), + CreatePropertyInfo(typeof(ModelWithJsonPropertyNames), typeof(string), "EmailAddress", "EmailAddress", + [new EmailAddressAttribute()]) + ]); + + var model = new ModelWithJsonPropertyNames { EmailAddress = "invalid-email" }; // Missing username and invalid email + + var jsonOptions = new JsonSerializerOptions(); + // Add a custom converter that knows about JsonPropertyName attributes + jsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(ModelWithJsonPropertyNames), modelType } + }), + ValidationContext = new ValidationContext(model), + SerializerOptions = jsonOptions + }; + + // Act + await modelType.ValidateAsync(model, context, default); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + // Property key uses camelCase naming policy + Assert.Equal("username", kvp.Key); + // Error message should also use camelCase for property names + Assert.Equal("The UserName field is required.", kvp.Value.First()); + }, + kvp => + { + // Property key uses camelCase naming policy + Assert.Equal("email", kvp.Key); + // Error message should also use camelCase for property names + Assert.Equal("The EmailAddress field is not a valid e-mail address.", kvp.Value.First()); + }); + } + + private class ModelWithJsonPropertyNames + { + [JsonPropertyName("username")] + public string? UserName { get; set; } + + [JsonPropertyName("email")] + [EmailAddress] + public string? EmailAddress { get; set; } + } + private class Address { public string? Street { get; set; } @@ -787,4 +1107,77 @@ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNull } } } + + [Fact] + public void Validate_FormatsErrorMessagesWithPropertyNamingPolicy() + { + // Arrange + var validationOptions = new ValidationOptions(); + + var address = new Address(); + var validationContext = new ValidationContext(address); + var validateContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = validationContext, + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + var propertyInfo = CreatePropertyInfo( + typeof(Address), + typeof(string), + "Street", + "Street", + [new RequiredAttribute()]); + + // Act + propertyInfo.ValidateAsync(address, validateContext, CancellationToken.None); + + // Assert + var error = Assert.Single(validateContext.ValidationErrors!); + Assert.Equal("street", error.Key); + Assert.Equal("The street field is required.", error.Value.First()); + } + + [Fact] + public void Validate_PreservesCustomErrorMessagesWithPropertyNamingPolicy() + { + // Arrange + var validationOptions = new ValidationOptions(); + + var model = new TestModel(); + var validationContext = new ValidationContext(model); + var validateContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = validationContext, + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + var propertyInfo = CreatePropertyInfo( + typeof(TestModel), + typeof(string), + "CustomProperty", + "CustomProperty", + [new RequiredAttribute { ErrorMessage = "Custom message without standard format." }]); + + // Act + propertyInfo.ValidateAsync(model, validateContext, CancellationToken.None); + + // Assert + var error = Assert.Single(validateContext.ValidationErrors!); + Assert.Equal("customProperty", error.Key); + Assert.Equal("Custom message without standard format.", error.Value.First()); // Custom message without formatting + } + + private class TestModel + { + public string? CustomProperty { get; set; } + } } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs new file mode 100644 index 000000000000..767460ec1781 --- /dev/null +++ b/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs @@ -0,0 +1,219 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Http.Validation; + +public class ValidateContextTests +{ + [Fact] + public void AddValidationError_FormatsCamelCaseKeys_WithSerializerOptions() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Act + context.AddValidationError("PropertyName", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("propertyName")); + } + + [Fact] + public void AddValidationError_FormatsSimpleKeys_WithSerializerOptions() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Act + context.AddValidationError("ThisIsAProperty", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("thisIsAProperty")); + } + + [Fact] + public void FormatComplexKey_FormatsNestedProperties_WithDots() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Act + context.AddValidationError("Customer.Address.Street", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("customer.address.street")); + } + + [Fact] + public void FormatComplexKey_PreservesArrayIndices() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Act + context.AddValidationError("Items[0].ProductName", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("items[0].productName")); + Assert.False(context.ValidationErrors.ContainsKey("items[0].ProductName")); + } + + [Fact] + public void FormatComplexKey_HandlesMultipleArrayIndices() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Act + context.AddValidationError("Orders[0].Items[1].ProductName", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("orders[0].items[1].productName")); + } + + [Fact] + public void FormatComplexKey_HandlesNestedArraysWithoutProperties() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Act + context.AddValidationError("Matrix[0][1]", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("matrix[0][1]")); + } + + [Fact] + public void FormatKey_ReturnsOriginalKey_WhenSerializerOptionsIsNull() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = null; + + // Act + context.AddValidationError("PropertyName", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("PropertyName")); + } + + [Fact] + public void FormatKey_ReturnsOriginalKey_WhenPropertyNamingPolicyIsNull() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = null + }; + + // Act + context.AddValidationError("PropertyName", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("PropertyName")); + } + + [Fact] + public void FormatKey_AppliesKebabCaseNamingPolicy() + { + // Arrange + var context = CreateValidateContext(); + context.SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = new KebabCaseNamingPolicy() + }; + + // Act + context.AddValidationError("ProductName", ["Error"]); + context.AddValidationError("OrderItems[0].ProductName", ["Error"]); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("product-name")); + Assert.True(context.ValidationErrors.ContainsKey("order-items[0].product-name")); + } + + private static ValidateContext CreateValidateContext() + { + var serviceProvider = new EmptyServiceProvider(); + var options = new ValidationOptions(); + var validationContext = new ValidationContext(new object(), serviceProvider, null); + + return new ValidateContext + { + ValidationContext = validationContext, + ValidationOptions = options + }; + } + + private class KebabCaseNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var result = string.Empty; + + for (int i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) + { + result += "-"; + } + + result += char.ToLower(name[i], CultureInfo.InvariantCulture); + } + + return result; + } + } + + private class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} \ No newline at end of file diff --git a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs index 73a41f0f8d57..ef2e55ac81bf 100644 --- a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs +++ b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs @@ -6,6 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; +using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -55,6 +56,10 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context { ValidateContext? validateContext = null; + // Get JsonOptions from DI + var jsonOptions = context.HttpContext.RequestServices.GetService>(); + var serializerOptions = jsonOptions?.Value?.SerializerOptions; + for (var i = 0; i < context.Arguments.Count; i++) { var validatableParameter = validatableParameters[i]; @@ -73,7 +78,8 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context validateContext = new ValidateContext { ValidationOptions = options, - ValidationContext = validationContext + ValidationContext = validationContext, + SerializerOptions = serializerOptions }; } else