Skip to content

Commit cda8a39

Browse files
committed
Fix nullability handling in generator and add sample Http file
1 parent 7ee3e1a commit cda8a39

File tree

7 files changed

+162
-26
lines changed

7 files changed

+162
-26
lines changed

src/Http/Http.Abstractions/src/Validation/TypeExtensions.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@ namespace Microsoft.AspNetCore.Http.Validation;
99

1010
internal static class TypeExtensions
1111
{
12+
/// <summary>
13+
/// Determines whether the specified type is an enumerable type.
14+
/// </summary>
15+
/// <param name="type">The type to check.</param>
16+
/// <returns><see langword="true"/> if the type is enumerable; otherwise, <see langword="false"/>.</returns>
1217
public static bool IsEnumerable(this Type type)
1318
{
1419
// Check if type itself is an IEnumerable
1520
if (type.IsGenericType &&
1621
(type.GetGenericTypeDefinition() == typeof(IEnumerable<>) ||
1722
type.GetGenericTypeDefinition() == typeof(ICollection<>) ||
18-
type.GetGenericTypeDefinition() == typeof(List<>)))
23+
type.GetGenericTypeDefinition() == typeof(List<>) ||
24+
type.GetGenericTypeDefinition() == typeof(IList<>)))
1925
{
2026
return true;
2127
}
@@ -36,6 +42,11 @@ public static bool IsEnumerable(this Type type)
3642
return false;
3743
}
3844

45+
/// <summary>
46+
/// Determines whether the specified type is a nullable type.
47+
/// </summary>
48+
/// <param name="type">The type to check.</param>
49+
/// <returns><see langword="true"/> if the type is nullable; otherwise, <see langword="false"/>.</returns>
3950
public static bool IsNullable(this Type type)
4051
{
4152
if (type.IsValueType)
@@ -52,6 +63,12 @@ public static bool IsNullable(this Type type)
5263
return false;
5364
}
5465

66+
/// <summary>
67+
/// Tries to get the <see cref="RequiredAttribute"/> from the specified array of validation attributes.
68+
/// </summary>
69+
/// <param name="attributes">The array of <see cref="ValidationAttribute"/> to search.</param>
70+
/// <param name="requiredAttribute">The found <see cref="RequiredAttribute"/> if available, otherwise null.</param>
71+
/// <returns><see langword="true"/> if a <see cref="RequiredAttribute"/> is found; otherwise, <see langword="false"/>.</returns>
5572
public static bool TryGetRequiredAttribute(this ValidationAttribute[] attributes, [NotNullWhen(true)] out RequiredAttribute? requiredAttribute)
5673
{
5774
foreach (var attribute in attributes)

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ namespace Microsoft.AspNetCore.Http.Validation;
1212
/// </summary>
1313
public abstract class ValidatablePropertyInfo : IValidatableInfo
1414
{
15+
private RequiredAttribute? _requiredAttribute;
16+
1517
/// <summary>
1618
/// Creates a new instance of <see cref="ValidatablePropertyInfo"/>.
1719
/// </summary>
@@ -80,9 +82,9 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
8082
context.ValidationContext.MemberName = Name;
8183

8284
// Check required attribute first
83-
if (validationAttributes.TryGetRequiredAttribute(out var requiredAttribute))
85+
if (_requiredAttribute is not null || validationAttributes.TryGetRequiredAttribute(out _requiredAttribute))
8486
{
85-
var result = requiredAttribute.GetValidationResult(propertyValue, context.ValidationContext);
87+
var result = _requiredAttribute.GetValidationResult(propertyValue, context.ValidationContext);
8688

8789
if (result is not null && result != ValidationResult.Success)
8890
{

src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ internal static void Emit(SourceProductionContext context, (InterceptableLocatio
2626
}
2727

2828
private static string Emit(InterceptableLocation? addValidation, ImmutableArray<ValidatableType> validatableTypes) => $$"""
29+
#nullable enable annotations
2930
//------------------------------------------------------------------------------
3031
// <auto-generated>
3132
// This code was generated by a tool.
@@ -79,15 +80,15 @@ public GeneratedValidatableTypeInfo(
7980
{{GeneratedCodeAttribute}}
8081
file class GeneratedValidatableInfoResolver : global::Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver
8182
{
82-
public bool TryGetValidatableTypeInfo(global::System.Type type, out global::Microsoft.AspNetCore.Http.Validation.IValidatableInfo validatableInfo)
83+
public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo)
8384
{
8485
validatableInfo = null;
8586
{{EmitTypeChecks(validatableTypes)}}
8687
return false;
8788
}
8889
8990
// No-ops, rely on runtime code for ParameterInfo-based resolution
90-
public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, out global::Microsoft.AspNetCore.Http.Validation.IValidatableInfo validatableInfo)
91+
public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo)
9192
{
9293
validatableInfo = null;
9394
return false;
@@ -131,7 +132,7 @@ private sealed record CacheKey(global::System.Type AttributeType, object[] Argum
131132
var type = k.AttributeType;
132133
var args = k.Arguments;
133134
134-
global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute;
135+
global::System.ComponentModel.DataAnnotations.ValidationAttribute? attribute;
135136
136137
if (args.Length == 0)
137138
{
@@ -143,7 +144,8 @@ private sealed record CacheKey(global::System.Type AttributeType, object[] Argum
143144
global::System.Type t when t == typeof(global::System.ComponentModel.DataAnnotations.UrlAttribute) => new global::System.ComponentModel.DataAnnotations.UrlAttribute(),
144145
global::System.Type t when t == typeof(global::System.ComponentModel.DataAnnotations.CreditCardAttribute) => new global::System.ComponentModel.DataAnnotations.CreditCardAttribute(),
145146
_ when typeof(global::System.ComponentModel.DataAnnotations.ValidationAttribute).IsAssignableFrom(type) =>
146-
(global::System.ComponentModel.DataAnnotations.ValidationAttribute)global::System.Activator.CreateInstance(type)!
147+
(global::System.ComponentModel.DataAnnotations.ValidationAttribute)global::System.Activator.CreateInstance(type)!,
148+
_ => throw new global::System.ArgumentException($"Unsupported validation attribute type: {type.FullName}")
147149
};
148150
}
149151
else if (type == typeof(global::System.ComponentModel.DataAnnotations.CustomValidationAttribute) && args.Length == 2)

src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/ValidationsGenerator.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@
66

77
namespace Microsoft.AspNetCore.Http.ValidationsGenerator;
88

9+
[Generator]
910
public sealed partial class ValidationsGenerator : IIncrementalGenerator
1011
{
1112
public void Initialize(IncrementalGeneratorInitializationContext context)
1213
{
14+
// while (!System.Diagnostics.Debugger.IsAttached)
15+
// {
16+
// System.Threading.Thread.Sleep(1000);
17+
// #pragma warning disable RS1035 // Do not use APIs banned for analyzers
18+
// System.Console.WriteLine($"Waiting for debugger to attach on {System.Diagnostics.Process.GetCurrentProcess().Id}...");
19+
// #pragma warning restore RS1035 // Do not use APIs banned for analyzers
20+
// }
1321
// Resolve the symbols that will be required when making comparisons
1422
// in future steps.
1523
var requiredSymbols = context.CompilationProvider.Select(ExtractRequiredSymbols);
@@ -44,7 +52,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
4452
var emitInputs = addValidation
4553
.Combine(validatableTypes);
4654

47-
// Emit ValidatableTypeInfo for all validatable types.
55+
// Emit the IValidatableInfo resolver injection and
56+
// ValidatableTypeInfo for all validatable types.
4857
context.RegisterSourceOutput(emitInputs, Emit);
4958
}
5059
}

src/Http/samples/MinimalValidationSample/MinimalValidationSample.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
55
<Nullable>enable</Nullable>
66
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
7+
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
78
</PropertyGroup>
89

910
<ItemGroup>
@@ -18,7 +19,9 @@
1819
</ItemGroup>
1920

2021
<ItemGroup>
21-
<ProjectReference Include="$(RepoRoot)/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Microsoft.AspNetCore.Http.ValidationsGenerator.csproj" OutputItemType="Analyzer" />
22+
<ProjectReference Include="$(RepoRoot)/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Microsoft.AspNetCore.Http.ValidationsGenerator.csproj"
23+
OutputItemType="Analyzer"
24+
ReferenceOutputAssembly="false" />
2225
</ItemGroup>
2326

2427
</Project>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
### Valid customer ID request
2+
GET http://localhost:5021/customers/42
3+
Accept: application/json
4+
5+
### Invalid customer ID request (ID must be >= 1)
6+
GET http://localhost:5021/customers/0
7+
Accept: application/json
8+
9+
### Valid customer POST request
10+
POST http://localhost:5021/customers
11+
Content-Type: application/json
12+
13+
{
14+
"name": "John Doe",
15+
"email": "[email protected]",
16+
"age": 30,
17+
"homeAddress": {
18+
"street": "123 Main St",
19+
"city": "Anytown",
20+
"zipCode": "12345"
21+
}
22+
}
23+
24+
### Invalid customer POST request (missing required fields)
25+
POST http://localhost:5021/customers
26+
Content-Type: application/json
27+
28+
{
29+
"age": 15
30+
}
31+
32+
### Invalid customer POST request (invalid email format)
33+
POST http://localhost:5021/customers
34+
Content-Type: application/json
35+
36+
{
37+
"name": "John Doe",
38+
"email": "not-an-email",
39+
"age": 30
40+
}
41+
42+
### Invalid customer POST request (age out of range)
43+
POST http://localhost:5021/customers
44+
Content-Type: application/json
45+
46+
{
47+
"name": "John Doe",
48+
"email": "[email protected]",
49+
"age": 15
50+
}
51+
52+
### Invalid customer POST request (invalid zipCode length)
53+
POST http://localhost:5021/customers
54+
Content-Type: application/json
55+
56+
{
57+
"name": "John Doe",
58+
"email": "[email protected]",
59+
"age": 30,
60+
"homeAddress": {
61+
"street": "123 Main St",
62+
"city": "Anytown",
63+
"zipCode": "1234567"
64+
}
65+
}
66+
67+
### Valid order POST request
68+
POST http://localhost:5021/orders
69+
Content-Type: application/json
70+
71+
{
72+
"orderId": 12345,
73+
"productName": "Sample Product",
74+
"quantity": 5
75+
}
76+
77+
### Invalid order POST request (missing required field)
78+
POST http://localhost:5021/orders
79+
Content-Type: application/json
80+
81+
{
82+
"orderId": 12345,
83+
"quantity": 5
84+
}
85+
86+
### Invalid order POST request (IValidatableObject validation failure)
87+
POST http://localhost:5021/orders
88+
Content-Type: application/json
89+
90+
{
91+
"orderId": 12345,
92+
"productName": "Sample Product",
93+
"quantity": 0
94+
}
95+
96+
### Invalid order POST request (negative orderId)
97+
POST http://localhost:5021/orders
98+
Content-Type: application/json
99+
100+
{
101+
"orderId": -1,
102+
"productName": "Sample Product",
103+
"quantity": 5
104+
}
105+
106+
### Valid product POST request (validation disabled)
107+
# This endpoint has DisableValidation() applied, so even invalid data should be accepted
108+
POST http://localhost:5021/products?productId=2&name=TestProduct
109+
Content-Type: application/json
110+
111+
### Invalid product POST request (validation disabled)
112+
# This has an odd productId and is missing name, but should still work because validation is disabled
113+
POST http://localhost:5021/products?productId=3
114+
Content-Type: application/json

src/Http/samples/MinimalValidationSample/Program.cs

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,14 @@
1414
app.MapGet("/customers/{id}", ([Range(1, int.MaxValue)] int id) =>
1515
$"Getting customer with ID: {id}");
1616

17-
app.MapPost("/customers", (Customer customer) =>
18-
{
19-
// Validation happens automatically before this code runs
20-
return TypedResults.Created($"/customers/{customer.Name}", customer);
21-
});
17+
app.MapPost("/customers", (Customer customer) => TypedResults.Created($"/customers/{customer.Name}", customer));
2218

23-
app.MapPost("/orders", (Order order) =>
24-
{
25-
// Both attribute validation and IValidatableObject.Validate are called automatically
26-
return TypedResults.Created($"/orders/{order.OrderId}", order);
27-
});
19+
app.MapPost("/orders", (Order order) => TypedResults.Created($"/orders/{order.OrderId}", order));
2820

29-
app.MapPost("/products", ([EvenNumberAttribute(ErrorMessage = "Product ID must be even")] int productId,
30-
[Required] string name) =>
31-
{
32-
return TypedResults.Ok(new { productId, name });
33-
})
34-
.DisableValidation();
21+
app.MapPost("/products",
22+
([EvenNumber(ErrorMessage = "Product ID must be even")] int productId, [Required] string name)
23+
=> TypedResults.Ok(new { productId, name }))
24+
.DisableValidation();
3525

3626
app.Run();
3727

@@ -71,7 +61,6 @@ public class Address
7161
}
7262

7363
// Define a type implementing IValidatableObject for custom validation
74-
[ValidatableType]
7564
public class Order : IValidatableObject
7665
{
7766
[Range(1, int.MaxValue)]

0 commit comments

Comments
 (0)