Skip to content

Commit 28b6aa1

Browse files
Copilotcaptainsafia
andcommitted
Format validation error messages to respect JSON naming policy
Co-authored-by: captainsafia <[email protected]>
1 parent c1cfc9e commit 28b6aa1

File tree

2 files changed

+144
-17
lines changed

2 files changed

+144
-17
lines changed

src/Http/Http.Abstractions/src/Validation/ValidateContext.cs

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.ComponentModel.DataAnnotations;
55
using System.Diagnostics.CodeAnalysis;
6+
using System.Linq;
67
using System.Text.Json;
78

89
namespace Microsoft.AspNetCore.Http.Validation;
@@ -68,29 +69,32 @@ public sealed class ValidateContext
6869
/// </summary>
6970
public JsonSerializerOptions? SerializerOptions { get; set; }
7071

71-
internal void AddValidationError(string key, string[] error)
72+
internal void AddValidationError(string key, string[] errors)
7273
{
7374
ValidationErrors ??= [];
7475

7576
var formattedKey = FormatKey(key);
76-
ValidationErrors[formattedKey] = error;
77+
var formattedErrors = errors.Select(FormatErrorMessage).ToArray();
78+
ValidationErrors[formattedKey] = formattedErrors;
7779
}
7880

7981
internal void AddOrExtendValidationErrors(string key, string[] errors)
8082
{
8183
ValidationErrors ??= [];
8284

8385
var formattedKey = FormatKey(key);
86+
var formattedErrors = errors.Select(FormatErrorMessage).ToArray();
87+
8488
if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors))
8589
{
86-
var newErrors = new string[existingErrors.Length + errors.Length];
90+
var newErrors = new string[existingErrors.Length + formattedErrors.Length];
8791
existingErrors.CopyTo(newErrors, 0);
88-
errors.CopyTo(newErrors, existingErrors.Length);
92+
formattedErrors.CopyTo(newErrors, existingErrors.Length);
8993
ValidationErrors[formattedKey] = newErrors;
9094
}
9195
else
9296
{
93-
ValidationErrors[formattedKey] = errors;
97+
ValidationErrors[formattedKey] = formattedErrors;
9498
}
9599
}
96100

@@ -99,13 +103,15 @@ internal void AddOrExtendValidationError(string key, string error)
99103
ValidationErrors ??= [];
100104

101105
var formattedKey = FormatKey(key);
102-
if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(error))
106+
var formattedError = FormatErrorMessage(error);
107+
108+
if (ValidationErrors.TryGetValue(formattedKey, out var existingErrors) && !existingErrors.Contains(formattedError))
103109
{
104-
ValidationErrors[formattedKey] = [.. existingErrors, error];
110+
ValidationErrors[formattedKey] = [.. existingErrors, formattedError];
105111
}
106112
else
107113
{
108-
ValidationErrors[formattedKey] = [error];
114+
ValidationErrors[formattedKey] = [formattedError];
109115
}
110116
}
111117

@@ -146,7 +152,7 @@ private string FormatComplexKey(string key)
146152
if (i > lastIndex)
147153
{
148154
string segment = key.Substring(lastIndex, i - lastIndex);
149-
string formattedSegment = propertyNamingPolicy != null
155+
string formattedSegment = propertyNamingPolicy is not null
150156
? propertyNamingPolicy.ConvertName(segment)
151157
: segment;
152158
result.Append(formattedSegment);
@@ -175,7 +181,7 @@ private string FormatComplexKey(string key)
175181
if (i > lastIndex)
176182
{
177183
string segment = key.Substring(lastIndex, i - lastIndex);
178-
string formattedSegment = propertyNamingPolicy != null
184+
string formattedSegment = propertyNamingPolicy is not null
179185
? propertyNamingPolicy.ConvertName(segment)
180186
: segment;
181187
result.Append(formattedSegment);
@@ -191,7 +197,7 @@ private string FormatComplexKey(string key)
191197
if (lastIndex < key.Length)
192198
{
193199
string segment = key.Substring(lastIndex);
194-
if (!inBracket && propertyNamingPolicy != null)
200+
if (!inBracket && propertyNamingPolicy is not null)
195201
{
196202
segment = propertyNamingPolicy.ConvertName(segment);
197203
}
@@ -200,4 +206,50 @@ private string FormatComplexKey(string key)
200206

201207
return result.ToString();
202208
}
209+
210+
// Format validation error messages to use the same property naming policy as the keys
211+
private string FormatErrorMessage(string errorMessage)
212+
{
213+
if (SerializerOptions?.PropertyNamingPolicy is null)
214+
{
215+
return errorMessage;
216+
}
217+
218+
// Common pattern: "The {PropertyName} field is required."
219+
const string pattern = "The ";
220+
const string fieldPattern = " field ";
221+
222+
int startIndex = errorMessage.IndexOf(pattern, StringComparison.Ordinal);
223+
if (startIndex != 0)
224+
{
225+
return errorMessage; // Does not start with "The "
226+
}
227+
228+
int endIndex = errorMessage.IndexOf(fieldPattern, pattern.Length, StringComparison.Ordinal);
229+
if (endIndex <= pattern.Length)
230+
{
231+
return errorMessage; // Does not contain " field " or it's too early
232+
}
233+
234+
// Extract the property name between "The " and " field "
235+
// Use ReadOnlySpan<char> for better performance
236+
ReadOnlySpan<char> messageSpan = errorMessage.AsSpan();
237+
ReadOnlySpan<char> propertyNameSpan = messageSpan.Slice(pattern.Length, endIndex - pattern.Length);
238+
string propertyName = propertyNameSpan.ToString();
239+
240+
if (string.IsNullOrWhiteSpace(propertyName))
241+
{
242+
return errorMessage;
243+
}
244+
245+
// Format the property name with the naming policy
246+
string formattedPropertyName = SerializerOptions.PropertyNamingPolicy.ConvertName(propertyName);
247+
248+
// Construct the new error message by combining parts
249+
return string.Concat(
250+
pattern,
251+
formattedPropertyName,
252+
messageSpan.Slice(endIndex).ToString()
253+
);
254+
}
203255
}

src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,12 @@ [new RequiredAttribute()])
206206
kvp =>
207207
{
208208
Assert.Equal(expectedFirstName, kvp.Key);
209-
Assert.Equal("The FirstName field is required.", kvp.Value.First());
209+
Assert.Equal($"The {expectedFirstName} field is required.", kvp.Value.First());
210210
},
211211
kvp =>
212212
{
213213
Assert.Equal(expectedLastName, kvp.Key);
214-
Assert.Equal("The LastName field is required.", kvp.Value.First());
214+
Assert.Equal($"The {expectedLastName} field is required.", kvp.Value.First());
215215
});
216216
}
217217

@@ -894,15 +894,17 @@ [new EmailAddressAttribute()])
894894
Assert.Collection(context.ValidationErrors,
895895
kvp =>
896896
{
897-
// Currently uses camelCase naming policy, not JsonPropertyName
897+
// Property key uses camelCase naming policy
898898
Assert.Equal("userName", kvp.Key);
899-
Assert.Equal("The UserName field is required.", kvp.Value.First());
899+
// Error message should also use camelCase for property names
900+
Assert.Equal("The userName field is required.", kvp.Value.First());
900901
},
901902
kvp =>
902903
{
903-
// Currently uses camelCase naming policy, not JsonPropertyName
904+
// Property key uses camelCase naming policy
904905
Assert.Equal("emailAddress", kvp.Key);
905-
Assert.Equal("The EmailAddress field is not a valid e-mail address.", kvp.Value.First());
906+
// Error message should also use camelCase for property names
907+
Assert.Equal("The emailAddress field is not a valid e-mail address.", kvp.Value.First());
906908
});
907909
}
908910

@@ -1105,4 +1107,77 @@ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNull
11051107
}
11061108
}
11071109
}
1110+
1111+
[Fact]
1112+
public void Validate_FormatsErrorMessagesWithPropertyNamingPolicy()
1113+
{
1114+
// Arrange
1115+
var validationOptions = new ValidationOptions();
1116+
1117+
var address = new Address();
1118+
var validationContext = new ValidationContext(address);
1119+
var validateContext = new ValidateContext
1120+
{
1121+
ValidationOptions = validationOptions,
1122+
ValidationContext = validationContext,
1123+
SerializerOptions = new JsonSerializerOptions
1124+
{
1125+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
1126+
}
1127+
};
1128+
1129+
var propertyInfo = CreatePropertyInfo(
1130+
typeof(Address),
1131+
typeof(string),
1132+
"Street",
1133+
"Street",
1134+
[new RequiredAttribute()]);
1135+
1136+
// Act
1137+
propertyInfo.ValidateAsync(address, validateContext, CancellationToken.None);
1138+
1139+
// Assert
1140+
var error = Assert.Single(validateContext.ValidationErrors!);
1141+
Assert.Equal("street", error.Key);
1142+
Assert.Equal("The street field is required.", error.Value.First());
1143+
}
1144+
1145+
[Fact]
1146+
public void Validate_PreservesCustomErrorMessagesWithPropertyNamingPolicy()
1147+
{
1148+
// Arrange
1149+
var validationOptions = new ValidationOptions();
1150+
1151+
var model = new TestModel();
1152+
var validationContext = new ValidationContext(model);
1153+
var validateContext = new ValidateContext
1154+
{
1155+
ValidationOptions = validationOptions,
1156+
ValidationContext = validationContext,
1157+
SerializerOptions = new JsonSerializerOptions
1158+
{
1159+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
1160+
}
1161+
};
1162+
1163+
var propertyInfo = CreatePropertyInfo(
1164+
typeof(TestModel),
1165+
typeof(string),
1166+
"CustomProperty",
1167+
"CustomProperty",
1168+
[new RequiredAttribute { ErrorMessage = "Custom message without standard format." }]);
1169+
1170+
// Act
1171+
propertyInfo.ValidateAsync(model, validateContext, CancellationToken.None);
1172+
1173+
// Assert
1174+
var error = Assert.Single(validateContext.ValidationErrors!);
1175+
Assert.Equal("customProperty", error.Key);
1176+
Assert.Equal("Custom message without standard format.", error.Value.First()); // Custom message without formatting
1177+
}
1178+
1179+
private class TestModel
1180+
{
1181+
public string? CustomProperty { get; set; }
1182+
}
11081183
}

0 commit comments

Comments
 (0)