From d3d01af1c2a767b544cb005f1723da31ffa3a729 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 May 2025 23:20:01 +0000 Subject: [PATCH 01/18] Initial plan for issue From 03af06c12000f82b47cba124a65db9be1e9b8d00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 May 2025 23:48:28 +0000 Subject: [PATCH 02/18] Add support for JsonSerializerOptions property naming policy in validation errors Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/PublicAPI.Unshipped.txt | 2 + .../src/Validation/ValidateContext.cs | 113 +++++++- .../Validation/ValidatableTypeInfoTests.cs | 274 +++++++++++++++++- .../test/Validation/ValidateContextTests.cs | 207 +++++++++++++ .../src/ValidationEndpointFilterFactory.cs | 7 +- 5 files changed, 592 insertions(+), 11 deletions(-) create mode 100644 src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 8cb332fa9f20..d83dff843f5d 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -23,6 +23,8 @@ Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentDepth.get -> int Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentDepth.set -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentValidationPath.get -> string! Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentValidationPath.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.ValidateContext() -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.get -> System.ComponentModel.DataAnnotations.ValidationContext! Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.set -> void diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index d38ada2ddeb1..5655820069e6 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; namespace Microsoft.AspNetCore.Http.Validation; @@ -60,27 +61,36 @@ public sealed class ValidateContext /// public int CurrentDepth { get; set; } + /// + /// Gets or sets the JSON serializer options to use for property name formatting. + /// When set, 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) { ValidationErrors ??= []; - ValidationErrors[key] = error; + var formattedKey = FormatKey(key); + ValidationErrors[formattedKey] = error; } 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 +98,102 @@ 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); } + + // For JsonPropertyName attribute support, we'd need property info + // but for basic usage, 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; + + 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 = SerializerOptions!.PropertyNamingPolicy!.ConvertName(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 = SerializerOptions!.PropertyNamingPolicy!.ConvertName(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) + { + segment = SerializerOptions!.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..5d033e4fccd6 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,21 @@ private class Person public Address? Address { get; set; } } + private class PersonWithJsonNames + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + } + + private class ModelWithJsonPropertyNames + { + [JsonPropertyName("username")] + public string? UserName { get; set; } + + [JsonPropertyName("email")] + public string? EmailAddress { get; set; } + } + private class Address { public string? Street { 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..d805f4e09c66 --- /dev/null +++ b/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs @@ -0,0 +1,207 @@ +#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. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Microsoft.AspNetCore.Http.Validation.Tests; + +public class ValidateContextTests +{ + [Fact] + public void FormatKey_NoJsonOptions_ReturnsSameKey() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()) + }; + + // Act & Assert - Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("FormatKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var formattedKey = (string)method!.Invoke(context, new object[] { "PropertyName" })!; + + Assert.Equal("PropertyName", formattedKey); + } + + [Fact] + public void FormatKey_WithCamelCasePolicy_ReturnsCamelCaseKey() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()), + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + // Act & Assert - Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("FormatKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var formattedKey = (string)method!.Invoke(context, new object[] { "PropertyName" })!; + + Assert.Equal("propertyName", formattedKey); + } + + [Fact] + public void FormatKey_WithSnakeCasePolicy_ReturnsSnakeCaseKey() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()), + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + } + }; + + // Act & Assert - Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("FormatKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var formattedKey = (string)method!.Invoke(context, new object[] { "PropertyName" })!; + + Assert.Equal("property_name", formattedKey); + } + + [Fact] + public void FormatKey_WithNestedPath_AppliesNamingPolicyToPathSegments() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()), + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + // Act & Assert - Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("FormatKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var formattedKey = (string)method!.Invoke(context, new object[] { "Customer.Address.StreetName" })!; + + Assert.Equal("customer.address.streetName", formattedKey); + } + + [Fact] + public void FormatKey_WithArrayIndices_PreservesIndicesAndAppliesNamingPolicy() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()), + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + // Act & Assert - Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("FormatKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var formattedKey = (string)method!.Invoke(context, new object[] { "Orders[0].OrderItems[2].ProductName" })!; + + Assert.Equal("orders[0].orderItems[2].productName", formattedKey); + } + + [Fact] + public void AddValidationError_WithNamingPolicy_FormatsKeyAccordingly() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()), + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + // Act + // Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("AddValidationError", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method!.Invoke(context, new object[] { "FirstName", new[] { "Error message" } }); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Single(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("firstName")); + Assert.Single(context.ValidationErrors["firstName"]); + Assert.Equal("Error message", context.ValidationErrors["firstName"][0]); + } + + [Fact] + public void AddOrExtendValidationError_WithNamingPolicy_FormatsKeyAccordingly() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()), + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + // Act + // Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("AddOrExtendValidationError", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method!.Invoke(context, new object[] { "FirstName", "Error message 1" }); + method!.Invoke(context, new object[] { "FirstName", "Error message 2" }); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Single(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("firstName")); + Assert.Equal(2, context.ValidationErrors["firstName"].Length); + Assert.Equal("Error message 1", context.ValidationErrors["firstName"][0]); + Assert.Equal("Error message 2", context.ValidationErrors["firstName"][1]); + } + + [Fact] + public void AddOrExtendValidationErrors_WithNamingPolicy_FormatsKeyAccordingly() + { + // Arrange + var context = new ValidateContext + { + ValidationOptions = new ValidationOptions(), + ValidationContext = new ValidationContext(new object()), + SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + }; + + // Act + // Use reflection to access internal method + var method = typeof(ValidateContext).GetMethod("AddOrExtendValidationErrors", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method!.Invoke(context, new object[] { "FirstName", new[] { "Error message 1", "Error message 2" } }); + method!.Invoke(context, new object[] { "FirstName", new[] { "Error message 3" } }); + + // Assert + Assert.NotNull(context.ValidationErrors); + Assert.Single(context.ValidationErrors); + Assert.True(context.ValidationErrors.ContainsKey("firstName")); + Assert.Equal(3, context.ValidationErrors["firstName"].Length); + Assert.Equal("Error message 1", context.ValidationErrors["firstName"][0]); + Assert.Equal("Error message 2", context.ValidationErrors["firstName"][1]); + Assert.Equal("Error message 3", context.ValidationErrors["firstName"][2]); + } +} \ No newline at end of file diff --git a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs index 73a41f0f8d57..3fff8288ad91 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,9 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context { ValidateContext? validateContext = null; + // Get JsonOptions from DI if available + var jsonOptions = context.HttpContext.RequestServices.GetService>()?.Value; + for (var i = 0; i < context.Arguments.Count; i++) { var validatableParameter = validatableParameters[i]; @@ -73,7 +77,8 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context validateContext = new ValidateContext { ValidationOptions = options, - ValidationContext = validationContext + ValidationContext = validationContext, + SerializerOptions = jsonOptions?.SerializerOptions }; } else From 216406ab96fff517fdfeb919ff93d722f5bf17df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 00:56:11 +0000 Subject: [PATCH 03/18] Make SerializerOptions property internal and retrieve options from DI Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/PublicAPI.Unshipped.txt | 2 - .../src/Validation/ValidateContext.cs | 11 +- .../test/Validation/ValidateContextTests.cs | 207 ------------------ .../src/ValidationEndpointFilterFactory.cs | 13 +- 4 files changed, 15 insertions(+), 218 deletions(-) delete mode 100644 src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index d83dff843f5d..8cb332fa9f20 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -23,8 +23,6 @@ Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentDepth.get -> int Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentDepth.set -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentValidationPath.get -> string! Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentValidationPath.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.ValidateContext() -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.get -> System.ComponentModel.DataAnnotations.ValidationContext! Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.set -> void diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index 5655820069e6..dab60e8d8485 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -60,13 +60,13 @@ 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 set, property names in validation errors will be formatted according to the /// PropertyNamingPolicy and JsonPropertyName attributes. /// - public JsonSerializerOptions? SerializerOptions { get; set; } + internal JsonSerializerOptions? SerializerOptions { get; set; } internal void AddValidationError(string key, string[] error) { @@ -108,7 +108,7 @@ internal void AddOrExtendValidationError(string key, string error) ValidationErrors[formattedKey] = [error]; } } - + private string FormatKey(string key) { if (string.IsNullOrEmpty(key) || SerializerOptions?.PropertyNamingPolicy is null) @@ -123,11 +123,10 @@ private string FormatKey(string key) return FormatComplexKey(key); } - // For JsonPropertyName attribute support, we'd need property info - // but for basic usage, apply the naming policy directly + // 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 diff --git a/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs deleted file mode 100644 index d805f4e09c66..000000000000 --- a/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -#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. - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.ComponentModel.DataAnnotations; -using System.Text.Json; - -namespace Microsoft.AspNetCore.Http.Validation.Tests; - -public class ValidateContextTests -{ - [Fact] - public void FormatKey_NoJsonOptions_ReturnsSameKey() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()) - }; - - // Act & Assert - Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("FormatKey", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var formattedKey = (string)method!.Invoke(context, new object[] { "PropertyName" })!; - - Assert.Equal("PropertyName", formattedKey); - } - - [Fact] - public void FormatKey_WithCamelCasePolicy_ReturnsCamelCaseKey() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()), - SerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - } - }; - - // Act & Assert - Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("FormatKey", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var formattedKey = (string)method!.Invoke(context, new object[] { "PropertyName" })!; - - Assert.Equal("propertyName", formattedKey); - } - - [Fact] - public void FormatKey_WithSnakeCasePolicy_ReturnsSnakeCaseKey() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()), - SerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower - } - }; - - // Act & Assert - Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("FormatKey", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var formattedKey = (string)method!.Invoke(context, new object[] { "PropertyName" })!; - - Assert.Equal("property_name", formattedKey); - } - - [Fact] - public void FormatKey_WithNestedPath_AppliesNamingPolicyToPathSegments() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()), - SerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - } - }; - - // Act & Assert - Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("FormatKey", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var formattedKey = (string)method!.Invoke(context, new object[] { "Customer.Address.StreetName" })!; - - Assert.Equal("customer.address.streetName", formattedKey); - } - - [Fact] - public void FormatKey_WithArrayIndices_PreservesIndicesAndAppliesNamingPolicy() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()), - SerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - } - }; - - // Act & Assert - Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("FormatKey", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var formattedKey = (string)method!.Invoke(context, new object[] { "Orders[0].OrderItems[2].ProductName" })!; - - Assert.Equal("orders[0].orderItems[2].productName", formattedKey); - } - - [Fact] - public void AddValidationError_WithNamingPolicy_FormatsKeyAccordingly() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()), - SerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - } - }; - - // Act - // Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("AddValidationError", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method!.Invoke(context, new object[] { "FirstName", new[] { "Error message" } }); - - // Assert - Assert.NotNull(context.ValidationErrors); - Assert.Single(context.ValidationErrors); - Assert.True(context.ValidationErrors.ContainsKey("firstName")); - Assert.Single(context.ValidationErrors["firstName"]); - Assert.Equal("Error message", context.ValidationErrors["firstName"][0]); - } - - [Fact] - public void AddOrExtendValidationError_WithNamingPolicy_FormatsKeyAccordingly() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()), - SerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - } - }; - - // Act - // Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("AddOrExtendValidationError", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method!.Invoke(context, new object[] { "FirstName", "Error message 1" }); - method!.Invoke(context, new object[] { "FirstName", "Error message 2" }); - - // Assert - Assert.NotNull(context.ValidationErrors); - Assert.Single(context.ValidationErrors); - Assert.True(context.ValidationErrors.ContainsKey("firstName")); - Assert.Equal(2, context.ValidationErrors["firstName"].Length); - Assert.Equal("Error message 1", context.ValidationErrors["firstName"][0]); - Assert.Equal("Error message 2", context.ValidationErrors["firstName"][1]); - } - - [Fact] - public void AddOrExtendValidationErrors_WithNamingPolicy_FormatsKeyAccordingly() - { - // Arrange - var context = new ValidateContext - { - ValidationOptions = new ValidationOptions(), - ValidationContext = new ValidationContext(new object()), - SerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - } - }; - - // Act - // Use reflection to access internal method - var method = typeof(ValidateContext).GetMethod("AddOrExtendValidationErrors", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method!.Invoke(context, new object[] { "FirstName", new[] { "Error message 1", "Error message 2" } }); - method!.Invoke(context, new object[] { "FirstName", new[] { "Error message 3" } }); - - // Assert - Assert.NotNull(context.ValidationErrors); - Assert.Single(context.ValidationErrors); - Assert.True(context.ValidationErrors.ContainsKey("firstName")); - Assert.Equal(3, context.ValidationErrors["firstName"].Length); - Assert.Equal("Error message 1", context.ValidationErrors["firstName"][0]); - Assert.Equal("Error message 2", context.ValidationErrors["firstName"][1]); - Assert.Equal("Error message 3", context.ValidationErrors["firstName"][2]); - } -} \ No newline at end of file diff --git a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs index 3fff8288ad91..3249af57bc90 100644 --- a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs +++ b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs @@ -57,7 +57,8 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context ValidateContext? validateContext = null; // Get JsonOptions from DI if available - var jsonOptions = context.HttpContext.RequestServices.GetService>()?.Value; + var jsonOptions = context.HttpContext.RequestServices.GetService>(); + var jsonSerializerOptions = jsonOptions?.Value?.SerializerOptions; for (var i = 0; i < context.Arguments.Count; i++) { @@ -77,9 +78,15 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context validateContext = new ValidateContext { ValidationOptions = options, - ValidationContext = validationContext, - SerializerOptions = jsonOptions?.SerializerOptions + ValidationContext = validationContext }; + + // Set the serializer options via reflection as it's internal + if (jsonSerializerOptions is not null) + { + var serializerOptionsProp = typeof(ValidateContext).GetProperty("SerializerOptions", BindingFlags.NonPublic | BindingFlags.Instance); + serializerOptionsProp?.SetValue(validateContext, jsonSerializerOptions); + } } else { From f92b43ee3148fbb3ab65ca52418448d3c611f12e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 01:22:34 +0000 Subject: [PATCH 04/18] Refactor SerializerOptions property to dynamically access JsonOptions from service provider Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/Validation/ValidateContext.cs | 34 +++++++++++++++++-- .../src/ValidationEndpointFilterFactory.cs | 12 +------ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index dab60e8d8485..e514198e3aca 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -63,10 +63,40 @@ public sealed class ValidateContext /// /// Gets or sets the JSON serializer options to use for property name formatting. - /// When set, property names in validation errors will be formatted according to the + /// When available, property names in validation errors will be formatted according to the /// PropertyNamingPolicy and JsonPropertyName attributes. /// - internal JsonSerializerOptions? SerializerOptions { get; set; } + internal JsonSerializerOptions? SerializerOptions + { + get + { + // If explicit options have been set, use those (primarily for testing) + if (_serializerOptions is not null) + { + return _serializerOptions; + } + + // Otherwise try to get them from DI + var jsonOptionsType = Type.GetType("Microsoft.AspNetCore.Http.Json.JsonOptions, Microsoft.AspNetCore.Http.Extensions"); + if (jsonOptionsType is null) + { + return null; + } + + var jsonOptionsService = ValidationContext.GetService(jsonOptionsType); + if (jsonOptionsService is null) + { + return null; + } + + // Get the SerializerOptions property via reflection + var serializerOptionsProperty = jsonOptionsType.GetProperty("SerializerOptions"); + return serializerOptionsProperty?.GetValue(jsonOptionsService) as JsonSerializerOptions; + } + set => _serializerOptions = value; + } + + private JsonSerializerOptions? _serializerOptions; internal void AddValidationError(string key, string[] error) { diff --git a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs index 3249af57bc90..2fc1b6364721 100644 --- a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs +++ b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs @@ -6,7 +6,6 @@ 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; @@ -56,9 +55,7 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context { ValidateContext? validateContext = null; - // Get JsonOptions from DI if available - var jsonOptions = context.HttpContext.RequestServices.GetService>(); - var jsonSerializerOptions = jsonOptions?.Value?.SerializerOptions; + // JsonOptions will be retrieved directly by ValidateContext.SerializerOptions property for (var i = 0; i < context.Arguments.Count; i++) { @@ -80,13 +77,6 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context ValidationOptions = options, ValidationContext = validationContext }; - - // Set the serializer options via reflection as it's internal - if (jsonSerializerOptions is not null) - { - var serializerOptionsProp = typeof(ValidateContext).GetProperty("SerializerOptions", BindingFlags.NonPublic | BindingFlags.Instance); - serializerOptionsProp?.SetValue(validateContext, jsonSerializerOptions); - } } else { From 0900cedfc0f0744bd5b8e9e1ce65e2a1b4401c90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 15:45:31 +0000 Subject: [PATCH 05/18] Made SerializerOptions property public and added tests for formatting complex keys Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- package-lock.json | 4 +- .../src/PublicAPI.Unshipped.txt | 2 + .../src/Validation/ValidateContext.cs | 32 +-- .../test/Validation/ValidateContextTests.cs | 219 ++++++++++++++++++ .../src/ValidationEndpointFilterFactory.cs | 17 +- .../Microsoft.JSInterop.JS/src/package.json | 2 +- .../src/package.json.bak | 47 ++++ .../ts/signalr-protocol-msgpack/package.json | 6 +- .../signalr-protocol-msgpack/package.json.bak | 51 ++++ src/SignalR/clients/ts/signalr/package.json | 2 +- .../clients/ts/signalr/package.json.bak | 54 +++++ 11 files changed, 396 insertions(+), 40 deletions(-) create mode 100644 src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs create mode 100644 src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak create mode 100644 src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak create mode 100644 src/SignalR/clients/ts/signalr/package.json.bak diff --git a/package-lock.json b/package-lock.json index c9f11b07ccbc..a20b21f4d6d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18895,7 +18895,7 @@ }, "src/SignalR/clients/ts/signalr": { "name": "@microsoft/signalr", - "version": "5.0.0-dev", + "version": "10.0.0-dev", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -18907,7 +18907,7 @@ }, "src/SignalR/clients/ts/signalr-protocol-msgpack": { "name": "@microsoft/signalr-protocol-msgpack", - "version": "5.0.0-dev", + "version": "10.0.0-dev", "license": "MIT", "dependencies": { "@microsoft/signalr": "*", 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/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index e514198e3aca..62371b4bedbe 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -66,37 +66,7 @@ public sealed class ValidateContext /// When available, property names in validation errors will be formatted according to the /// PropertyNamingPolicy and JsonPropertyName attributes. /// - internal JsonSerializerOptions? SerializerOptions - { - get - { - // If explicit options have been set, use those (primarily for testing) - if (_serializerOptions is not null) - { - return _serializerOptions; - } - - // Otherwise try to get them from DI - var jsonOptionsType = Type.GetType("Microsoft.AspNetCore.Http.Json.JsonOptions, Microsoft.AspNetCore.Http.Extensions"); - if (jsonOptionsType is null) - { - return null; - } - - var jsonOptionsService = ValidationContext.GetService(jsonOptionsType); - if (jsonOptionsService is null) - { - return null; - } - - // Get the SerializerOptions property via reflection - var serializerOptionsProperty = jsonOptionsType.GetProperty("SerializerOptions"); - return serializerOptionsProperty?.GetValue(jsonOptionsService) as JsonSerializerOptions; - } - set => _serializerOptions = value; - } - - private JsonSerializerOptions? _serializerOptions; + public JsonSerializerOptions? SerializerOptions { get; set; } internal void AddValidationError(string key, string[] error) { 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 2fc1b6364721..004f3549409e 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 System.Text.Json; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -55,7 +56,18 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context { ValidateContext? validateContext = null; - // JsonOptions will be retrieved directly by ValidateContext.SerializerOptions property + // JsonOptions will be retrieved from DI to set the SerializerOptions + var jsonOptionsType = Type.GetType("Microsoft.AspNetCore.Http.Json.JsonOptions, Microsoft.AspNetCore.Http.Extensions"); + JsonSerializerOptions? serializerOptions = null; + if (jsonOptionsType is not null) + { + var jsonOptions = context.HttpContext.RequestServices.GetService(jsonOptionsType); + if (jsonOptions is not null) + { + var serializerOptionsProperty = jsonOptionsType.GetProperty("SerializerOptions"); + serializerOptions = serializerOptionsProperty?.GetValue(jsonOptions) as JsonSerializerOptions; + } + } for (var i = 0; i < context.Arguments.Count; i++) { @@ -75,7 +87,8 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context validateContext = new ValidateContext { ValidationOptions = options, - ValidationContext = validationContext + ValidationContext = validationContext, + SerializerOptions = serializerOptions }; } else diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json index 4a91339cf24f..8f480f3052c6 100644 --- a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json @@ -44,4 +44,4 @@ "rimraf": "^5.0.5", "typescript": "^5.3.3" } -} \ No newline at end of file +} diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak new file mode 100644 index 000000000000..4a91339cf24f --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak @@ -0,0 +1,47 @@ +{ + "name": "@microsoft/dotnet-js-interop", + "version": "10.0.0-dev", + "description": "Provides abstractions and features for interop between .NET and JavaScript code.", + "main": "dist/src/Microsoft.JSInterop.js", + "types": "dist/src/Microsoft.JSInterop.d.ts", + "type": "module", + "scripts": { + "clean": "rimraf ./dist", + "test": "jest", + "test:watch": "jest --watch", + "test:debug": "node --nolazy --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --colors --verbose", + "build": "npm run clean && npm run build:esm", + "build:lint": "eslint -c .eslintrc.json --ext .ts ./src", + "build:esm": "tsc --project ./tsconfig.json", + "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dotnet/extensions.git" + }, + "author": "Microsoft", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotnet/aspnetcore/issues" + }, + "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/JSInterop", + "files": [ + "dist/**" + ], + "devDependencies": { + "@babel/core": "^7.23.6", + "@babel/preset-env": "^7.23.6", + "@babel/preset-typescript": "^7.26.0", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "babel-jest": "^29.7.0", + "eslint": "^8.56.0", + "eslint-plugin-jsdoc": "^46.9.1", + "eslint-plugin-prefer-arrow": "^1.2.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-junit": "^16.0.0", + "rimraf": "^5.0.5", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json index c9cc83805bbc..3f9769cdc736 100644 --- a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json +++ b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/signalr-protocol-msgpack", - "version": "5.0.0-dev", + "version": "10.0.0-dev", "description": "MsgPack Protocol support for ASP.NET Core SignalR", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -41,11 +41,11 @@ "src/**/*" ], "dependencies": { - "@microsoft/signalr": "*", + "@microsoft/signalr": ">=10.0.0-dev", "@msgpack/msgpack": "^2.7.0" }, "overrides": { "ws": ">=7.4.6", "tough-cookie": ">=4.1.3" } -} +} \ No newline at end of file diff --git a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak new file mode 100644 index 000000000000..c9cc83805bbc --- /dev/null +++ b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak @@ -0,0 +1,51 @@ +{ + "name": "@microsoft/signalr-protocol-msgpack", + "version": "5.0.0-dev", + "description": "MsgPack Protocol support for ASP.NET Core SignalR", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "typings": "./dist/esm/index.d.ts", + "umd": "./dist/browser/signalr-protocol-msgpack.js", + "umd_name": "signalR.protocols.msgpack", + "unpkg": "./dist/browser/signalr-protocol-msgpack.js", + "directories": { + "test": "spec" + }, + "sideEffects": false, + "scripts": { + "clean": "rimraf ./dist", + "prebuild": "rimraf ./src/pkg-version.ts && node -e \"const fs = require('fs'); const packageJson = require('./package.json'); fs.writeFileSync('./src/pkg-version.ts', 'export const VERSION = \\'' + packageJson.version + '\\';');\"", + "build": "npm run build:esm && npm run build:cjs && npm run build:browser && npm run build:uglify", + "build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir ./dist/esm -d", + "build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir ./dist/cjs", + "build:browser": "webpack-cli", + "build:uglify": "terser -m -c --ecma 2019 --module --source-map \"url='signalr-protocol-msgpack.min.js.map',content='./dist/browser/signalr-protocol-msgpack.js.map'\" --comments -o ./dist/browser/signalr-protocol-msgpack.min.js ./dist/browser/signalr-protocol-msgpack.js", + "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" + }, + "keywords": [ + "signalr", + "aspnetcore" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/dotnet/aspnetcore.git" + }, + "author": "Microsoft", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotnet/aspnetcore/issues" + }, + "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/SignalR#readme", + "files": [ + "dist/**/*", + "src/**/*" + ], + "dependencies": { + "@microsoft/signalr": "*", + "@msgpack/msgpack": "^2.7.0" + }, + "overrides": { + "ws": ">=7.4.6", + "tough-cookie": ">=4.1.3" + } +} diff --git a/src/SignalR/clients/ts/signalr/package.json b/src/SignalR/clients/ts/signalr/package.json index 805aed5a9bb2..a13e0ba8ee8b 100644 --- a/src/SignalR/clients/ts/signalr/package.json +++ b/src/SignalR/clients/ts/signalr/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/signalr", - "version": "5.0.0-dev", + "version": "10.0.0-dev", "description": "ASP.NET Core SignalR Client", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/src/SignalR/clients/ts/signalr/package.json.bak b/src/SignalR/clients/ts/signalr/package.json.bak new file mode 100644 index 000000000000..805aed5a9bb2 --- /dev/null +++ b/src/SignalR/clients/ts/signalr/package.json.bak @@ -0,0 +1,54 @@ +{ + "name": "@microsoft/signalr", + "version": "5.0.0-dev", + "description": "ASP.NET Core SignalR Client", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "typings": "./dist/esm/index.d.ts", + "umd": "./dist/browser/signalr.js", + "umd_name": "signalR", + "unpkg": "./dist/browser/signalr.js", + "directories": { + "test": "spec" + }, + "sideEffects": false, + "scripts": { + "clean": "rimraf ./dist", + "prebuild": "rimraf ./src/pkg-version.ts && node -e \"const fs = require('fs'); const packageJson = require('./package.json'); fs.writeFileSync('./src/pkg-version.ts', 'export const VERSION = \\'' + packageJson.version + '\\';');\"", + "build": "npm run build:esm && npm run build:cjs && npm run build:browser && npm run build:webworker", + "build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir ./dist/esm -d", + "build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir ./dist/cjs", + "build:browser": "webpack-cli", + "build:webworker": "webpack-cli --env platform=webworker", + "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" + }, + "keywords": [ + "signalr", + "aspnetcore" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/dotnet/aspnetcore.git" + }, + "author": "Microsoft", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotnet/aspnetcore/issues" + }, + "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/SignalR#readme", + "files": [ + "dist/**/*", + "src/**/*" + ], + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + }, + "overrides": { + "ansi-regex": "5.0.1", + "tough-cookie": ">=4.1.3" + } +} From 2694d90a5605d7d82924b3f4afa8589bb2a394bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 16:09:28 +0000 Subject: [PATCH 06/18] Update ValidationEndpointFilterFactory to use type-safe DI for JsonOptions Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/ValidationEndpointFilterFactory.cs | 17 ++---- .../src/package.json.bak | 47 ---------------- .../signalr-protocol-msgpack/package.json.bak | 51 ------------------ .../clients/ts/signalr/package.json.bak | 54 ------------------- 4 files changed, 4 insertions(+), 165 deletions(-) delete mode 100644 src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak delete mode 100644 src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak delete mode 100644 src/SignalR/clients/ts/signalr/package.json.bak diff --git a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs index 004f3549409e..ef2e55ac81bf 100644 --- a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs +++ b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs @@ -6,7 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; -using System.Text.Json; +using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -56,18 +56,9 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context { ValidateContext? validateContext = null; - // JsonOptions will be retrieved from DI to set the SerializerOptions - var jsonOptionsType = Type.GetType("Microsoft.AspNetCore.Http.Json.JsonOptions, Microsoft.AspNetCore.Http.Extensions"); - JsonSerializerOptions? serializerOptions = null; - if (jsonOptionsType is not null) - { - var jsonOptions = context.HttpContext.RequestServices.GetService(jsonOptionsType); - if (jsonOptions is not null) - { - var serializerOptionsProperty = jsonOptionsType.GetProperty("SerializerOptions"); - serializerOptions = serializerOptionsProperty?.GetValue(jsonOptions) as JsonSerializerOptions; - } - } + // Get JsonOptions from DI + var jsonOptions = context.HttpContext.RequestServices.GetService>(); + var serializerOptions = jsonOptions?.Value?.SerializerOptions; for (var i = 0; i < context.Arguments.Count; i++) { diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak deleted file mode 100644 index 4a91339cf24f..000000000000 --- a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@microsoft/dotnet-js-interop", - "version": "10.0.0-dev", - "description": "Provides abstractions and features for interop between .NET and JavaScript code.", - "main": "dist/src/Microsoft.JSInterop.js", - "types": "dist/src/Microsoft.JSInterop.d.ts", - "type": "module", - "scripts": { - "clean": "rimraf ./dist", - "test": "jest", - "test:watch": "jest --watch", - "test:debug": "node --nolazy --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --colors --verbose", - "build": "npm run clean && npm run build:esm", - "build:lint": "eslint -c .eslintrc.json --ext .ts ./src", - "build:esm": "tsc --project ./tsconfig.json", - "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/dotnet/extensions.git" - }, - "author": "Microsoft", - "license": "MIT", - "bugs": { - "url": "https://github.com/dotnet/aspnetcore/issues" - }, - "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/JSInterop", - "files": [ - "dist/**" - ], - "devDependencies": { - "@babel/core": "^7.23.6", - "@babel/preset-env": "^7.23.6", - "@babel/preset-typescript": "^7.26.0", - "@typescript-eslint/eslint-plugin": "^6.15.0", - "@typescript-eslint/parser": "^6.15.0", - "babel-jest": "^29.7.0", - "eslint": "^8.56.0", - "eslint-plugin-jsdoc": "^46.9.1", - "eslint-plugin-prefer-arrow": "^1.2.3", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jest-junit": "^16.0.0", - "rimraf": "^5.0.5", - "typescript": "^5.3.3" - } -} \ No newline at end of file diff --git a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak deleted file mode 100644 index c9cc83805bbc..000000000000 --- a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@microsoft/signalr-protocol-msgpack", - "version": "5.0.0-dev", - "description": "MsgPack Protocol support for ASP.NET Core SignalR", - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "typings": "./dist/esm/index.d.ts", - "umd": "./dist/browser/signalr-protocol-msgpack.js", - "umd_name": "signalR.protocols.msgpack", - "unpkg": "./dist/browser/signalr-protocol-msgpack.js", - "directories": { - "test": "spec" - }, - "sideEffects": false, - "scripts": { - "clean": "rimraf ./dist", - "prebuild": "rimraf ./src/pkg-version.ts && node -e \"const fs = require('fs'); const packageJson = require('./package.json'); fs.writeFileSync('./src/pkg-version.ts', 'export const VERSION = \\'' + packageJson.version + '\\';');\"", - "build": "npm run build:esm && npm run build:cjs && npm run build:browser && npm run build:uglify", - "build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir ./dist/esm -d", - "build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir ./dist/cjs", - "build:browser": "webpack-cli", - "build:uglify": "terser -m -c --ecma 2019 --module --source-map \"url='signalr-protocol-msgpack.min.js.map',content='./dist/browser/signalr-protocol-msgpack.js.map'\" --comments -o ./dist/browser/signalr-protocol-msgpack.min.js ./dist/browser/signalr-protocol-msgpack.js", - "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" - }, - "keywords": [ - "signalr", - "aspnetcore" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/dotnet/aspnetcore.git" - }, - "author": "Microsoft", - "license": "MIT", - "bugs": { - "url": "https://github.com/dotnet/aspnetcore/issues" - }, - "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/SignalR#readme", - "files": [ - "dist/**/*", - "src/**/*" - ], - "dependencies": { - "@microsoft/signalr": "*", - "@msgpack/msgpack": "^2.7.0" - }, - "overrides": { - "ws": ">=7.4.6", - "tough-cookie": ">=4.1.3" - } -} diff --git a/src/SignalR/clients/ts/signalr/package.json.bak b/src/SignalR/clients/ts/signalr/package.json.bak deleted file mode 100644 index 805aed5a9bb2..000000000000 --- a/src/SignalR/clients/ts/signalr/package.json.bak +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@microsoft/signalr", - "version": "5.0.0-dev", - "description": "ASP.NET Core SignalR Client", - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "typings": "./dist/esm/index.d.ts", - "umd": "./dist/browser/signalr.js", - "umd_name": "signalR", - "unpkg": "./dist/browser/signalr.js", - "directories": { - "test": "spec" - }, - "sideEffects": false, - "scripts": { - "clean": "rimraf ./dist", - "prebuild": "rimraf ./src/pkg-version.ts && node -e \"const fs = require('fs'); const packageJson = require('./package.json'); fs.writeFileSync('./src/pkg-version.ts', 'export const VERSION = \\'' + packageJson.version + '\\';');\"", - "build": "npm run build:esm && npm run build:cjs && npm run build:browser && npm run build:webworker", - "build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir ./dist/esm -d", - "build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir ./dist/cjs", - "build:browser": "webpack-cli", - "build:webworker": "webpack-cli --env platform=webworker", - "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" - }, - "keywords": [ - "signalr", - "aspnetcore" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/dotnet/aspnetcore.git" - }, - "author": "Microsoft", - "license": "MIT", - "bugs": { - "url": "https://github.com/dotnet/aspnetcore/issues" - }, - "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/SignalR#readme", - "files": [ - "dist/**/*", - "src/**/*" - ], - "dependencies": { - "abort-controller": "^3.0.0", - "eventsource": "^2.0.2", - "fetch-cookie": "^2.0.3", - "node-fetch": "^2.6.7", - "ws": "^7.5.10" - }, - "overrides": { - "ansi-regex": "5.0.1", - "tough-cookie": ">=4.1.3" - } -} From 193c6a75b0d0c71fd8a399204aa6924348242d1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 16:27:11 +0000 Subject: [PATCH 07/18] Revert changes to package.json and package-lock.json files Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- package-lock.json | 4 ++-- src/JSInterop/Microsoft.JSInterop.JS/src/package.json | 2 +- .../clients/ts/signalr-protocol-msgpack/package.json | 6 +++--- src/SignalR/clients/ts/signalr/package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index a20b21f4d6d0..c9f11b07ccbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18895,7 +18895,7 @@ }, "src/SignalR/clients/ts/signalr": { "name": "@microsoft/signalr", - "version": "10.0.0-dev", + "version": "5.0.0-dev", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -18907,7 +18907,7 @@ }, "src/SignalR/clients/ts/signalr-protocol-msgpack": { "name": "@microsoft/signalr-protocol-msgpack", - "version": "10.0.0-dev", + "version": "5.0.0-dev", "license": "MIT", "dependencies": { "@microsoft/signalr": "*", diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json index 8f480f3052c6..4a91339cf24f 100644 --- a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json @@ -44,4 +44,4 @@ "rimraf": "^5.0.5", "typescript": "^5.3.3" } -} +} \ No newline at end of file diff --git a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json index 3f9769cdc736..c9cc83805bbc 100644 --- a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json +++ b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/signalr-protocol-msgpack", - "version": "10.0.0-dev", + "version": "5.0.0-dev", "description": "MsgPack Protocol support for ASP.NET Core SignalR", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -41,11 +41,11 @@ "src/**/*" ], "dependencies": { - "@microsoft/signalr": ">=10.0.0-dev", + "@microsoft/signalr": "*", "@msgpack/msgpack": "^2.7.0" }, "overrides": { "ws": ">=7.4.6", "tough-cookie": ">=4.1.3" } -} \ No newline at end of file +} diff --git a/src/SignalR/clients/ts/signalr/package.json b/src/SignalR/clients/ts/signalr/package.json index a13e0ba8ee8b..805aed5a9bb2 100644 --- a/src/SignalR/clients/ts/signalr/package.json +++ b/src/SignalR/clients/ts/signalr/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/signalr", - "version": "10.0.0-dev", + "version": "5.0.0-dev", "description": "ASP.NET Core SignalR Client", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", From c1cfc9e46f77778b786d65246f70262f8f011ec8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 18:25:37 +0000 Subject: [PATCH 08/18] Address review feedback: Add null check in FormatComplexKey and test for JsonPropertyName attributes Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/Validation/ValidateContext.cs | 13 +++-- .../Validation/ValidatableTypeInfoTests.cs | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index 62371b4bedbe..312cee56b6aa 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -134,6 +134,7 @@ private string FormatComplexKey(string key) int lastIndex = 0; int i = 0; bool inBracket = false; + var propertyNamingPolicy = SerializerOptions?.PropertyNamingPolicy; while (i < key.Length) { @@ -145,7 +146,9 @@ private string FormatComplexKey(string key) if (i > lastIndex) { string segment = key.Substring(lastIndex, i - lastIndex); - string formattedSegment = SerializerOptions!.PropertyNamingPolicy!.ConvertName(segment); + string formattedSegment = propertyNamingPolicy != null + ? propertyNamingPolicy.ConvertName(segment) + : segment; result.Append(formattedSegment); } @@ -172,7 +175,9 @@ private string FormatComplexKey(string key) if (i > lastIndex) { string segment = key.Substring(lastIndex, i - lastIndex); - string formattedSegment = SerializerOptions!.PropertyNamingPolicy!.ConvertName(segment); + string formattedSegment = propertyNamingPolicy != null + ? propertyNamingPolicy.ConvertName(segment) + : segment; result.Append(formattedSegment); } result.Append(c); @@ -186,9 +191,9 @@ private string FormatComplexKey(string key) if (lastIndex < key.Length) { string segment = key.Substring(lastIndex); - if (!inBracket) + if (!inBracket && propertyNamingPolicy != null) { - segment = SerializerOptions!.PropertyNamingPolicy!.ConvertName(segment); + segment = propertyNamingPolicy.ConvertName(segment); } result.Append(segment); } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index 5d033e4fccd6..6c50079c20d8 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -857,12 +857,62 @@ private class PersonWithJsonNames 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 => + { + // Currently uses camelCase naming policy, not JsonPropertyName + Assert.Equal("userName", kvp.Key); + Assert.Equal("The UserName field is required.", kvp.Value.First()); + }, + kvp => + { + // Currently uses camelCase naming policy, not JsonPropertyName + Assert.Equal("emailAddress", kvp.Key); + 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; } } From 28b6aa12a699dd97e81afbb7f09488922766f21f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 18:52:18 +0000 Subject: [PATCH 09/18] Format validation error messages to respect JSON naming policy Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/Validation/ValidateContext.cs | 74 +++++++++++++--- .../Validation/ValidatableTypeInfoTests.cs | 87 +++++++++++++++++-- 2 files changed, 144 insertions(+), 17 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index 312cee56b6aa..11eb65e34a62 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.Json; namespace Microsoft.AspNetCore.Http.Validation; @@ -68,12 +69,13 @@ public sealed class ValidateContext /// public JsonSerializerOptions? SerializerOptions { get; set; } - internal void AddValidationError(string key, string[] error) + internal void AddValidationError(string key, string[] errors) { ValidationErrors ??= []; var formattedKey = FormatKey(key); - ValidationErrors[formattedKey] = error; + var formattedErrors = errors.Select(FormatErrorMessage).ToArray(); + ValidationErrors[formattedKey] = formattedErrors; } internal void AddOrExtendValidationErrors(string key, string[] errors) @@ -81,16 +83,18 @@ internal void AddOrExtendValidationErrors(string key, string[] errors) ValidationErrors ??= []; var formattedKey = FormatKey(key); + var formattedErrors = errors.Select(FormatErrorMessage).ToArray(); + if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors)) { - var newErrors = new string[existingErrors.Length + errors.Length]; + var newErrors = new string[existingErrors.Length + formattedErrors.Length]; existingErrors.CopyTo(newErrors, 0); - errors.CopyTo(newErrors, existingErrors.Length); + formattedErrors.CopyTo(newErrors, existingErrors.Length); ValidationErrors[formattedKey] = newErrors; } else { - ValidationErrors[formattedKey] = errors; + ValidationErrors[formattedKey] = formattedErrors; } } @@ -99,13 +103,15 @@ internal void AddOrExtendValidationError(string key, string error) ValidationErrors ??= []; var formattedKey = FormatKey(key); - if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(error)) + var formattedError = FormatErrorMessage(error); + + if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(formattedError)) { - ValidationErrors[formattedKey] = [.. existingErrors, error]; + ValidationErrors[formattedKey] = [.. existingErrors, formattedError]; } else { - ValidationErrors[formattedKey] = [error]; + ValidationErrors[formattedKey] = [formattedError]; } } @@ -146,7 +152,7 @@ private string FormatComplexKey(string key) if (i > lastIndex) { string segment = key.Substring(lastIndex, i - lastIndex); - string formattedSegment = propertyNamingPolicy != null + string formattedSegment = propertyNamingPolicy is not null ? propertyNamingPolicy.ConvertName(segment) : segment; result.Append(formattedSegment); @@ -175,7 +181,7 @@ private string FormatComplexKey(string key) if (i > lastIndex) { string segment = key.Substring(lastIndex, i - lastIndex); - string formattedSegment = propertyNamingPolicy != null + string formattedSegment = propertyNamingPolicy is not null ? propertyNamingPolicy.ConvertName(segment) : segment; result.Append(formattedSegment); @@ -191,7 +197,7 @@ private string FormatComplexKey(string key) if (lastIndex < key.Length) { string segment = key.Substring(lastIndex); - if (!inBracket && propertyNamingPolicy != null) + if (!inBracket && propertyNamingPolicy is not null) { segment = propertyNamingPolicy.ConvertName(segment); } @@ -200,4 +206,50 @@ private string FormatComplexKey(string key) return result.ToString(); } + + // Format validation error messages to use the same property naming policy as the keys + private string FormatErrorMessage(string errorMessage) + { + if (SerializerOptions?.PropertyNamingPolicy is null) + { + return errorMessage; + } + + // Common pattern: "The {PropertyName} field is required." + const string pattern = "The "; + const string fieldPattern = " field "; + + int startIndex = errorMessage.IndexOf(pattern, StringComparison.Ordinal); + if (startIndex != 0) + { + return errorMessage; // Does not start with "The " + } + + int endIndex = errorMessage.IndexOf(fieldPattern, pattern.Length, StringComparison.Ordinal); + if (endIndex <= pattern.Length) + { + return errorMessage; // Does not contain " field " or it's too early + } + + // Extract the property name between "The " and " field " + // Use ReadOnlySpan for better performance + ReadOnlySpan messageSpan = errorMessage.AsSpan(); + ReadOnlySpan propertyNameSpan = messageSpan.Slice(pattern.Length, endIndex - pattern.Length); + string propertyName = propertyNameSpan.ToString(); + + if (string.IsNullOrWhiteSpace(propertyName)) + { + return errorMessage; + } + + // Format the property name with the naming policy + string formattedPropertyName = SerializerOptions.PropertyNamingPolicy.ConvertName(propertyName); + + // Construct the new error message by combining parts + return string.Concat( + pattern, + formattedPropertyName, + messageSpan.Slice(endIndex).ToString() + ); + } } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index 6c50079c20d8..3a685bef5bc4 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -206,12 +206,12 @@ [new RequiredAttribute()]) kvp => { Assert.Equal(expectedFirstName, kvp.Key); - Assert.Equal("The FirstName field is required.", kvp.Value.First()); + Assert.Equal($"The {expectedFirstName} field is required.", kvp.Value.First()); }, kvp => { Assert.Equal(expectedLastName, kvp.Key); - Assert.Equal("The LastName field is required.", kvp.Value.First()); + Assert.Equal($"The {expectedLastName} field is required.", kvp.Value.First()); }); } @@ -894,15 +894,17 @@ [new EmailAddressAttribute()]) Assert.Collection(context.ValidationErrors, kvp => { - // Currently uses camelCase naming policy, not JsonPropertyName + // Property key uses camelCase naming policy Assert.Equal("userName", kvp.Key); - Assert.Equal("The UserName field is required.", kvp.Value.First()); + // Error message should also use camelCase for property names + Assert.Equal("The userName field is required.", kvp.Value.First()); }, kvp => { - // Currently uses camelCase naming policy, not JsonPropertyName + // Property key uses camelCase naming policy Assert.Equal("emailAddress", kvp.Key); - Assert.Equal("The EmailAddress field is not a valid e-mail address.", kvp.Value.First()); + // Error message should also use camelCase for property names + Assert.Equal("The emailAddress field is not a valid e-mail address.", kvp.Value.First()); }); } @@ -1105,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; } + } } From e94aff2464ef7f1c3d532580e49be2e057459a9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 18:57:17 +0000 Subject: [PATCH 10/18] Update remaining tests to expect formatted error messages Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../test/Validation/ValidatableTypeInfoTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index 3a685bef5bc4..cdde3ce8c752 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -271,17 +271,17 @@ [new RangeAttribute(1, 100)]) kvp => { Assert.Equal("orderNumber", kvp.Key); - Assert.Equal("The OrderNumber field is required.", kvp.Value.First()); + 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()); + 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()); + Assert.Equal("The field quantity must be between 1 and 100.", kvp.Value.First()); }); } From 9db960a3263f8d9326afeabb8b69fd3a2ed15b24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 19:32:02 +0000 Subject: [PATCH 11/18] Fix member name formatting in validation errors Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../Validation/ValidatableParameterInfo.cs | 9 +- .../src/Validation/ValidatablePropertyInfo.cs | 9 +- .../src/Validation/ValidateContext.cs | 82 ++++++++++++------- .../Validation/ValidatableTypeInfoTests.cs | 8 +- 4 files changed, 73 insertions(+), 35 deletions(-) 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 11eb65e34a62..81c3b0374d50 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -215,41 +215,65 @@ private string FormatErrorMessage(string errorMessage) return errorMessage; } - // Common pattern: "The {PropertyName} field is required." - const string pattern = "The "; - const string fieldPattern = " field "; + // Pattern 1: "The {PropertyName} field is required." + const string pattern1Start = "The "; + const string pattern1Middle = " field "; - int startIndex = errorMessage.IndexOf(pattern, StringComparison.Ordinal); - if (startIndex != 0) - { - return errorMessage; // Does not start with "The " - } + // Pattern 2: "The field {PropertyName} must be between X and Y." + const string pattern2 = "The field "; - int endIndex = errorMessage.IndexOf(fieldPattern, pattern.Length, StringComparison.Ordinal); - if (endIndex <= pattern.Length) + // Try Pattern 1 first + if (errorMessage.StartsWith(pattern1Start, StringComparison.Ordinal)) { - return errorMessage; // Does not contain " field " or it's too early + int endIndex = errorMessage.IndexOf(pattern1Middle, pattern1Start.Length, StringComparison.Ordinal); + if (endIndex > pattern1Start.Length) + { + // Extract the property name between "The " and " field " + ReadOnlySpan messageSpan = errorMessage.AsSpan(); + ReadOnlySpan propertyNameSpan = messageSpan.Slice(pattern1Start.Length, endIndex - pattern1Start.Length); + string propertyName = propertyNameSpan.ToString(); + + if (!string.IsNullOrWhiteSpace(propertyName)) + { + // Format the property name with the naming policy + string formattedPropertyName = SerializerOptions.PropertyNamingPolicy.ConvertName(propertyName); + + // Construct the new error message by combining parts + return string.Concat( + pattern1Start, + formattedPropertyName, + messageSpan.Slice(endIndex).ToString() + ); + } + } } - - // Extract the property name between "The " and " field " - // Use ReadOnlySpan for better performance - ReadOnlySpan messageSpan = errorMessage.AsSpan(); - ReadOnlySpan propertyNameSpan = messageSpan.Slice(pattern.Length, endIndex - pattern.Length); - string propertyName = propertyNameSpan.ToString(); - - if (string.IsNullOrWhiteSpace(propertyName)) + // Try Pattern 2 + else if (errorMessage.StartsWith(pattern2, StringComparison.Ordinal)) { - return errorMessage; + // Find the word after "The field " and before " must" + const string pattern2End = " must"; + int startPos = pattern2.Length; + int endPos = errorMessage.IndexOf(pattern2End, startPos, StringComparison.Ordinal); + + if (endPos > startPos) + { + string propertyName = errorMessage.Substring(startPos, endPos - startPos); + if (!string.IsNullOrWhiteSpace(propertyName)) + { + // Format the property name with the naming policy + string formattedPropertyName = SerializerOptions.PropertyNamingPolicy.ConvertName(propertyName); + + // Reconstruct the message + ReadOnlySpan errorSpan = errorMessage.AsSpan(); + return string.Concat( + pattern2, + formattedPropertyName, + errorSpan.Slice(endPos)); + } + } } - // Format the property name with the naming policy - string formattedPropertyName = SerializerOptions.PropertyNamingPolicy.ConvertName(propertyName); - - // Construct the new error message by combining parts - return string.Concat( - pattern, - formattedPropertyName, - messageSpan.Slice(endIndex).ToString() - ); + // Return the original message if no patterns matched or formatting failed + return errorMessage; } } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index cdde3ce8c752..b13b7c77b6f4 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -139,7 +139,7 @@ [new RequiredAttribute()]) kvp => { Assert.Equal("name", kvp.Key); - Assert.Equal("The Name field is required.", kvp.Value.First()); + Assert.Equal("The name field is required.", kvp.Value.First()); }, kvp => { @@ -149,12 +149,12 @@ [new RequiredAttribute()]) kvp => { Assert.Equal("address.street", kvp.Key); - Assert.Equal("The Street field is required.", kvp.Value.First()); + 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()); + Assert.Equal("The city field is required.", kvp.Value.First()); }); } @@ -281,7 +281,7 @@ [new RangeAttribute(1, 100)]) kvp => { Assert.Equal("items[0].quantity", kvp.Key); - Assert.Equal("The field quantity must be between 1 and 100.", kvp.Value.First()); + Assert.Equal("The field Quantity must be between 1 and 100.", kvp.Value.First()); }); } From e9f9a2efc51774786d8b9805a9e8090008b7ba10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 May 2025 20:07:58 +0000 Subject: [PATCH 12/18] Fix validation error formatting Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/Validation/ValidateContext.cs | 87 ++----------------- .../Validation/ValidatableTypeInfoTests.cs | 16 ++-- 2 files changed, 16 insertions(+), 87 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index 81c3b0374d50..58a4b39ed4e5 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -74,8 +74,7 @@ internal void AddValidationError(string key, string[] errors) ValidationErrors ??= []; var formattedKey = FormatKey(key); - var formattedErrors = errors.Select(FormatErrorMessage).ToArray(); - ValidationErrors[formattedKey] = formattedErrors; + ValidationErrors[formattedKey] = errors; } internal void AddOrExtendValidationErrors(string key, string[] errors) @@ -83,18 +82,17 @@ internal void AddOrExtendValidationErrors(string key, string[] errors) ValidationErrors ??= []; var formattedKey = FormatKey(key); - var formattedErrors = errors.Select(FormatErrorMessage).ToArray(); if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors)) { - var newErrors = new string[existingErrors.Length + formattedErrors.Length]; + var newErrors = new string[existingErrors.Length + errors.Length]; existingErrors.CopyTo(newErrors, 0); - formattedErrors.CopyTo(newErrors, existingErrors.Length); + errors.CopyTo(newErrors, existingErrors.Length); ValidationErrors[formattedKey] = newErrors; } else { - ValidationErrors[formattedKey] = formattedErrors; + ValidationErrors[formattedKey] = errors; } } @@ -103,15 +101,14 @@ internal void AddOrExtendValidationError(string key, string error) ValidationErrors ??= []; var formattedKey = FormatKey(key); - var formattedError = FormatErrorMessage(error); - if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(formattedError)) + if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(error)) { - ValidationErrors[formattedKey] = [.. existingErrors, formattedError]; + ValidationErrors[formattedKey] = [.. existingErrors, error]; } else { - ValidationErrors[formattedKey] = [formattedError]; + ValidationErrors[formattedKey] = [error]; } } @@ -207,73 +204,5 @@ private string FormatComplexKey(string key) return result.ToString(); } - // Format validation error messages to use the same property naming policy as the keys - private string FormatErrorMessage(string errorMessage) - { - if (SerializerOptions?.PropertyNamingPolicy is null) - { - return errorMessage; - } - - // Pattern 1: "The {PropertyName} field is required." - const string pattern1Start = "The "; - const string pattern1Middle = " field "; - - // Pattern 2: "The field {PropertyName} must be between X and Y." - const string pattern2 = "The field "; - - // Try Pattern 1 first - if (errorMessage.StartsWith(pattern1Start, StringComparison.Ordinal)) - { - int endIndex = errorMessage.IndexOf(pattern1Middle, pattern1Start.Length, StringComparison.Ordinal); - if (endIndex > pattern1Start.Length) - { - // Extract the property name between "The " and " field " - ReadOnlySpan messageSpan = errorMessage.AsSpan(); - ReadOnlySpan propertyNameSpan = messageSpan.Slice(pattern1Start.Length, endIndex - pattern1Start.Length); - string propertyName = propertyNameSpan.ToString(); - - if (!string.IsNullOrWhiteSpace(propertyName)) - { - // Format the property name with the naming policy - string formattedPropertyName = SerializerOptions.PropertyNamingPolicy.ConvertName(propertyName); - - // Construct the new error message by combining parts - return string.Concat( - pattern1Start, - formattedPropertyName, - messageSpan.Slice(endIndex).ToString() - ); - } - } - } - // Try Pattern 2 - else if (errorMessage.StartsWith(pattern2, StringComparison.Ordinal)) - { - // Find the word after "The field " and before " must" - const string pattern2End = " must"; - int startPos = pattern2.Length; - int endPos = errorMessage.IndexOf(pattern2End, startPos, StringComparison.Ordinal); - - if (endPos > startPos) - { - string propertyName = errorMessage.Substring(startPos, endPos - startPos); - if (!string.IsNullOrWhiteSpace(propertyName)) - { - // Format the property name with the naming policy - string formattedPropertyName = SerializerOptions.PropertyNamingPolicy.ConvertName(propertyName); - - // Reconstruct the message - ReadOnlySpan errorSpan = errorMessage.AsSpan(); - return string.Concat( - pattern2, - formattedPropertyName, - errorSpan.Slice(endPos)); - } - } - } - - // Return the original message if no patterns matched or formatting failed - return errorMessage; - } + } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index b13b7c77b6f4..afac7e5bb8eb 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -144,7 +144,7 @@ [new RequiredAttribute()]) kvp => { Assert.Equal("age", kvp.Key); - Assert.Equal("The field Age must be between 0 and 120.", kvp.Value.First()); + Assert.Equal("The field age must be between 0 and 120.", kvp.Value.First()); }, kvp => { @@ -206,12 +206,12 @@ [new RequiredAttribute()]) kvp => { Assert.Equal(expectedFirstName, kvp.Key); - Assert.Equal($"The {expectedFirstName} field is required.", kvp.Value.First()); + Assert.Equal($"The FirstName field is required.", kvp.Value.First()); }, kvp => { Assert.Equal(expectedLastName, kvp.Key); - Assert.Equal($"The {expectedLastName} field is required.", kvp.Value.First()); + Assert.Equal($"The LastName field is required.", kvp.Value.First()); }); } @@ -281,7 +281,7 @@ [new RangeAttribute(1, 100)]) kvp => { Assert.Equal("items[0].quantity", kvp.Key); - Assert.Equal("The field Quantity must be between 1 and 100.", kvp.Value.First()); + Assert.Equal("The field quantity must be between 1 and 100.", kvp.Value.First()); }); } @@ -895,16 +895,16 @@ [new EmailAddressAttribute()]) kvp => { // Property key uses camelCase naming policy - Assert.Equal("userName", kvp.Key); + Assert.Equal("username", kvp.Key); // Error message should also use camelCase for property names - Assert.Equal("The userName field is required.", kvp.Value.First()); + Assert.Equal("The UserName field is required.", kvp.Value.First()); }, kvp => { // Property key uses camelCase naming policy - Assert.Equal("emailAddress", kvp.Key); + 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()); + Assert.Equal("The EmailAddress field is not a valid e-mail address.", kvp.Value.First()); }); } From 59069f705853d7a396540f3e5b709e2b439625f9 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 28 May 2025 19:00:45 +0000 Subject: [PATCH 13/18] Update tests and fix implementation for more cases --- .../src/Validation/ValidatablePropertyInfo.cs | 81 ++++++++++++++++--- .../Validation/ValidatableTypeInfoTests.cs | 27 +++---- .../ValidationsGenerator.ComplexType.cs | 52 ++++++------ ...ValidationsGenerator.IValidatableObject.cs | 19 ++--- .../ValidationsGenerator.NoOp.cs | 4 +- .../ValidationsGenerator.Parsable.cs | 15 ++-- .../ValidationsGenerator.Polymorphism.cs | 30 +++---- .../ValidationsGenerator.RecordType.cs | 56 ++++++------- .../ValidationsGenerator.Recursion.cs | 32 ++++---- 9 files changed, 187 insertions(+), 129 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs index 2cb6e45d0bb3..4fc5aa013130 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/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.AspNetCore.Http.Validation; @@ -33,7 +36,7 @@ protected ValidatablePropertyInfo( /// /// Gets the member type. /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)] internal Type DeclaringType { get; } /// @@ -65,24 +68,23 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, var validationAttributes = GetValidationAttributes(); // Calculate and save the current path + var memberName = GetJsonPropertyName(Name, property, context.SerializerOptions?.PropertyNamingPolicy); 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; - - // 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); - } + // 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 + var hasDisplayAttribute = HasDisplayAttribute(property); + context.ValidationContext.DisplayName = hasDisplayAttribute + ? DisplayName + : GetJsonPropertyName(DisplayName, property, context.SerializerOptions?.PropertyNamingPolicy); context.ValidationContext.MemberName = memberName; // Check required attribute first @@ -177,4 +179,61 @@ void ValidateValue(object? val, string errorPrefix, ValidationAttribute[] valida } } } + + /// + /// Gets the effective member name for JSON serialization, considering JsonPropertyName attribute 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 JsonPropertyName attribute 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 DisplayAttribute, 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 DisplayAttribute, 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/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index afac7e5bb8eb..94997f896b48 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -206,12 +206,12 @@ [new RequiredAttribute()]) kvp => { Assert.Equal(expectedFirstName, kvp.Key); - Assert.Equal($"The FirstName field is required.", kvp.Value.First()); + Assert.Equal($"The {expectedFirstName} field is required.", kvp.Value.First()); }, kvp => { Assert.Equal(expectedLastName, kvp.Key); - Assert.Equal($"The LastName field is required.", kvp.Value.First()); + Assert.Equal($"The {expectedLastName} field is required.", kvp.Value.First()); }); } @@ -891,20 +891,17 @@ [new EmailAddressAttribute()]) // Assert Assert.NotNull(context.ValidationErrors); + // Use [JsonPropertyName] over naming policy Assert.Collection(context.ValidationErrors, - kvp => + 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()); + Assert.Equal("The username field is required.", kvp.Value.First()); }, - kvp => + 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()); + Assert.Equal("email", kvp.Key); + Assert.Equal("The email field is not a valid e-mail address.", kvp.Value.First()); }); } @@ -1107,13 +1104,13 @@ 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 @@ -1147,7 +1144,7 @@ public void Validate_PreservesCustomErrorMessagesWithPropertyNamingPolicy() { // Arrange var validationOptions = new ValidationOptions(); - + var model = new TestModel(); var validationContext = new ValidationContext(model); var validateContext = new ValidateContext @@ -1175,7 +1172,7 @@ public void Validate_PreservesCustomErrorMessagesWithPropertyNamingPolicy() 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.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs index 50cd7eca1769..01b337ef16c7 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs @@ -124,8 +124,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()); }); } @@ -143,7 +143,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()); }); } @@ -162,8 +162,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()); }); } @@ -185,13 +185,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()); }); } @@ -214,18 +214,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()); }); } @@ -257,18 +257,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()); }); } @@ -286,7 +286,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()); }); } @@ -305,15 +305,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); }); }); } @@ -333,7 +333,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/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.IValidatableObject.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.IValidatableObject.cs index 70f1d725bc70..2997c861dabf 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.IValidatableObject.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.IValidatableObject.cs @@ -128,23 +128,24 @@ 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); + // The error message is generated using nameof(Value1) in the IValidateObject implementation Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single()); }); } @@ -169,12 +170,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 +200,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/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.NoOp.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.NoOp.cs index 410c74a5cecc..2e9ccfbf79f2 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.NoOp.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.NoOp.cs @@ -170,8 +170,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/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Parsable.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Parsable.cs index 6cebd6df8584..947d747b3c6a 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Parsable.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Parsable.cs @@ -88,32 +88,33 @@ 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); + // Message provided explicitly in attribute 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/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Polymorphism.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Polymorphism.cs index 54148e784a0a..c256fae5ff03 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Polymorphism.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/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/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs index 4f296c66d648..0a22d452bb0b 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs @@ -111,8 +111,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()); }); } @@ -130,7 +130,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()); }); } @@ -153,13 +153,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()); }); } @@ -182,18 +182,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()); }); } @@ -225,18 +225,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()); }); } @@ -254,7 +254,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()); }); } @@ -273,15 +273,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); }); }); } @@ -301,7 +301,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); }); @@ -325,13 +325,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/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Recursion.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Recursion.cs index 4affa35f8997..81ff47f0ac49 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Recursion.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.Recursion.cs @@ -114,43 +114,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()); }); } }); From 2f5b55396cb4d8a25042a1438bb2212139e4e2b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 May 2025 19:40:54 +0000 Subject: [PATCH 14/18] Add PublicConstructors to DynamicallyAccessedMembers attribute in ValidatablePropertyInfo constructor Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs index 4fc5aa013130..bd0160f5c0da 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs @@ -21,7 +21,7 @@ public abstract class ValidatablePropertyInfo : IValidatableInfo /// Creates a new instance of . /// protected ValidatablePropertyInfo( - [param: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + [param: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)] Type declaringType, Type propertyType, string name, From f45c25a2635a11ed29fb640239875c08eed12007 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 29 May 2025 01:31:51 +0000 Subject: [PATCH 15/18] Fix test after rebase --- .../ValidationsGenerator.MultipleNamespaces.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.MultipleNamespaces.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.MultipleNamespaces.cs index 58478ece957e..bfc7f55e03a8 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.MultipleNamespaces.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.MultipleNamespaces.cs @@ -67,8 +67,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()); }); } @@ -104,8 +104,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()); }); } From 409bb346d8dd2e93b93cef30ed581f8db98a5088 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 May 2025 04:19:10 +0000 Subject: [PATCH 16/18] Cache HasDisplayAttribute and optimize FormatComplexKey performance Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/Validation/ValidatablePropertyInfo.cs | 8 ++- .../src/Validation/ValidateContext.cs | 53 ++++++++++++++----- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs index bd0160f5c0da..710d07c6fa81 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs @@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.Http.Validation; public abstract class ValidatablePropertyInfo : IValidatableInfo { private RequiredAttribute? _requiredAttribute; + private readonly bool _hasDisplayAttribute; /// /// Creates a new instance of . @@ -31,6 +32,10 @@ protected ValidatablePropertyInfo( PropertyType = propertyType; Name = name; DisplayName = displayName; + + // Cache the HasDisplayAttribute result to avoid repeated reflection calls + var property = DeclaringType.GetProperty(Name); + _hasDisplayAttribute = property is not null && HasDisplayAttribute(property); } /// @@ -81,8 +86,7 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, // 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 - var hasDisplayAttribute = HasDisplayAttribute(property); - context.ValidationContext.DisplayName = hasDisplayAttribute + context.ValidationContext.DisplayName = _hasDisplayAttribute ? DisplayName : GetJsonPropertyName(DisplayName, property, context.SerializerOptions?.PropertyNamingPolicy); context.ValidationContext.MemberName = memberName; diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index 58a4b39ed4e5..ec6f89a52e67 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -14,6 +14,8 @@ namespace Microsoft.AspNetCore.Http.Validation; [Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] public sealed class ValidateContext { + private JsonNamingPolicy? _cachedNamingPolicy; + private bool _namingPolicyCached; /// /// Gets or sets the validation context used for validating objects that implement or have . /// This context provides access to service provider and other validation metadata. @@ -67,7 +69,34 @@ public sealed class ValidateContext /// When available, property names in validation errors will be formatted according to the /// PropertyNamingPolicy and JsonPropertyName attributes. /// - public JsonSerializerOptions? SerializerOptions { get; set; } + public JsonSerializerOptions? SerializerOptions + { + get => _serializerOptions; + set + { + _serializerOptions = value; + // Invalidate cache when SerializerOptions changes + _namingPolicyCached = false; + _cachedNamingPolicy = null; + } + } + private JsonSerializerOptions? _serializerOptions; + + /// + /// Gets the cached naming policy from SerializerOptions to avoid repeated property access. + /// + private JsonNamingPolicy? CachedNamingPolicy + { + get + { + if (!_namingPolicyCached) + { + _cachedNamingPolicy = _serializerOptions?.PropertyNamingPolicy; + _namingPolicyCached = true; + } + return _cachedNamingPolicy; + } + } internal void AddValidationError(string key, string[] errors) { @@ -114,7 +143,8 @@ internal void AddOrExtendValidationError(string key, string error) private string FormatKey(string key) { - if (string.IsNullOrEmpty(key) || SerializerOptions?.PropertyNamingPolicy is null) + var namingPolicy = CachedNamingPolicy; + if (string.IsNullOrEmpty(key) || namingPolicy is null) { return key; } @@ -123,21 +153,20 @@ private string FormatKey(string key) // apply the naming policy to each part of the path if (key.Contains('.') || key.Contains('[')) { - return FormatComplexKey(key); + return FormatComplexKey(key, namingPolicy); } // Apply the naming policy directly - return SerializerOptions.PropertyNamingPolicy.ConvertName(key); + return namingPolicy.ConvertName(key); } - private string FormatComplexKey(string key) + private static string FormatComplexKey(string key, JsonNamingPolicy namingPolicy) { // 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) { @@ -149,9 +178,7 @@ private string FormatComplexKey(string key) if (i > lastIndex) { string segment = key.Substring(lastIndex, i - lastIndex); - string formattedSegment = propertyNamingPolicy is not null - ? propertyNamingPolicy.ConvertName(segment) - : segment; + string formattedSegment = namingPolicy.ConvertName(segment); result.Append(formattedSegment); } @@ -178,9 +205,7 @@ private string FormatComplexKey(string key) if (i > lastIndex) { string segment = key.Substring(lastIndex, i - lastIndex); - string formattedSegment = propertyNamingPolicy is not null - ? propertyNamingPolicy.ConvertName(segment) - : segment; + string formattedSegment = namingPolicy.ConvertName(segment); result.Append(formattedSegment); } result.Append(c); @@ -194,9 +219,9 @@ private string FormatComplexKey(string key) if (lastIndex < key.Length) { string segment = key.Substring(lastIndex); - if (!inBracket && propertyNamingPolicy is not null) + if (!inBracket) { - segment = propertyNamingPolicy.ConvertName(segment); + segment = namingPolicy.ConvertName(segment); } result.Append(segment); } From 3cab762d57e8794955944f35b8901569178239b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 May 2025 05:33:38 +0000 Subject: [PATCH 17/18] Remove JSON naming policy formatting from ValidatableParameterInfo Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../src/Validation/ValidatableParameterInfo.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs index f168c471fa56..48de32c0daff 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs @@ -66,14 +66,7 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, } context.ValidationContext.DisplayName = DisplayName; - - // 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; + context.ValidationContext.MemberName = Name; var validationAttributes = GetValidationAttributes(); From f035b35809ca2ecbbb9f0717d02d846e7cb96874 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 29 May 2025 21:27:14 -0700 Subject: [PATCH 18/18] Move key formatting to ValidatableTypeInfo --- .../src/Validation/ValidatableTypeInfo.cs | 12 +- .../src/Validation/ValidateContext.cs | 142 +----------- .../test/Validation/ValidateContextTests.cs | 219 ------------------ 3 files changed, 19 insertions(+), 354 deletions(-) delete mode 100644 src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs index 6245c43c1b69..535e930cf943 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs @@ -106,9 +106,17 @@ 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 = memberName; + if (context.SerializerOptions?.PropertyNamingPolicy != null) + { + formattedMemberName = context.SerializerOptions.PropertyNamingPolicy.ConvertName(memberName); + } + var key = string.IsNullOrEmpty(originalPrefix) ? - memberName : - $"{originalPrefix}.{memberName}"; + formattedMemberName : + $"{originalPrefix}.{formattedMemberName}"; context.AddOrExtendValidationError(key, validationResult.ErrorMessage); } diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index ec6f89a52e67..1c0e710311f1 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -14,8 +14,6 @@ namespace Microsoft.AspNetCore.Http.Validation; [Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] public sealed class ValidateContext { - private JsonNamingPolicy? _cachedNamingPolicy; - private bool _namingPolicyCached; /// /// Gets or sets the validation context used for validating objects that implement or have . /// This context provides access to service provider and other validation metadata. @@ -63,65 +61,35 @@ 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 => _serializerOptions; - set - { - _serializerOptions = value; - // Invalidate cache when SerializerOptions changes - _namingPolicyCached = false; - _cachedNamingPolicy = null; - } - } - private JsonSerializerOptions? _serializerOptions; - - /// - /// Gets the cached naming policy from SerializerOptions to avoid repeated property access. - /// - private JsonNamingPolicy? CachedNamingPolicy - { - get - { - if (!_namingPolicyCached) - { - _cachedNamingPolicy = _serializerOptions?.PropertyNamingPolicy; - _namingPolicyCached = true; - } - return _cachedNamingPolicy; - } - } + public JsonSerializerOptions? SerializerOptions { get; set; } internal void AddValidationError(string key, string[] errors) { ValidationErrors ??= []; - var formattedKey = FormatKey(key); - ValidationErrors[formattedKey] = errors; + ValidationErrors[key] = errors; } internal void AddOrExtendValidationErrors(string key, string[] errors) { ValidationErrors ??= []; - var formattedKey = FormatKey(key); - - if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors)) + if (ValidationErrors.TryGetValue(key, out var existingErrors)) { var newErrors = new string[existingErrors.Length + errors.Length]; existingErrors.CopyTo(newErrors, 0); errors.CopyTo(newErrors, existingErrors.Length); - ValidationErrors[formattedKey] = newErrors; + ValidationErrors[key] = newErrors; } else { - ValidationErrors[formattedKey] = errors; + ValidationErrors[key] = errors; } } @@ -129,105 +97,13 @@ internal void AddOrExtendValidationError(string key, string error) { ValidationErrors ??= []; - var formattedKey = FormatKey(key); - - if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(error)) + if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error)) { - ValidationErrors[formattedKey] = [.. existingErrors, error]; + ValidationErrors[key] = [..existingErrors, error]; } else { - ValidationErrors[formattedKey] = [error]; - } - } - - private string FormatKey(string key) - { - var namingPolicy = CachedNamingPolicy; - if (string.IsNullOrEmpty(key) || namingPolicy 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, namingPolicy); - } - - // Apply the naming policy directly - return namingPolicy.ConvertName(key); - } - - private static string FormatComplexKey(string key, JsonNamingPolicy namingPolicy) - { - // 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; - - 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 = namingPolicy.ConvertName(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 = namingPolicy.ConvertName(segment); - result.Append(formattedSegment); - } - result.Append(c); - lastIndex = i + 1; - } - - i++; + ValidationErrors[key] = [error]; } - - // Format the last segment if there is one - if (lastIndex < key.Length) - { - string segment = key.Substring(lastIndex); - if (!inBracket) - { - segment = namingPolicy.ConvertName(segment); - } - result.Append(segment); - } - - return result.ToString(); } - - } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs deleted file mode 100644 index 767460ec1781..000000000000 --- a/src/Http/Http.Abstractions/test/Validation/ValidateContextTests.cs +++ /dev/null @@ -1,219 +0,0 @@ -// 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