From 52024a60e581cf69c05191969a29c8e5059add96 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 9 May 2025 11:05:53 -0700 Subject: [PATCH 1/2] Tolerate null ValidationContext in validation resolver APIs --- .../Validation/ValidatableParameterInfo.cs | 8 ++- .../src/Validation/ValidatablePropertyInfo.cs | 5 +- .../src/Validation/ValidatableTypeInfo.cs | 6 +- .../ValidatableParameterInfoTests.cs | 54 ++++++++++++++++ .../Validation/ValidatableTypeInfoTests.cs | 63 +++++++++++++++++++ 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs index 9fba8ab854b4..75c1e86955ab 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs @@ -3,7 +3,6 @@ using System.Collections; using System.ComponentModel.DataAnnotations; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Http.Validation; @@ -60,14 +59,17 @@ protected ValidatableParameterInfo( /// public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) { - Debug.Assert(context.ValidationContext is not null); - // Skip validation if value is null and parameter is optional if (value == null && ParameterType.IsNullable()) { return; } + // ValidationContext requires a non-null value although the invocation pattern that we use + // calls `GetValidationResult` and passes the value there. `GetValidationResult` tolerates + // null values so we only need to set a non-null value to the ValidationContext here. + context.ValidationContext ??= new ValidationContext(value ?? new object(), displayName: DisplayName, serviceProvider: null, items: null); + context.ValidationContext.DisplayName = DisplayName; context.ValidationContext.MemberName = Name; diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs index 167d54500466..22ce94077909 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Http.Validation; @@ -61,8 +60,6 @@ protected ValidatablePropertyInfo( /// public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) { - Debug.Assert(context.ValidationContext is not null); - var property = DeclaringType.GetProperty(Name) ?? throw new InvalidOperationException($"Property '{Name}' not found on type '{DeclaringType.Name}'."); var propertyValue = property.GetValue(value); var validationAttributes = GetValidationAttributes(); @@ -78,6 +75,8 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, context.CurrentValidationPath = $"{originalPrefix}.{Name}"; } + context.ValidationContext ??= new ValidationContext(value ?? new object(), displayName: DisplayName, serviceProvider: null, items: null); + context.ValidationContext.DisplayName = DisplayName; context.ValidationContext.MemberName = Name; diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs index 82ae03465f9b..c61d7e796423 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -45,12 +44,15 @@ protected ValidatableTypeInfo( /// public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) { - Debug.Assert(context.ValidationContext is not null); if (value == null) { return; } + // Although classes can be annotated with [DisplayName], we only process display names when producing + // errors for properties so we can pass the `Type.Name` as the display name for the type here. + context.ValidationContext ??= new ValidationContext(value, displayName: Type.Name, serviceProvider: null, items: null); + // Check if we've exceeded the maximum depth if (context.CurrentDepth >= context.ValidationOptions.MaxDepth) { diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs index e10614b2b19e..d75585ab0c90 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs @@ -287,6 +287,60 @@ public async Task Validate_ExceptionDuringValidation_CapturesExceptionAsError() Assert.Equal("Test exception", error.Value.First()); } + [Fact] + public async Task Validate_WithoutValidationContext_RequiredParameter_AddsErrorAndInitializesValidationContext() + { + // Arrange + var paramInfo = CreateTestParameterInfo( + parameterType: typeof(string), + name: "testParam", + displayName: "Test Parameter", + validationAttributes: [new RequiredAttribute()]); + + // Create a ValidateContext without a pre-populated ValidationContext + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary()) + }; + + // Sanity check + Assert.Null(context.ValidationContext); + + // Act + await paramInfo.ValidateAsync(null, context, default); + + // Assert – a ValidationContext should have been created and the error recorded + Assert.NotNull(context.ValidationContext); + var errors = context.ValidationErrors; + Assert.NotNull(errors); + var error = Assert.Single(errors); + Assert.Equal("testParam", error.Key); + Assert.Equal("The Test Parameter field is required.", error.Value.Single()); + } + + [Fact] + public async Task Validate_WithoutValidationContext_ValidValue_NoErrors() + { + // Arrange + var paramInfo = CreateTestParameterInfo( + parameterType: typeof(int), + name: "testParam", + displayName: "Test Parameter", + validationAttributes: [new RangeAttribute(10, 100)]); + + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary()) + }; + + // Act + await paramInfo.ValidateAsync(50, context, default); + + // Assert – ValidationContext initialized, but no errors added + Assert.NotNull(context.ValidationContext); + Assert.Null(context.ValidationErrors); + } + private TestValidatableParameterInfo CreateTestParameterInfo( Type parameterType, string name, diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index 98e74bd9d32a..6aaf899bdcdb 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -554,6 +554,69 @@ public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_Beh }); } + [Fact] + public async Task Validate_WithoutValidationContext_AddsErrorAndInitializesValidationContext() + { + // Arrange + var personType = new TestValidatableTypeInfo( + typeof(Person), + [ + // Only the Name property is required for this test + CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name", + [new RequiredAttribute()]) + ]); + + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(Person), personType } + }) + }; + + var person = new Person(); // Name is null → fails validation + Assert.Null(context.ValidationContext); // ensure no ValidationContext pre-set + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationContext); + Assert.NotNull(context.ValidationErrors); + var error = Assert.Single(context.ValidationErrors); + Assert.Equal("Name", error.Key); + Assert.Equal("The Name field is required.", error.Value.First()); + } + + [Fact] + public async Task Validate_WithoutValidationContext_ValidObject_NoErrors() + { + // Arrange + var personType = new TestValidatableTypeInfo( + typeof(Person), + [ + CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name", + [new RequiredAttribute()]) + ]); + + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(Person), personType } + }) + }; + + var person = new Person { Name = "Alice" }; + + // Act + await personType.ValidateAsync(person, context, default); + + // Assert + Assert.NotNull(context.ValidationContext); + Assert.Null(context.ValidationErrors); + } + // Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739 private class GlobalErrorObject : IValidatableObject { From 1ec6dcfbfe1752194c8625a4ca6ee3721d4f85ce Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Tue, 13 May 2025 11:18:01 -0700 Subject: [PATCH 2/2] Make ValidationContext a required property --- .../src/PublicAPI.Unshipped.txt | 2 +- .../Validation/ValidatableParameterInfo.cs | 5 - .../src/Validation/ValidatablePropertyInfo.cs | 2 - .../src/Validation/ValidatableTypeInfo.cs | 4 - .../src/Validation/ValidateContext.cs | 19 +- .../ValidatableParameterInfoTests.cs | 54 ----- .../Validation/ValidatableTypeInfoTests.cs | 196 ++++++------------ .../src/ValidationEndpointFilterFactory.cs | 23 +- 8 files changed, 98 insertions(+), 207 deletions(-) diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 2524b5a9cefe..8cb332fa9f20 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -24,7 +24,7 @@ Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentDepth.set -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentValidationPath.get -> string! Microsoft.AspNetCore.Http.Validation.ValidateContext.CurrentValidationPath.set -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidateContext() -> void -Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.get -> System.ComponentModel.DataAnnotations.ValidationContext? +Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.get -> System.ComponentModel.DataAnnotations.ValidationContext! Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationContext.set -> void Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationErrors.get -> System.Collections.Generic.Dictionary? Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationErrors.set -> void diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs index 75c1e86955ab..48de32c0daff 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs @@ -65,11 +65,6 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, return; } - // ValidationContext requires a non-null value although the invocation pattern that we use - // calls `GetValidationResult` and passes the value there. `GetValidationResult` tolerates - // null values so we only need to set a non-null value to the ValidationContext here. - context.ValidationContext ??= new ValidationContext(value ?? new object(), displayName: DisplayName, serviceProvider: null, items: null); - context.ValidationContext.DisplayName = DisplayName; context.ValidationContext.MemberName = Name; diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs index 22ce94077909..0b16e34d1dc9 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs @@ -75,8 +75,6 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, context.CurrentValidationPath = $"{originalPrefix}.{Name}"; } - context.ValidationContext ??= new ValidationContext(value ?? new object(), displayName: DisplayName, serviceProvider: null, items: null); - context.ValidationContext.DisplayName = DisplayName; context.ValidationContext.MemberName = Name; diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs index c61d7e796423..6245c43c1b69 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs @@ -49,10 +49,6 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, return; } - // Although classes can be annotated with [DisplayName], we only process display names when producing - // errors for properties so we can pass the `Type.Name` as the display name for the type here. - context.ValidationContext ??= new ValidationContext(value, displayName: Type.Name, serviceProvider: null, items: null); - // Check if we've exceeded the maximum depth if (context.CurrentDepth >= context.ValidationOptions.MaxDepth) { diff --git a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs index 3e02c35a4722..d38ada2ddeb1 100644 --- a/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs +++ b/src/Http/Http.Abstractions/src/Validation/ValidateContext.cs @@ -16,7 +16,24 @@ public sealed class ValidateContext /// Gets or sets the validation context used for validating objects that implement or have . /// This context provides access to service provider and other validation metadata. /// - public ValidationContext? ValidationContext { get; set; } + /// + /// This property should be set by the consumer of the + /// interface to provide the necessary context for validation. The object should be initialized + /// with the current object being validated, the display name, and the service provider to support + /// the complete set of validation scenarios. + /// + /// + /// + /// var validationContext = new ValidationContext(objectToValidate, serviceProvider, items); + /// var validationOptions = serviceProvider.GetService<IOptions<ValidationOptions>>()?.Value; + /// var validateContext = new ValidateContext + /// { + /// ValidationContext = validationContext, + /// ValidationOptions = validationOptions + /// }; + /// + /// + public required ValidationContext ValidationContext { get; set; } /// /// Gets or sets the prefix used to identify the current object being validated in a complex object graph. diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs index d75585ab0c90..e10614b2b19e 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs @@ -287,60 +287,6 @@ public async Task Validate_ExceptionDuringValidation_CapturesExceptionAsError() Assert.Equal("Test exception", error.Value.First()); } - [Fact] - public async Task Validate_WithoutValidationContext_RequiredParameter_AddsErrorAndInitializesValidationContext() - { - // Arrange - var paramInfo = CreateTestParameterInfo( - parameterType: typeof(string), - name: "testParam", - displayName: "Test Parameter", - validationAttributes: [new RequiredAttribute()]); - - // Create a ValidateContext without a pre-populated ValidationContext - var context = new ValidateContext - { - ValidationOptions = new TestValidationOptions(new Dictionary()) - }; - - // Sanity check - Assert.Null(context.ValidationContext); - - // Act - await paramInfo.ValidateAsync(null, context, default); - - // Assert – a ValidationContext should have been created and the error recorded - Assert.NotNull(context.ValidationContext); - var errors = context.ValidationErrors; - Assert.NotNull(errors); - var error = Assert.Single(errors); - Assert.Equal("testParam", error.Key); - Assert.Equal("The Test Parameter field is required.", error.Value.Single()); - } - - [Fact] - public async Task Validate_WithoutValidationContext_ValidValue_NoErrors() - { - // Arrange - var paramInfo = CreateTestParameterInfo( - parameterType: typeof(int), - name: "testParam", - displayName: "Test Parameter", - validationAttributes: [new RangeAttribute(10, 100)]); - - var context = new ValidateContext - { - ValidationOptions = new TestValidationOptions(new Dictionary()) - }; - - // Act - await paramInfo.ValidateAsync(50, context, default); - - // Assert – ValidationContext initialized, but no errors added - Assert.NotNull(context.ValidationContext); - Assert.Null(context.ValidationErrors); - } - private TestValidatableParameterInfo CreateTestParameterInfo( Type parameterType, string name, diff --git a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index 6aaf899bdcdb..a6123bb11c67 100644 --- a/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs +++ b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs @@ -41,17 +41,16 @@ [new RequiredAttribute()]) { typeof(Address), addressType } }); - var context = new ValidateContext - { - ValidationOptions = validationOptions, - }; - var personWithMissingRequiredFields = new Person { Age = 150, // Invalid age Address = new Address() // Missing required City and Street }; - context.ValidationContext = new ValidationContext(personWithMissingRequiredFields); + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(personWithMissingRequiredFields) + }; // Act await personType.ValidateAsync(personWithMissingRequiredFields, context, default); @@ -96,21 +95,20 @@ [new RequiredAttribute()]), []) ]); - var context = new ValidateContext - { - ValidationOptions = new TestValidationOptions(new Dictionary - { - { typeof(Employee), employeeType } - }) - }; - var employee = new Employee { Name = "John Doe", Department = "IT", Salary = -5000 // Negative salary will trigger IValidatableObject validation }; - context.ValidationContext = new ValidationContext(employee); + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(Employee), employeeType } + }), + ValidationContext = new ValidationContext(employee) + }; // Act await employeeType.ValidateAsync(employee, context, default); @@ -142,22 +140,21 @@ [new RequiredAttribute()]) [new RangeAttribute(2, 5)]) ]); + var car = new Car + { + // Missing Make and Model (required in base type) + Doors = 7 // Invalid number of doors + }; var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary { { typeof(Vehicle), baseType }, { typeof(Car), derivedType } - }) + }), + ValidationContext = new ValidationContext(car) }; - var car = new Car - { - // Missing Make and Model (required in base type) - Doors = 7 // Invalid number of doors - }; - context.ValidationContext = new ValidationContext(car); - // Act await derivedType.ValidateAsync(car, context, default); @@ -203,15 +200,6 @@ [new RequiredAttribute()]), []) ]); - var context = new ValidateContext - { - ValidationOptions = new TestValidationOptions(new Dictionary - { - { typeof(OrderItem), itemType }, - { typeof(Order), orderType } - }) - }; - var order = new Order { OrderNumber = "ORD-12345", @@ -222,7 +210,15 @@ [new RequiredAttribute()]), new OrderItem { ProductName = "Another Product", Quantity = 200 /* Invalid quantity */ } ] }; - context.ValidationContext = new ValidationContext(order); + var context = new ValidateContext + { + ValidationOptions = new TestValidationOptions(new Dictionary + { + { typeof(OrderItem), itemType }, + { typeof(Order), orderType } + }), + ValidationContext = new ValidationContext(order) + }; // Act await orderType.ValidateAsync(order, context, default); @@ -260,21 +256,20 @@ public async Task Validate_HandlesNullValues_Appropriately() []) ]); + var person = new Person + { + Name = null, + Address = null + }; var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary { { typeof(Person), personType } - }) + }), + ValidationContext = new ValidationContext(person) }; - var person = new Person - { - Name = null, - Address = null - }; - context.ValidationContext = new ValidationContext(person); - // Act await personType.ValidateAsync(person, context, default); @@ -305,12 +300,6 @@ [new RequiredAttribute()]), }); validationOptions.MaxDepth = 3; // Set a small max depth to trigger the limit - var context = new ValidateContext - { - ValidationOptions = validationOptions, - ValidationErrors = [] - }; - // Create a deep tree with circular references var rootNode = new TreeNode { Name = "Root" }; var level1 = new TreeNode { Name = "Level1", Parent = rootNode }; @@ -328,7 +317,12 @@ [new RequiredAttribute()]), // Add a circular reference level5.Children.Add(rootNode); - context.ValidationContext = new ValidationContext(rootNode); + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationErrors = [], + ValidationContext = new ValidationContext(rootNode) + }; // Act + Assert var exception = await Assert.ThrowsAsync( @@ -349,17 +343,16 @@ public async Task Validate_HandlesCustomValidationAttributes() CreatePropertyInfo(typeof(Product), typeof(string), "SKU", "SKU", [new RequiredAttribute(), new CustomSkuValidationAttribute()]), ]); + var product = new Product { SKU = "INVALID-SKU" }; var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary { { typeof(Product), productType } - }) + }), + ValidationContext = new ValidationContext(product) }; - var product = new Product { SKU = "INVALID-SKU" }; - context.ValidationContext = new ValidationContext(product); - // Act await productType.ValidateAsync(product, context, default); @@ -385,17 +378,16 @@ public async Task Validate_HandlesMultipleErrorsOnSameProperty() ]) ]); + var user = new User { Password = "abc" }; // Too short and not complex enough var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary { { typeof(User), userType } - }) + }), + ValidationContext = new ValidationContext(user) }; - var user = new User { Password = "abc" }; // Too short and not complex enough - context.ValidationContext = new ValidationContext(user); - // Act await userType.ValidateAsync(user, context, default); @@ -429,6 +421,11 @@ public async Task Validate_HandlesMultiLevelInheritance() CreatePropertyInfo(typeof(DerivedEntity), typeof(string), "Name", "Name", [new RequiredAttribute()]) ]); + var entity = new DerivedEntity + { + Name = "", // Invalid: required + CreatedAt = DateTime.Now.AddDays(1) // Invalid: future date + }; var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary @@ -436,16 +433,10 @@ public async Task Validate_HandlesMultiLevelInheritance() { typeof(BaseEntity), baseType }, { typeof(IntermediateEntity), intermediateType }, { typeof(DerivedEntity), derivedType } - }) + }), + ValidationContext = new ValidationContext(entity) }; - var entity = new DerivedEntity - { - Name = "", // Invalid: required - CreatedAt = DateTime.Now.AddDays(1) // Invalid: future date - }; - context.ValidationContext = new ValidationContext(entity); - // Act await derivedType.ValidateAsync(entity, context, default); @@ -475,17 +466,16 @@ public async Task Validate_RequiredOnPropertyShortCircuitsOtherValidations() [new RequiredAttribute(), new PasswordComplexityAttribute()]) ]); + var user = new User { Password = null }; // Invalid: required var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary { { typeof(User), userType } - }) + }), + ValidationContext = new ValidationContext(user) // Invalid: required }; - var user = new User { Password = null }; // Invalid: required - context.ValidationContext = new ValidationContext(user); - // Act await userType.ValidateAsync(user, context, default); @@ -503,18 +493,17 @@ public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_Beh var globalType = new TestValidatableTypeInfo( typeof(GlobalErrorObject), []); // no properties – nothing sets MemberName + var globalErrorInstance = new GlobalErrorObject { Data = -1 }; var context = new ValidateContext { ValidationOptions = new TestValidationOptions(new Dictionary { { typeof(GlobalErrorObject), globalType } - }) + }), + ValidationContext = new ValidationContext(globalErrorInstance) }; - var globalErrorInstance = new GlobalErrorObject { Data = -1 }; - context.ValidationContext = new ValidationContext(globalErrorInstance); - await globalType.ValidateAsync(globalErrorInstance, context, default); Assert.NotNull(context.ValidationErrors); @@ -554,69 +543,6 @@ public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_Beh }); } - [Fact] - public async Task Validate_WithoutValidationContext_AddsErrorAndInitializesValidationContext() - { - // Arrange - var personType = new TestValidatableTypeInfo( - typeof(Person), - [ - // Only the Name property is required for this test - CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name", - [new RequiredAttribute()]) - ]); - - var context = new ValidateContext - { - ValidationOptions = new TestValidationOptions(new Dictionary - { - { typeof(Person), personType } - }) - }; - - var person = new Person(); // Name is null → fails validation - Assert.Null(context.ValidationContext); // ensure no ValidationContext pre-set - - // Act - await personType.ValidateAsync(person, context, default); - - // Assert - Assert.NotNull(context.ValidationContext); - Assert.NotNull(context.ValidationErrors); - var error = Assert.Single(context.ValidationErrors); - Assert.Equal("Name", error.Key); - Assert.Equal("The Name field is required.", error.Value.First()); - } - - [Fact] - public async Task Validate_WithoutValidationContext_ValidObject_NoErrors() - { - // Arrange - var personType = new TestValidatableTypeInfo( - typeof(Person), - [ - CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name", - [new RequiredAttribute()]) - ]); - - var context = new ValidateContext - { - ValidationOptions = new TestValidationOptions(new Dictionary - { - { typeof(Person), personType } - }) - }; - - var person = new Person { Name = "Alice" }; - - // Act - await personType.ValidateAsync(person, context, default); - - // Assert - Assert.NotNull(context.ValidationContext); - Assert.Null(context.ValidationErrors); - } - // Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739 private class GlobalErrorObject : IValidatableObject { diff --git a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs index bd9b841fd556..da2709fcddbf 100644 --- a/src/Http/Routing/src/ValidationEndpointFilterFactory.cs +++ b/src/Http/Routing/src/ValidationEndpointFilterFactory.cs @@ -43,7 +43,7 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context return async (context) => { - var validatableContext = new ValidateContext { ValidationOptions = options }; + ValidateContext? validateContext = null; for (var i = 0; i < context.Arguments.Count; i++) { @@ -57,15 +57,28 @@ public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context } var validationContext = new ValidationContext(argument, displayName, context.HttpContext.RequestServices, items: null); - validatableContext.ValidationContext = validationContext; - await validatableParameter.ValidateAsync(argument, validatableContext, context.HttpContext.RequestAborted); + + if (validateContext == null) + { + validateContext = new ValidateContext + { + ValidationOptions = options, + ValidationContext = validationContext + }; + } + else + { + validateContext.ValidationContext = validationContext; + } + + await validatableParameter.ValidateAsync(argument, validateContext, context.HttpContext.RequestAborted); } - if (validatableContext.ValidationErrors is { Count: > 0 }) + if (validateContext is { ValidationErrors.Count: > 0 }) { context.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; context.HttpContext.Response.ContentType = "application/problem+json"; - return await ValueTask.FromResult(new HttpValidationProblemDetails(validatableContext.ValidationErrors)); + return await ValueTask.FromResult(new HttpValidationProblemDetails(validateContext.ValidationErrors)); } return await next(context);