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
Show file tree
Hide file tree
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
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

namespace Microsoft.AspNetCore.Http.Validation;
Expand Down Expand Up @@ -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())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion src/Http/Http.Abstractions/src/Validation/ValidateContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
Expand All @@ -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>(
Expand All @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand Down
Loading
Loading