Skip to content

Treat ValidationContext as required in validation resolver APIs #61854

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 14, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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<string!, string![]!>?
Microsoft.AspNetCore.Http.Validation.ValidateContext.ValidationErrors.set -> void
Original file line number Diff line number Diff line change
@@ -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(
/// </remarks>
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())
{
Original file line number Diff line number Diff line change
@@ -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(
/// <inheritdoc />
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();
Original file line number Diff line number Diff line change
@@ -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(
/// <inheritdoc />
public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
{
Debug.Assert(context.ValidationContext is not null);
if (value == null)
{
return;
19 changes: 18 additions & 1 deletion src/Http/Http.Abstractions/src/Validation/ValidateContext.cs
Original file line number Diff line number Diff line change
@@ -16,7 +16,24 @@ public sealed class ValidateContext
/// Gets or sets the validation context used for validating objects that implement <see cref="IValidatableObject"/> or have <see cref="ValidationAttribute"/>.
/// This context provides access to service provider and other validation metadata.
/// </summary>
public ValidationContext? ValidationContext { get; set; }
/// <remarks>
/// This property should be set by the consumer of the <see cref="IValidatableInfo"/>
/// 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.
/// </remarks>
/// <example>
/// <code>
/// var validationContext = new ValidationContext(objectToValidate, serviceProvider, items);
/// var validationOptions = serviceProvider.GetService&lt;IOptions&lt;ValidationOptions&gt;&gt;()?.Value;
/// var validateContext = new ValidateContext
/// {
/// ValidationContext = validationContext,
/// ValidationOptions = validationOptions
/// };
/// </code>
/// </example>
public required ValidationContext ValidationContext { get; set; }

/// <summary>
/// Gets or sets the prefix used to identify the current object being validated in a complex object graph.
Original file line number Diff line number Diff line change
@@ -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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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<InvalidOperationException>(
@@ -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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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,23 +421,22 @@ 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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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<Type, ValidatableTypeInfo>
{
{ 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);
23 changes: 18 additions & 5 deletions src/Http/Routing/src/ValidationEndpointFilterFactory.cs
Original file line number Diff line number Diff line change
@@ -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);