Skip to content

Commit 2da8f2d

Browse files
committed
Tolerate null ValidationContext in validation resolver APIs
1 parent 92bc896 commit 2da8f2d

File tree

5 files changed

+128
-8
lines changed

5 files changed

+128
-8
lines changed

src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System.Collections;
55
using System.ComponentModel.DataAnnotations;
6-
using System.Diagnostics;
76

87
namespace Microsoft.AspNetCore.Http.Validation;
98

@@ -58,14 +57,17 @@ protected ValidatableParameterInfo(
5857
/// </remarks>
5958
public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
6059
{
61-
Debug.Assert(context.ValidationContext is not null);
62-
6360
// Skip validation if value is null and parameter is optional
6461
if (value == null && ParameterType.IsNullable())
6562
{
6663
return;
6764
}
6865

66+
// ValidationContext requires a non-null value although the invocation pattern that we use
67+
// calls `GetValidationResult` and passes the value there. `GetValidationResult` tolerates
68+
// null values so we only need to set a non-null value to the ValidationContext here.
69+
context.ValidationContext ??= new ValidationContext(value ?? new object(), displayName: DisplayName, serviceProvider: null, items: null);
70+
6971
context.ValidationContext.DisplayName = DisplayName;
7072
context.ValidationContext.MemberName = Name;
7173

src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.ComponentModel.DataAnnotations;
5-
using System.Diagnostics;
65
using System.Diagnostics.CodeAnalysis;
76

87
namespace Microsoft.AspNetCore.Http.Validation;
@@ -60,8 +59,6 @@ protected ValidatablePropertyInfo(
6059
/// <inheritdoc />
6160
public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
6261
{
63-
Debug.Assert(context.ValidationContext is not null);
64-
6562
var property = DeclaringType.GetProperty(Name) ?? throw new InvalidOperationException($"Property '{Name}' not found on type '{DeclaringType.Name}'.");
6663
var propertyValue = property.GetValue(value);
6764
var validationAttributes = GetValidationAttributes();
@@ -77,6 +74,8 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
7774
context.CurrentValidationPath = $"{originalPrefix}.{Name}";
7875
}
7976

77+
context.ValidationContext ??= new ValidationContext(value ?? new object(), displayName: DisplayName, serviceProvider: null, items: null);
78+
8079
context.ValidationContext.DisplayName = DisplayName;
8180
context.ValidationContext.MemberName = Name;
8281

src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.ComponentModel.DataAnnotations;
5-
using System.Diagnostics;
65
using System.Diagnostics.CodeAnalysis;
76
using System.Linq;
87

@@ -44,12 +43,15 @@ protected ValidatableTypeInfo(
4443
/// <inheritdoc />
4544
public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
4645
{
47-
Debug.Assert(context.ValidationContext is not null);
4846
if (value == null)
4947
{
5048
return;
5149
}
5250

51+
// Although classes can be annotated with [DisplayName], we only process display names when producing
52+
// errors for properties so we can pass the `Type.Name` as the display name for the type here.
53+
context.ValidationContext ??= new ValidationContext(value, displayName: Type.Name, serviceProvider: null, items: null);
54+
5355
// Check if we've exceeded the maximum depth
5456
if (context.CurrentDepth >= context.ValidationOptions.MaxDepth)
5557
{

src/Http/Http.Abstractions/test/Validation/ValidatableParameterInfoTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,60 @@ public async Task Validate_ExceptionDuringValidation_CapturesExceptionAsError()
285285
Assert.Equal("Test exception", error.Value.First());
286286
}
287287

288+
[Fact]
289+
public async Task Validate_WithoutValidationContext_RequiredParameter_AddsErrorAndInitializesValidationContext()
290+
{
291+
// Arrange
292+
var paramInfo = CreateTestParameterInfo(
293+
parameterType: typeof(string),
294+
name: "testParam",
295+
displayName: "Test Parameter",
296+
validationAttributes: [new RequiredAttribute()]);
297+
298+
// Create a ValidateContext without a pre-populated ValidationContext
299+
var context = new ValidateContext
300+
{
301+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>())
302+
};
303+
304+
// Sanity check
305+
Assert.Null(context.ValidationContext);
306+
307+
// Act
308+
await paramInfo.ValidateAsync(null, context, default);
309+
310+
// Assert – a ValidationContext should have been created and the error recorded
311+
Assert.NotNull(context.ValidationContext);
312+
var errors = context.ValidationErrors;
313+
Assert.NotNull(errors);
314+
var error = Assert.Single(errors);
315+
Assert.Equal("testParam", error.Key);
316+
Assert.Equal("The Test Parameter field is required.", error.Value.Single());
317+
}
318+
319+
[Fact]
320+
public async Task Validate_WithoutValidationContext_ValidValue_NoErrors()
321+
{
322+
// Arrange
323+
var paramInfo = CreateTestParameterInfo(
324+
parameterType: typeof(int),
325+
name: "testParam",
326+
displayName: "Test Parameter",
327+
validationAttributes: [new RangeAttribute(10, 100)]);
328+
329+
var context = new ValidateContext
330+
{
331+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>())
332+
};
333+
334+
// Act
335+
await paramInfo.ValidateAsync(50, context, default);
336+
337+
// Assert – ValidationContext initialized, but no errors added
338+
Assert.NotNull(context.ValidationContext);
339+
Assert.Null(context.ValidationErrors);
340+
}
341+
288342
private TestValidatableParameterInfo CreateTestParameterInfo(
289343
Type parameterType,
290344
string name,

src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,69 @@ public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_Beh
552552
});
553553
}
554554

555+
[Fact]
556+
public async Task Validate_WithoutValidationContext_AddsErrorAndInitializesValidationContext()
557+
{
558+
// Arrange
559+
var personType = new TestValidatableTypeInfo(
560+
typeof(Person),
561+
[
562+
// Only the Name property is required for this test
563+
CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name",
564+
[new RequiredAttribute()])
565+
]);
566+
567+
var context = new ValidateContext
568+
{
569+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
570+
{
571+
{ typeof(Person), personType }
572+
})
573+
};
574+
575+
var person = new Person(); // Name is null → fails validation
576+
Assert.Null(context.ValidationContext); // ensure no ValidationContext pre-set
577+
578+
// Act
579+
await personType.ValidateAsync(person, context, default);
580+
581+
// Assert
582+
Assert.NotNull(context.ValidationContext);
583+
Assert.NotNull(context.ValidationErrors);
584+
var error = Assert.Single(context.ValidationErrors);
585+
Assert.Equal("Name", error.Key);
586+
Assert.Equal("The Name field is required.", error.Value.First());
587+
}
588+
589+
[Fact]
590+
public async Task Validate_WithoutValidationContext_ValidObject_NoErrors()
591+
{
592+
// Arrange
593+
var personType = new TestValidatableTypeInfo(
594+
typeof(Person),
595+
[
596+
CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name",
597+
[new RequiredAttribute()])
598+
]);
599+
600+
var context = new ValidateContext
601+
{
602+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
603+
{
604+
{ typeof(Person), personType }
605+
})
606+
};
607+
608+
var person = new Person { Name = "Alice" };
609+
610+
// Act
611+
await personType.ValidateAsync(person, context, default);
612+
613+
// Assert
614+
Assert.NotNull(context.ValidationContext);
615+
Assert.Null(context.ValidationErrors);
616+
}
617+
555618
// Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739
556619
private class GlobalErrorObject : IValidatableObject
557620
{

0 commit comments

Comments
 (0)