Skip to content

Commit f29313d

Browse files
committed
Respect JsonSerializerOptions in validation errors
1 parent 1ce2228 commit f29313d

13 files changed

+705
-148
lines changed

src/Validation/src/ValidatablePropertyInfo.cs

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
using System.ComponentModel.DataAnnotations;
55
using System.Diagnostics.CodeAnalysis;
6+
using System.Reflection;
7+
using System.Text.Json;
8+
using System.Text.Json.Serialization;
69

710
namespace Microsoft.Extensions.Validation;
811

@@ -13,12 +16,13 @@ namespace Microsoft.Extensions.Validation;
1316
public abstract class ValidatablePropertyInfo : IValidatableInfo
1417
{
1518
private RequiredAttribute? _requiredAttribute;
19+
private readonly bool _hasDisplayAttribute;
1620

1721
/// <summary>
1822
/// Creates a new instance of <see cref="ValidatablePropertyInfo"/>.
1923
/// </summary>
2024
protected ValidatablePropertyInfo(
21-
[param: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
25+
[param: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)]
2226
Type declaringType,
2327
Type propertyType,
2428
string name,
@@ -28,12 +32,16 @@ protected ValidatablePropertyInfo(
2832
PropertyType = propertyType;
2933
Name = name;
3034
DisplayName = displayName;
35+
36+
// Cache the HasDisplayAttribute result to avoid repeated reflection calls
37+
var property = DeclaringType.GetProperty(Name);
38+
_hasDisplayAttribute = property is not null && HasDisplayAttribute(property);
3139
}
3240

3341
/// <summary>
3442
/// Gets the member type.
3543
/// </summary>
36-
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
44+
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)]
3745
internal Type DeclaringType { get; }
3846

3947
/// <summary>
@@ -64,19 +72,27 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
6472
var propertyValue = property.GetValue(value);
6573
var validationAttributes = GetValidationAttributes();
6674

75+
// Get JsonSerializerOptions from DI container
76+
var namingPolicy = context.SerializerOptions?.PropertyNamingPolicy;
77+
6778
// Calculate and save the current path
79+
var memberName = GetJsonPropertyName(Name, property, namingPolicy);
6880
var originalPrefix = context.CurrentValidationPath;
6981
if (string.IsNullOrEmpty(originalPrefix))
7082
{
71-
context.CurrentValidationPath = Name;
83+
context.CurrentValidationPath = memberName;
7284
}
7385
else
7486
{
75-
context.CurrentValidationPath = $"{originalPrefix}.{Name}";
87+
context.CurrentValidationPath = $"{originalPrefix}.{memberName}";
7688
}
7789

78-
context.ValidationContext.DisplayName = DisplayName;
79-
context.ValidationContext.MemberName = Name;
90+
// Format the display name and member name according to JsonPropertyName attribute first, then naming policy
91+
// If the property has a [Display] attribute (either on property or record parameter), use DisplayName directly without formatting
92+
context.ValidationContext.DisplayName = _hasDisplayAttribute
93+
? DisplayName
94+
: GetJsonPropertyName(DisplayName, property, namingPolicy);
95+
context.ValidationContext.MemberName = memberName;
8096

8197
// Check required attribute first
8298
if (_requiredAttribute is not null || validationAttributes.TryGetRequiredAttribute(out _requiredAttribute))
@@ -170,4 +186,61 @@ void ValidateValue(object? val, string errorPrefix, ValidationAttribute[] valida
170186
}
171187
}
172188
}
189+
190+
/// <summary>
191+
/// Gets the effective member name for JSON serialization, considering <see cref="JsonPropertyNameAttribute"/> and naming policy.
192+
/// </summary>
193+
/// <param name="targetValue">The target value to get the name for.</param>
194+
/// <param name="property">The property info to get the name for.</param>
195+
/// <param name="namingPolicy">The JSON naming policy to apply if no <see cref="JsonPropertyNameAttribute"/> is present.</param>
196+
/// <returns>The effective property name for JSON serialization.</returns>
197+
private static string GetJsonPropertyName(string targetValue, PropertyInfo property, JsonNamingPolicy? namingPolicy)
198+
{
199+
var jsonPropertyName = property.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name;
200+
201+
if (jsonPropertyName is not null)
202+
{
203+
return jsonPropertyName;
204+
}
205+
206+
if (namingPolicy is not null)
207+
{
208+
return namingPolicy.ConvertName(targetValue);
209+
}
210+
211+
return targetValue;
212+
}
213+
214+
/// <summary>
215+
/// Determines whether the property has a <see cref="DisplayAttribute"/>, either directly on the property
216+
/// or on the corresponding constructor parameter if the declaring type is a record.
217+
/// </summary>
218+
/// <param name="property">The property to check.</param>
219+
/// <returns>True if the property has a <see cref="DisplayAttribute"/> , false otherwise.</returns>
220+
private bool HasDisplayAttribute(PropertyInfo property)
221+
{
222+
// Check if the property itself has the DisplayAttribute with a valid Name
223+
if (property.GetCustomAttribute<DisplayAttribute>() is { Name: not null })
224+
{
225+
return true;
226+
}
227+
228+
// Look for a constructor parameter matching the property name (case-insensitive)
229+
// to account for the record scenario
230+
foreach (var constructor in DeclaringType.GetConstructors())
231+
{
232+
foreach (var parameter in constructor.GetParameters())
233+
{
234+
if (string.Equals(parameter.Name, property.Name, StringComparison.OrdinalIgnoreCase))
235+
{
236+
if (parameter.GetCustomAttribute<DisplayAttribute>() is { Name: not null })
237+
{
238+
return true;
239+
}
240+
}
241+
}
242+
}
243+
244+
return false;
245+
}
173246
}

src/Validation/src/ValidatableTypeInfo.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,13 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
106106
// Create a validation error for each member name that is provided
107107
foreach (var memberName in validationResult.MemberNames)
108108
{
109+
// Format the member name using JsonSerializerOptions naming policy if available
110+
// Note: we don't respect [JsonPropertyName] here because we have no context of the property being validated.
111+
var formattedMemberName = context.SerializerOptions?.PropertyNamingPolicy?.ConvertName(memberName) ?? memberName;
112+
109113
var key = string.IsNullOrEmpty(originalPrefix) ?
110-
memberName :
111-
$"{originalPrefix}.{memberName}";
114+
formattedMemberName :
115+
$"{originalPrefix}.{formattedMemberName}";
112116
context.AddOrExtendValidationError(key, validationResult.ErrorMessage);
113117
}
114118

src/Validation/src/ValidateContext.cs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using System.ComponentModel.DataAnnotations;
55
using System.Diagnostics.CodeAnalysis;
6+
using System.Text.Json;
7+
using Microsoft.Extensions.Options;
68

79
namespace Microsoft.Extensions.Validation;
810

@@ -60,10 +62,55 @@ public sealed class ValidateContext
6062
/// </summary>
6163
public int CurrentDepth { get; set; }
6264

65+
private JsonSerializerOptions? _cachedSerializerOptions;
66+
private bool _serializerOptionsResolved;
67+
68+
internal JsonSerializerOptions? SerializerOptions
69+
{
70+
get
71+
{
72+
if (_serializerOptionsResolved)
73+
{
74+
return _cachedSerializerOptions;
75+
}
76+
77+
_cachedSerializerOptions = ResolveSerializerOptions();
78+
_serializerOptionsResolved = true;
79+
return _cachedSerializerOptions;
80+
}
81+
}
82+
83+
/// <summary>
84+
/// Attempts to resolve the <see cref="JsonSerializerOptions"/> used for serialization
85+
/// using reflection to access JsonOptions from the ASP.NET Core shared framework.
86+
/// </summary>
87+
private JsonSerializerOptions? ResolveSerializerOptions()
88+
{
89+
var targetType = "Microsoft.AspNetCore.Http.Json.JsonOptions, Microsoft.AspNetCore.Http.Extensions";
90+
var jsonOptionsType = Type.GetType(targetType, throwOnError: false);
91+
if (jsonOptionsType is null)
92+
{
93+
return null;
94+
}
95+
96+
var iOptionsType = typeof(IOptions<>).MakeGenericType(jsonOptionsType);
97+
98+
var optionsObj = ValidationContext.GetService(iOptionsType);
99+
if (optionsObj is null)
100+
{
101+
return null;
102+
}
103+
104+
var valueProp = iOptionsType.GetProperty("Value")!;
105+
var jsonOptions = valueProp.GetValue(optionsObj);
106+
var serializerProp = jsonOptionsType.GetProperty("SerializerOptions")!;
107+
108+
return serializerProp.GetValue(jsonOptions) as JsonSerializerOptions;
109+
}
110+
63111
internal void AddValidationError(string key, string[] error)
64112
{
65113
ValidationErrors ??= [];
66-
67114
ValidationErrors[key] = error;
68115
}
69116

@@ -90,7 +137,7 @@ internal void AddOrExtendValidationError(string key, string error)
90137

91138
if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error))
92139
{
93-
ValidationErrors[key] = [.. existingErrors, error];
140+
ValidationErrors[key] = [..existingErrors, error];
94141
}
95142
else
96143
{

src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint)
126126
var problemDetails = await AssertBadRequest(context);
127127
Assert.Collection(problemDetails.Errors, kvp =>
128128
{
129-
Assert.Equal("IntegerWithRange", kvp.Key);
130-
Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single());
129+
Assert.Equal("integerWithRange", kvp.Key);
130+
Assert.Equal("The field integerWithRange must be between 10 and 100.", kvp.Value.Single());
131131
});
132132
}
133133

@@ -145,7 +145,7 @@ async Task InvalidIntegerWithRangeAndDisplayNameProducesError(Endpoint endpoint)
145145
var problemDetails = await AssertBadRequest(context);
146146
Assert.Collection(problemDetails.Errors, kvp =>
147147
{
148-
Assert.Equal("IntegerWithRangeAndDisplayName", kvp.Key);
148+
Assert.Equal("integerWithRangeAndDisplayName", kvp.Key);
149149
Assert.Equal("The field Valid identifier must be between 10 and 100.", kvp.Value.Single());
150150
});
151151
}
@@ -164,8 +164,8 @@ async Task MissingRequiredSubtypePropertyProducesError(Endpoint endpoint)
164164
var problemDetails = await AssertBadRequest(context);
165165
Assert.Collection(problemDetails.Errors, kvp =>
166166
{
167-
Assert.Equal("PropertyWithMemberAttributes", kvp.Key);
168-
Assert.Equal("The PropertyWithMemberAttributes field is required.", kvp.Value.Single());
167+
Assert.Equal("propertyWithMemberAttributes", kvp.Key);
168+
Assert.Equal("The propertyWithMemberAttributes field is required.", kvp.Value.Single());
169169
});
170170
}
171171

@@ -187,13 +187,13 @@ async Task InvalidRequiredSubtypePropertyProducesError(Endpoint endpoint)
187187
Assert.Collection(problemDetails.Errors,
188188
kvp =>
189189
{
190-
Assert.Equal("PropertyWithMemberAttributes.RequiredProperty", kvp.Key);
191-
Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
190+
Assert.Equal("propertyWithMemberAttributes.requiredProperty", kvp.Key);
191+
Assert.Equal("The requiredProperty field is required.", kvp.Value.Single());
192192
},
193193
kvp =>
194194
{
195-
Assert.Equal("PropertyWithMemberAttributes.StringWithLength", kvp.Key);
196-
Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
195+
Assert.Equal("propertyWithMemberAttributes.stringWithLength", kvp.Key);
196+
Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
197197
});
198198
}
199199

@@ -216,18 +216,18 @@ async Task InvalidSubTypeWithInheritancePropertyProducesError(Endpoint endpoint)
216216
Assert.Collection(problemDetails.Errors,
217217
kvp =>
218218
{
219-
Assert.Equal("PropertyWithInheritance.EmailString", kvp.Key);
220-
Assert.Equal("The EmailString field is not a valid e-mail address.", kvp.Value.Single());
219+
Assert.Equal("propertyWithInheritance.emailString", kvp.Key);
220+
Assert.Equal("The emailString field is not a valid e-mail address.", kvp.Value.Single());
221221
},
222222
kvp =>
223223
{
224-
Assert.Equal("PropertyWithInheritance.RequiredProperty", kvp.Key);
225-
Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
224+
Assert.Equal("propertyWithInheritance.requiredProperty", kvp.Key);
225+
Assert.Equal("The requiredProperty field is required.", kvp.Value.Single());
226226
},
227227
kvp =>
228228
{
229-
Assert.Equal("PropertyWithInheritance.StringWithLength", kvp.Key);
230-
Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
229+
Assert.Equal("propertyWithInheritance.stringWithLength", kvp.Key);
230+
Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
231231
});
232232
}
233233

@@ -259,18 +259,18 @@ async Task InvalidListOfSubTypesProducesError(Endpoint endpoint)
259259
Assert.Collection(problemDetails.Errors,
260260
kvp =>
261261
{
262-
Assert.Equal("ListOfSubTypes[0].RequiredProperty", kvp.Key);
263-
Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
262+
Assert.Equal("listOfSubTypes[0].requiredProperty", kvp.Key);
263+
Assert.Equal("The requiredProperty field is required.", kvp.Value.Single());
264264
},
265265
kvp =>
266266
{
267-
Assert.Equal("ListOfSubTypes[0].StringWithLength", kvp.Key);
268-
Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
267+
Assert.Equal("listOfSubTypes[0].stringWithLength", kvp.Key);
268+
Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
269269
},
270270
kvp =>
271271
{
272-
Assert.Equal("ListOfSubTypes[1].StringWithLength", kvp.Key);
273-
Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
272+
Assert.Equal("listOfSubTypes[1].stringWithLength", kvp.Key);
273+
Assert.Equal("The field stringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
274274
});
275275
}
276276

@@ -288,7 +288,7 @@ async Task InvalidPropertyWithDerivedValidationAttributeProducesError(Endpoint e
288288
var problemDetails = await AssertBadRequest(context);
289289
Assert.Collection(problemDetails.Errors, kvp =>
290290
{
291-
Assert.Equal("IntegerWithDerivedValidationAttribute", kvp.Key);
291+
Assert.Equal("integerWithDerivedValidationAttribute", kvp.Key);
292292
Assert.Equal("Value must be an even number", kvp.Value.Single());
293293
});
294294
}
@@ -297,7 +297,7 @@ async Task InvalidPropertyWithMultipleAttributesProducesError(Endpoint endpoint)
297297
{
298298
var payload = """
299299
{
300-
"PropertyWithMultipleAttributes": 5
300+
"propertyWithMultipleAttributes": 5
301301
}
302302
""";
303303
var context = CreateHttpContextWithPayload(payload, serviceProvider);
@@ -307,15 +307,15 @@ async Task InvalidPropertyWithMultipleAttributesProducesError(Endpoint endpoint)
307307
var problemDetails = await AssertBadRequest(context);
308308
Assert.Collection(problemDetails.Errors, kvp =>
309309
{
310-
Assert.Equal("PropertyWithMultipleAttributes", kvp.Key);
310+
Assert.Equal("propertyWithMultipleAttributes", kvp.Key);
311311
Assert.Collection(kvp.Value,
312312
error =>
313313
{
314-
Assert.Equal("The field PropertyWithMultipleAttributes is invalid.", error);
314+
Assert.Equal("The field propertyWithMultipleAttributes is invalid.", error);
315315
},
316316
error =>
317317
{
318-
Assert.Equal("The field PropertyWithMultipleAttributes must be between 10 and 100.", error);
318+
Assert.Equal("The field propertyWithMultipleAttributes must be between 10 and 100.", error);
319319
});
320320
});
321321
}
@@ -335,7 +335,7 @@ async Task InvalidPropertyWithCustomValidationProducesError(Endpoint endpoint)
335335
var problemDetails = await AssertBadRequest(context);
336336
Assert.Collection(problemDetails.Errors, kvp =>
337337
{
338-
Assert.Equal("IntegerWithCustomValidation", kvp.Key);
338+
Assert.Equal("integerWithCustomValidation", kvp.Key);
339339
var error = Assert.Single(kvp.Value);
340340
Assert.Equal("Can't use the same number value in two properties on the same class.", error);
341341
});

0 commit comments

Comments
 (0)