Skip to content

Commit 70cded5

Browse files
[release/10.0] Emit validation info for types that have IValidatableObject interface and no validation attributes (#63415)
* Emit validation info for types that only have IValidatableObject method and no validation attributes * Refactor the interface check to use explicit loop instead of LINQ * Add test case, reuse ImplementsInterface method --------- Co-authored-by: Ondřej Roztočil <[email protected]>
1 parent 46b18c3 commit 70cded5

File tree

4 files changed

+364
-2
lines changed

4 files changed

+364
-2
lines changed

src/Shared/RoslynUtils/WellKnownTypeData.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ public enum WellKnownType
124124
System_ComponentModel_DataAnnotations_ValidationAttribute,
125125
System_ComponentModel_DataAnnotations_RequiredAttribute,
126126
System_ComponentModel_DataAnnotations_CustomValidationAttribute,
127+
System_ComponentModel_DataAnnotations_IValidatableObject,
127128
Microsoft_Extensions_Validation_SkipValidationAttribute,
128129
System_Type,
129130
}
@@ -247,6 +248,7 @@ public enum WellKnownType
247248
"System.ComponentModel.DataAnnotations.ValidationAttribute",
248249
"System.ComponentModel.DataAnnotations.RequiredAttribute",
249250
"System.ComponentModel.DataAnnotations.CustomValidationAttribute",
251+
"System.ComponentModel.DataAnnotations.IValidatableObject",
250252
"Microsoft.Extensions.Validation.SkipValidationAttribute",
251253
"System.Type",
252254
];

src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
8282

8383
visitedTypes.Add(typeSymbol);
8484

85-
var hasValidationAttributes = HasValidationAttributes(typeSymbol, wellKnownTypes);
85+
var hasTypeLevelValidation = HasValidationAttributes(typeSymbol, wellKnownTypes) || HasIValidatableObjectInterface(typeSymbol, wellKnownTypes);
8686

8787
// Extract validatable types discovered in base types of this type and add them to the top-level list.
8888
var current = typeSymbol.BaseType;
@@ -109,7 +109,7 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
109109
}
110110

111111
// No validatable members or derived types found, so we don't need to add this type.
112-
if (members.IsDefaultOrEmpty && !hasValidationAttributes && !hasValidatableBaseType && !hasValidatableDerivedTypes)
112+
if (members.IsDefaultOrEmpty && !hasTypeLevelValidation && !hasValidatableBaseType && !hasValidatableDerivedTypes)
113113
{
114114
return false;
115115
}
@@ -301,4 +301,10 @@ internal static bool HasValidationAttributes(ISymbol symbol, WellKnownTypes well
301301

302302
return false;
303303
}
304+
305+
internal static bool HasIValidatableObjectInterface(ITypeSymbol typeSymbol, WellKnownTypes wellKnownTypes)
306+
{
307+
var validatableObjectSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject);
308+
return typeSymbol.ImplementsInterface(validatableObjectSymbol);
309+
}
304310
}

src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,145 @@ async Task ValidateForTopLevelInvoked()
164164
}
165165
});
166166
}
167+
168+
[Fact]
169+
public async Task CanValidateIValidatableObject_WithoutPropertyValidations()
170+
{
171+
var source = """
172+
using System.Collections.Generic;
173+
using System.ComponentModel.DataAnnotations;
174+
using System.Threading.Tasks;
175+
using Microsoft.AspNetCore.Builder;
176+
using Microsoft.AspNetCore.Http;
177+
using Microsoft.Extensions.Validation;
178+
using Microsoft.AspNetCore.Mvc;
179+
using Microsoft.AspNetCore.Routing;
180+
using Microsoft.Extensions.DependencyInjection;
181+
182+
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
183+
184+
builder.Services.AddValidation();
185+
186+
WebApplication app = builder. Build();
187+
188+
app.MapPost("/base", (BaseClass model) => Results.Ok(model));
189+
app.MapPost("/derived", (DerivedClass model) => Results.Ok(model));
190+
app.MapPost("/complex", (ComplexClass model) => Results.Ok(model));
191+
192+
app.Run();
193+
194+
public class BaseClass : IValidatableObject
195+
{
196+
public string? Value { get; set; }
197+
198+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
199+
{
200+
if (string.IsNullOrEmpty(Value))
201+
{
202+
yield return new ValidationResult("Value cannot be null or empty.", [nameof(Value)]);
203+
}
204+
}
205+
}
206+
207+
public class DerivedClass : BaseClass
208+
{
209+
}
210+
211+
public class ComplexClass
212+
{
213+
public NestedClass? NestedObject { get; set; }
214+
}
215+
216+
public class NestedClass : IValidatableObject
217+
{
218+
public string? Value { get; set; }
219+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
220+
{
221+
if (string.IsNullOrEmpty(Value))
222+
{
223+
yield return new ValidationResult("Value cannot be null or empty.", [nameof(Value)]);
224+
}
225+
}
226+
}
227+
""";
228+
229+
await Verify(source, out var compilation);
230+
231+
await VerifyEndpoint(compilation, "/base", async (endpoint, serviceProvider) =>
232+
{
233+
await ValidateMethodCalled();
234+
235+
async Task ValidateMethodCalled()
236+
{
237+
var httpContext = CreateHttpContextWithPayload("""
238+
{
239+
"Value": ""
240+
}
241+
""", serviceProvider);
242+
243+
await endpoint.RequestDelegate(httpContext);
244+
245+
var problemDetails = await AssertBadRequest(httpContext);
246+
Assert.Collection(problemDetails.Errors,
247+
error =>
248+
{
249+
Assert.Equal("Value", error.Key);
250+
Assert.Collection(error.Value,
251+
msg => Assert.Equal("Value cannot be null or empty.", msg));
252+
});
253+
}
254+
});
255+
256+
await VerifyEndpoint(compilation, "/derived", async (endpoint, serviceProvider) =>
257+
{
258+
await ValidateMethodCalled();
259+
260+
async Task ValidateMethodCalled()
261+
{
262+
var httpContext = CreateHttpContextWithPayload("""
263+
{
264+
"Value": ""
265+
}
266+
""", serviceProvider);
267+
268+
await endpoint.RequestDelegate(httpContext);
269+
270+
var problemDetails = await AssertBadRequest(httpContext);
271+
Assert.Collection(problemDetails.Errors,
272+
error =>
273+
{
274+
Assert.Equal("Value", error.Key);
275+
Assert.Collection(error.Value,
276+
msg => Assert.Equal("Value cannot be null or empty.", msg));
277+
});
278+
}
279+
});
280+
281+
await VerifyEndpoint(compilation, "/complex", async (endpoint, serviceProvider) =>
282+
{
283+
await ValidateMethodCalled();
284+
285+
async Task ValidateMethodCalled()
286+
{
287+
var httpContext = CreateHttpContextWithPayload("""
288+
{
289+
"NestedObject": {
290+
"Value": ""
291+
}
292+
}
293+
""", serviceProvider);
294+
295+
await endpoint.RequestDelegate(httpContext);
296+
297+
var problemDetails = await AssertBadRequest(httpContext);
298+
Assert.Collection(problemDetails.Errors,
299+
error =>
300+
{
301+
Assert.Equal("NestedObject.Value", error.Key);
302+
Assert.Collection(error.Value,
303+
msg => Assert.Equal("Value cannot be null or empty.", msg));
304+
});
305+
}
306+
});
307+
}
167308
}

0 commit comments

Comments
 (0)