diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs index 6bf3ff182d19..a20f0ec8a800 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs @@ -104,12 +104,20 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, { if (validationResult != ValidationResult.Success && validationResult.ErrorMessage is not null) { - var memberName = validationResult.MemberNames.First(); - var key = string.IsNullOrEmpty(originalPrefix) ? - memberName : - $"{originalPrefix}.{memberName}"; - - context.AddOrExtendValidationError(key, validationResult.ErrorMessage); + // Create a validation error for each member name that is provided + foreach (var memberName in validationResult.MemberNames) + { + var key = string.IsNullOrEmpty(originalPrefix) ? + memberName : + $"{originalPrefix}.{memberName}"; + context.AddOrExtendValidationError(key, validationResult.ErrorMessage); + } + + if (!validationResult.MemberNames.Any()) + { + // If no member names are specified, then treat this as a top-level error + context.AddOrExtendValidationError(string.Empty, validationResult.ErrorMessage); + } } } diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index fe75387e8e22..2a4c82e704e5 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -495,6 +495,95 @@ public async Task Validate_RequiredOnPropertyShortCircuitsOtherValidations() Assert.Equal("The Password field is required.", error.Value.Single()); } + [Fact] + public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_BehavesAsExpected() + { + var globalType = new TestValidatableTypeInfo( + typeof(GlobalErrorObject), + []); // no properties – nothing sets MemberName + + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(GlobalErrorObject), globalType } + }) + }; + + var globalErrorInstance = new GlobalErrorObject { Data = -1 }; + context.ValidationContext = new ValidationContext(globalErrorInstance); + + await globalType.ValidateAsync(globalErrorInstance, context, default); + + Assert.NotNull(context.ValidationErrors); + var globalError = Assert.Single(context.ValidationErrors); + Assert.Equal(string.Empty, globalError.Key); + Assert.Equal("Data must be positive.", globalError.Value.Single()); + + var multiType = new TestValidatableTypeInfo( + typeof(MultiMemberErrorObject), + [ + CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "FirstName", "FirstName", []), + CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "LastName", "LastName", []) + ]); + + context.ValidationErrors = []; + context.ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(MultiMemberErrorObject), multiType } + }); + + var multiErrorInstance = new MultiMemberErrorObject { FirstName = "", LastName = "" }; + context.ValidationContext = new ValidationContext(multiErrorInstance); + + await multiType.ValidateAsync(multiErrorInstance, 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()); + }); + } + + // Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739 + private class GlobalErrorObject : IValidatableObject + { + public int Data { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Data <= 0) + { + yield return new ValidationResult("Data must be positive."); + } + } + } + + // Returns multiple member names to validate https://github.com/dotnet/aspnetcore/issues/61739 + private class MultiMemberErrorObject : IValidatableObject + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(FirstName) || string.IsNullOrEmpty(LastName)) + { + // MULTIPLE member names + yield return new ValidationResult( + "FirstName and LastName are required.", + [nameof(FirstName), nameof(LastName)]); + } + } + } + private ValidatablePropertyInfo CreatePropertyInfo( Type containingType, Type propertyType, @@ -534,7 +623,7 @@ public IEnumerable Validate(ValidationContext validationContex { if (Salary < 0) { - yield return new ValidationResult("Salary must be a positive value.", new[] { nameof(Salary) }); + yield return new ValidationResult("Salary must be a positive value.", ["Salary"]); } } }