Skip to content
Open
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
10 changes: 6 additions & 4 deletions src/Validation/src/RuntimeValidatableParameterInfoResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNull
// If there are no validation attributes and this type is not a complex type
// we don't need to validate it. Complex types without attributes are still
// validatable because we want to run the validations on the properties.
if (validationAttributes.Length == 0 && !IsClass(parameterInfo.ParameterType))
if (validationAttributes.Length == 0 && !IsComplexType(parameterInfo.ParameterType))
{
validatableInfo = null;
return false;
Expand Down Expand Up @@ -80,7 +80,7 @@ internal sealed class RuntimeValidatableParameterInfo(
private readonly ValidationAttribute[] _validationAttributes = validationAttributes;
}

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

return type.IsClass;
// Complex types include both reference types (classes) and value types (structs, record structs)
// that aren't in the exclusion list above
return type.IsClass || type.IsValueType;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -384,4 +384,146 @@ async Task ValidInputProducesNoWarnings(Endpoint endpoint)
});

}

[Fact]
public async Task CanValidateRecordStructTypes()
{
// Arrange
var source = """
using System;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Validation;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder();

builder.Services.AddValidation();

var app = builder.Build();

app.MapPost("/validatable-record-struct", (ValidatableRecordStruct validatableRecordStruct) => Results.Ok("Passed"));

app.Run();

public record struct SubRecordStruct([Required] string RequiredProperty, [StringLength(10)] string? StringWithLength);

public record struct ValidatableRecordStruct(
[Range(10, 100)]
int IntegerWithRange,
[Range(10, 100), Display(Name = "Valid identifier")]
int IntegerWithRangeAndDisplayName,
SubRecordStruct SubProperty
);
""";
await Verify(source, out var compilation);
await VerifyEndpoint(compilation, "/validatable-record-struct", async (endpoint, serviceProvider) =>
{
await InvalidIntegerWithRangeProducesError(endpoint);
await InvalidIntegerWithRangeAndDisplayNameProducesError(endpoint);
await InvalidSubPropertyProducesError(endpoint);
await ValidInputProducesNoWarnings(endpoint);

async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint)
{
var payload = """
{
"IntegerWithRange": 5,
"IntegerWithRangeAndDisplayName": 50,
"SubProperty": {
"RequiredProperty": "valid",
"StringWithLength": "valid"
}
}
""";
var context = CreateHttpContextWithPayload(payload, serviceProvider);

await endpoint.RequestDelegate(context);

var problemDetails = await AssertBadRequest(context);
Assert.Collection(problemDetails.Errors, kvp =>
{
Assert.Equal("IntegerWithRange", kvp.Key);
Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single());
});
}

async Task InvalidIntegerWithRangeAndDisplayNameProducesError(Endpoint endpoint)
{
var payload = """
{
"IntegerWithRange": 50,
"IntegerWithRangeAndDisplayName": 5,
"SubProperty": {
"RequiredProperty": "valid",
"StringWithLength": "valid"
}
}
""";
var context = CreateHttpContextWithPayload(payload, serviceProvider);

await endpoint.RequestDelegate(context);

var problemDetails = await AssertBadRequest(context);
Assert.Collection(problemDetails.Errors, kvp =>
{
Assert.Equal("IntegerWithRangeAndDisplayName", kvp.Key);
Assert.Equal("The field Valid identifier must be between 10 and 100.", kvp.Value.Single());
});
}

async Task InvalidSubPropertyProducesError(Endpoint endpoint)
{
var payload = """
{
"IntegerWithRange": 50,
"IntegerWithRangeAndDisplayName": 50,
"SubProperty": {
"RequiredProperty": "",
"StringWithLength": "way-too-long"
}
}
""";
var context = CreateHttpContextWithPayload(payload, serviceProvider);

await endpoint.RequestDelegate(context);

var problemDetails = await AssertBadRequest(context);
Assert.Collection(problemDetails.Errors,
kvp =>
{
Assert.Equal("SubProperty.RequiredProperty", kvp.Key);
Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
},
kvp =>
{
Assert.Equal("SubProperty.StringWithLength", kvp.Key);
Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
});
}

async Task ValidInputProducesNoWarnings(Endpoint endpoint)
{
var payload = """
{
"IntegerWithRange": 50,
"IntegerWithRangeAndDisplayName": 50,
"SubProperty": {
"RequiredProperty": "valid",
"StringWithLength": "valid"
}
}
""";
var context = CreateHttpContextWithPayload(payload, serviceProvider);
await endpoint.RequestDelegate(context);

Assert.Equal(200, context.Response.StatusCode);
}
});

}
}
Loading
Loading