Skip to content

Commit d12915f

Browse files
authored
Make IValidatableObject handling more resilient for MemberNames (#61778)
1 parent 83e90c4 commit d12915f

File tree

2 files changed

+104
-7
lines changed

2 files changed

+104
-7
lines changed

src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,20 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
104104
{
105105
if (validationResult != ValidationResult.Success && validationResult.ErrorMessage is not null)
106106
{
107-
var memberName = validationResult.MemberNames.First();
108-
var key = string.IsNullOrEmpty(originalPrefix) ?
109-
memberName :
110-
$"{originalPrefix}.{memberName}";
111-
112-
context.AddOrExtendValidationError(key, validationResult.ErrorMessage);
107+
// Create a validation error for each member name that is provided
108+
foreach (var memberName in validationResult.MemberNames)
109+
{
110+
var key = string.IsNullOrEmpty(originalPrefix) ?
111+
memberName :
112+
$"{originalPrefix}.{memberName}";
113+
context.AddOrExtendValidationError(key, validationResult.ErrorMessage);
114+
}
115+
116+
if (!validationResult.MemberNames.Any())
117+
{
118+
// If no member names are specified, then treat this as a top-level error
119+
context.AddOrExtendValidationError(string.Empty, validationResult.ErrorMessage);
120+
}
113121
}
114122
}
115123

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

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,95 @@ public async Task Validate_RequiredOnPropertyShortCircuitsOtherValidations()
495495
Assert.Equal("The Password field is required.", error.Value.Single());
496496
}
497497

498+
[Fact]
499+
public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_BehavesAsExpected()
500+
{
501+
var globalType = new TestValidatableTypeInfo(
502+
typeof(GlobalErrorObject),
503+
[]); // no properties – nothing sets MemberName
504+
505+
var context = new ValidateContext
506+
{
507+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
508+
{
509+
{ typeof(GlobalErrorObject), globalType }
510+
})
511+
};
512+
513+
var globalErrorInstance = new GlobalErrorObject { Data = -1 };
514+
context.ValidationContext = new ValidationContext(globalErrorInstance);
515+
516+
await globalType.ValidateAsync(globalErrorInstance, context, default);
517+
518+
Assert.NotNull(context.ValidationErrors);
519+
var globalError = Assert.Single(context.ValidationErrors);
520+
Assert.Equal(string.Empty, globalError.Key);
521+
Assert.Equal("Data must be positive.", globalError.Value.Single());
522+
523+
var multiType = new TestValidatableTypeInfo(
524+
typeof(MultiMemberErrorObject),
525+
[
526+
CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "FirstName", "FirstName", []),
527+
CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "LastName", "LastName", [])
528+
]);
529+
530+
context.ValidationErrors = [];
531+
context.ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
532+
{
533+
{ typeof(MultiMemberErrorObject), multiType }
534+
});
535+
536+
var multiErrorInstance = new MultiMemberErrorObject { FirstName = "", LastName = "" };
537+
context.ValidationContext = new ValidationContext(multiErrorInstance);
538+
539+
await multiType.ValidateAsync(multiErrorInstance, context, default);
540+
541+
Assert.NotNull(context.ValidationErrors);
542+
Assert.Collection(context.ValidationErrors,
543+
kvp =>
544+
{
545+
Assert.Equal("FirstName", kvp.Key);
546+
Assert.Equal("FirstName and LastName are required.", kvp.Value.First());
547+
},
548+
kvp =>
549+
{
550+
Assert.Equal("LastName", kvp.Key);
551+
Assert.Equal("FirstName and LastName are required.", kvp.Value.First());
552+
});
553+
}
554+
555+
// Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739
556+
private class GlobalErrorObject : IValidatableObject
557+
{
558+
public int Data { get; set; }
559+
560+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
561+
{
562+
if (Data <= 0)
563+
{
564+
yield return new ValidationResult("Data must be positive.");
565+
}
566+
}
567+
}
568+
569+
// Returns multiple member names to validate https://github.com/dotnet/aspnetcore/issues/61739
570+
private class MultiMemberErrorObject : IValidatableObject
571+
{
572+
public string? FirstName { get; set; }
573+
public string? LastName { get; set; }
574+
575+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
576+
{
577+
if (string.IsNullOrEmpty(FirstName) || string.IsNullOrEmpty(LastName))
578+
{
579+
// MULTIPLE member names
580+
yield return new ValidationResult(
581+
"FirstName and LastName are required.",
582+
[nameof(FirstName), nameof(LastName)]);
583+
}
584+
}
585+
}
586+
498587
private ValidatablePropertyInfo CreatePropertyInfo(
499588
Type containingType,
500589
Type propertyType,
@@ -534,7 +623,7 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
534623
{
535624
if (Salary < 0)
536625
{
537-
yield return new ValidationResult("Salary must be a positive value.", new[] { nameof(Salary) });
626+
yield return new ValidationResult("Salary must be a positive value.", ["Salary"]);
538627
}
539628
}
540629
}

0 commit comments

Comments
 (0)