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 9fba8ab854b4..48de32c0daff 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,8 +59,6 @@ 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()) { diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs index 167d54500466..0b16e34d1dc9 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(); diff --git a/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs b/src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs index 82ae03465f9b..6245c43c1b69 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,7 +44,6 @@ protected ValidatableTypeInfo( /// public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) { - Debug.Assert(context.ValidationContext is not null); if (value == null) { return; 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/ValidatableTypeInfoTests.cs b/src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs index 98e74bd9d32a..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,20 +256,19 @@ 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 } - }) - }; - - var person = new Person - { - Name = null, - Address = null + }), + ValidationContext = new ValidationContext(person) }; - 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); 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);