Skip to content

Commit ee27af8

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

14 files changed

+664
-147
lines changed

src/Validation/src/Microsoft.Extensions.Validation.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
</PropertyGroup>
1313

1414
<ItemGroup>
15+
<Reference Include="Microsoft.AspNetCore.Http.Extensions" PrivateAssets="all" />
1516
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
1617
<Reference Include="Microsoft.Extensions.Options" />
1718
</ItemGroup>

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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
using System.ComponentModel.DataAnnotations;
55
using System.Diagnostics.CodeAnalysis;
6+
using System.Linq;
7+
using System.Text.Json;
8+
using Microsoft.AspNetCore.Http.Json;
9+
using Microsoft.Extensions.Options;
610

711
namespace Microsoft.Extensions.Validation;
812

@@ -60,6 +64,8 @@ public sealed class ValidateContext
6064
/// </summary>
6165
public int CurrentDepth { get; set; }
6266

67+
internal JsonSerializerOptions? SerializerOptions => ValidationContext.GetService(typeof(IOptions<JsonOptions>)) is IOptions<JsonOptions> options ? options.Value.SerializerOptions : null;
68+
6369
internal void AddValidationError(string key, string[] error)
6470
{
6571
ValidationErrors ??= [];
@@ -90,7 +96,7 @@ internal void AddOrExtendValidationError(string key, string error)
9096

9197
if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error))
9298
{
93-
ValidationErrors[key] = [.. existingErrors, error];
99+
ValidationErrors[key] = [..existingErrors, error];
94100
}
95101
else
96102
{

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
});

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,23 +128,23 @@ async Task ValidateMethodCalledIfPropertyValidationsFail()
128128
Assert.Collection(problemDetails.Errors,
129129
error =>
130130
{
131-
Assert.Equal("Value2", error.Key);
131+
Assert.Equal("value2", error.Key);
132132
Assert.Collection(error.Value,
133-
msg => Assert.Equal("The Value2 field is required.", msg));
133+
msg => Assert.Equal("The value2 field is required.", msg));
134134
},
135135
error =>
136136
{
137-
Assert.Equal("SubType.RequiredProperty", error.Key);
138-
Assert.Equal("The RequiredProperty field is required.", error.Value.Single());
137+
Assert.Equal("subType.requiredProperty", error.Key);
138+
Assert.Equal("The requiredProperty field is required.", error.Value.Single());
139139
},
140140
error =>
141141
{
142-
Assert.Equal("SubType.Value3", error.Key);
142+
Assert.Equal("subType.value3", error.Key);
143143
Assert.Equal("The field ValidatableSubType must be 'some-value'.", error.Value.Single());
144144
},
145145
error =>
146146
{
147-
Assert.Equal("Value1", error.Key);
147+
Assert.Equal("value1", error.Key);
148148
Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single());
149149
});
150150
}
@@ -169,12 +169,12 @@ async Task ValidateForSubtypeInvokedFirst()
169169
Assert.Collection(problemDetails.Errors,
170170
error =>
171171
{
172-
Assert.Equal("SubType.Value3", error.Key);
172+
Assert.Equal("subType.value3", error.Key);
173173
Assert.Equal("The field ValidatableSubType must be 'some-value'.", error.Value.Single());
174174
},
175175
error =>
176176
{
177-
Assert.Equal("Value1", error.Key);
177+
Assert.Equal("value1", error.Key);
178178
Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single());
179179
});
180180
}
@@ -199,7 +199,7 @@ async Task ValidateForTopLevelInvoked()
199199
Assert.Collection(problemDetails.Errors,
200200
error =>
201201
{
202-
Assert.Equal("Value1", error.Key);
202+
Assert.Equal("value1", error.Key);
203203
Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single());
204204
});
205205
}

0 commit comments

Comments
 (0)