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