Skip to content

Commit cb092d8

Browse files
Fix minimal API validation for record structs (#64514)
* Initial plan * Fix minimal API validation for record structs by renaming IsClass to IsComplexType Co-authored-by: captainsafia <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: captainsafia <[email protected]>
1 parent edda51e commit cb092d8

File tree

3 files changed

+370
-4
lines changed

3 files changed

+370
-4
lines changed

src/Validation/src/RuntimeValidatableParameterInfoResolver.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNull
4343
// If there are no validation attributes and this type is not a complex type
4444
// we don't need to validate it. Complex types without attributes are still
4545
// validatable because we want to run the validations on the properties.
46-
if (validationAttributes.Length == 0 && !IsClass(parameterInfo.ParameterType))
46+
if (validationAttributes.Length == 0 && !IsComplexType(parameterInfo.ParameterType))
4747
{
4848
validatableInfo = null;
4949
return false;
@@ -80,7 +80,7 @@ internal sealed class RuntimeValidatableParameterInfo(
8080
private readonly ValidationAttribute[] _validationAttributes = validationAttributes;
8181
}
8282

83-
private static bool IsClass(Type type)
83+
private static bool IsComplexType(Type type)
8484
{
8585
// Skip primitives, enums, common built-in types, and types that are specially
8686
// handled by RDF/RDG that don't need validation if they don't have attributes
@@ -105,9 +105,11 @@ private static bool IsClass(Type type)
105105
// Check if the underlying type in a nullable is valid
106106
if (Nullable.GetUnderlyingType(type) is { } nullableType)
107107
{
108-
return IsClass(nullableType);
108+
return IsComplexType(nullableType);
109109
}
110110

111-
return type.IsClass;
111+
// Complex types include both reference types (classes) and value types (structs, record structs)
112+
// that aren't in the exclusion list above
113+
return type.IsClass || type.IsValueType;
112114
}
113115
}

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,4 +384,146 @@ async Task ValidInputProducesNoWarnings(Endpoint endpoint)
384384
});
385385

386386
}
387+
388+
[Fact]
389+
public async Task CanValidateRecordStructTypes()
390+
{
391+
// Arrange
392+
var source = """
393+
using System;
394+
using System.ComponentModel.DataAnnotations;
395+
using System.Collections.Generic;
396+
using System.Threading.Tasks;
397+
using Microsoft.AspNetCore.Builder;
398+
using Microsoft.AspNetCore.Http;
399+
using Microsoft.Extensions.Validation;
400+
using Microsoft.AspNetCore.Routing;
401+
using Microsoft.Extensions.DependencyInjection;
402+
403+
var builder = WebApplication.CreateBuilder();
404+
405+
builder.Services.AddValidation();
406+
407+
var app = builder.Build();
408+
409+
app.MapPost("/validatable-record-struct", (ValidatableRecordStruct validatableRecordStruct) => Results.Ok("Passed"));
410+
411+
app.Run();
412+
413+
public record struct SubRecordStruct([Required] string RequiredProperty, [StringLength(10)] string? StringWithLength);
414+
415+
public record struct ValidatableRecordStruct(
416+
[Range(10, 100)]
417+
int IntegerWithRange,
418+
[Range(10, 100), Display(Name = "Valid identifier")]
419+
int IntegerWithRangeAndDisplayName,
420+
SubRecordStruct SubProperty
421+
);
422+
""";
423+
await Verify(source, out var compilation);
424+
await VerifyEndpoint(compilation, "/validatable-record-struct", async (endpoint, serviceProvider) =>
425+
{
426+
await InvalidIntegerWithRangeProducesError(endpoint);
427+
await InvalidIntegerWithRangeAndDisplayNameProducesError(endpoint);
428+
await InvalidSubPropertyProducesError(endpoint);
429+
await ValidInputProducesNoWarnings(endpoint);
430+
431+
async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint)
432+
{
433+
var payload = """
434+
{
435+
"IntegerWithRange": 5,
436+
"IntegerWithRangeAndDisplayName": 50,
437+
"SubProperty": {
438+
"RequiredProperty": "valid",
439+
"StringWithLength": "valid"
440+
}
441+
}
442+
""";
443+
var context = CreateHttpContextWithPayload(payload, serviceProvider);
444+
445+
await endpoint.RequestDelegate(context);
446+
447+
var problemDetails = await AssertBadRequest(context);
448+
Assert.Collection(problemDetails.Errors, kvp =>
449+
{
450+
Assert.Equal("IntegerWithRange", kvp.Key);
451+
Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single());
452+
});
453+
}
454+
455+
async Task InvalidIntegerWithRangeAndDisplayNameProducesError(Endpoint endpoint)
456+
{
457+
var payload = """
458+
{
459+
"IntegerWithRange": 50,
460+
"IntegerWithRangeAndDisplayName": 5,
461+
"SubProperty": {
462+
"RequiredProperty": "valid",
463+
"StringWithLength": "valid"
464+
}
465+
}
466+
""";
467+
var context = CreateHttpContextWithPayload(payload, serviceProvider);
468+
469+
await endpoint.RequestDelegate(context);
470+
471+
var problemDetails = await AssertBadRequest(context);
472+
Assert.Collection(problemDetails.Errors, kvp =>
473+
{
474+
Assert.Equal("IntegerWithRangeAndDisplayName", kvp.Key);
475+
Assert.Equal("The field Valid identifier must be between 10 and 100.", kvp.Value.Single());
476+
});
477+
}
478+
479+
async Task InvalidSubPropertyProducesError(Endpoint endpoint)
480+
{
481+
var payload = """
482+
{
483+
"IntegerWithRange": 50,
484+
"IntegerWithRangeAndDisplayName": 50,
485+
"SubProperty": {
486+
"RequiredProperty": "",
487+
"StringWithLength": "way-too-long"
488+
}
489+
}
490+
""";
491+
var context = CreateHttpContextWithPayload(payload, serviceProvider);
492+
493+
await endpoint.RequestDelegate(context);
494+
495+
var problemDetails = await AssertBadRequest(context);
496+
Assert.Collection(problemDetails.Errors,
497+
kvp =>
498+
{
499+
Assert.Equal("SubProperty.RequiredProperty", kvp.Key);
500+
Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
501+
},
502+
kvp =>
503+
{
504+
Assert.Equal("SubProperty.StringWithLength", kvp.Key);
505+
Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
506+
});
507+
}
508+
509+
async Task ValidInputProducesNoWarnings(Endpoint endpoint)
510+
{
511+
var payload = """
512+
{
513+
"IntegerWithRange": 50,
514+
"IntegerWithRangeAndDisplayName": 50,
515+
"SubProperty": {
516+
"RequiredProperty": "valid",
517+
"StringWithLength": "valid"
518+
}
519+
}
520+
""";
521+
var context = CreateHttpContextWithPayload(payload, serviceProvider);
522+
await endpoint.RequestDelegate(context);
523+
524+
Assert.Equal(200, context.Response.StatusCode);
525+
}
526+
});
527+
528+
}
387529
}

0 commit comments

Comments
 (0)