Browse Source

Fix handling of record types in validations source generator (#61402)

* Fix handling of record types in validations source generator

* Address feedback and add tests
Safia Abdalla 10 months ago
parent
commit
c22a8530ee
13 changed files with 984 additions and 39 deletions
  1. 30 4
      src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs
  2. 21 0
      src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs
  3. 59 0
      src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Parsers/ValidationsGenerator.TypesParser.cs
  4. 7 7
      src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs
  5. 371 0
      src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs
  6. 30 4
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs
  7. 30 4
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs
  8. 30 4
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs
  9. 30 4
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs
  10. 286 0
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs
  11. 30 4
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs
  12. 30 4
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs
  13. 30 4
      src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs

+ 30 - 4
src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs

@@ -106,7 +106,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         {{addValidation.GetInterceptsLocationAttributeSyntax()}}
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -133,13 +133,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }

+ 21 - 0
src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs

@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Collections.Immutable;
+using System.Linq;
 using Microsoft.CodeAnalysis;
 
 namespace Microsoft.AspNetCore.Http.ValidationsGenerator;
@@ -101,4 +102,24 @@ internal static class ITypeSymbolExtensions
                || SymbolEqualityComparer.Default.Equals(type, requiredSymbols.Stream)
                || SymbolEqualityComparer.Default.Equals(type, requiredSymbols.PipeReader);
     }
+
+    internal static IPropertySymbol? FindPropertyIncludingBaseTypes(this INamedTypeSymbol typeSymbol, string propertyName)
+    {
+        var property = typeSymbol.GetMembers()
+            .OfType<IPropertySymbol>()
+            .FirstOrDefault(p => string.Equals(p.Name, propertyName, System.StringComparison.OrdinalIgnoreCase));
+
+        if (property != null)
+        {
+            return property;
+        }
+
+        // If not found, recursively search base types
+        if (typeSymbol.BaseType is INamedTypeSymbol baseType)
+        {
+            return FindPropertyIncludingBaseTypes(baseType, propertyName);
+        }
+
+        return null;
+    }
 }

+ 59 - 0
src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Parsers/ValidationsGenerator.TypesParser.cs

@@ -89,15 +89,74 @@ public sealed partial class ValidationsGenerator : IIncrementalGenerator
     internal ImmutableArray<ValidatableProperty> ExtractValidatableMembers(ITypeSymbol typeSymbol, RequiredSymbols requiredSymbols, ref HashSet<ValidatableType> validatableTypes, ref List<ITypeSymbol> visitedTypes)
     {
         var members = new List<ValidatableProperty>();
+        var resolvedRecordProperty = new List<IPropertySymbol>();
+
+        // Special handling for record types to extract properties from
+        // the primary constructor.
+        if (typeSymbol is INamedTypeSymbol { IsRecord: true } namedType)
+        {
+            // Find the primary constructor for the record, account
+            // for members that are in base types to account for
+            // record inheritance scenarios
+            var primaryConstructor = namedType.Constructors
+                .FirstOrDefault(c => c.Parameters.Length > 0 && c.Parameters.All(p =>
+                    namedType.FindPropertyIncludingBaseTypes(p.Name) != null));
+
+            if (primaryConstructor != null)
+            {
+                // Process all parameters in constructor order to maintain parameter ordering
+                foreach (var parameter in primaryConstructor.Parameters)
+                {
+                    // Find the corresponding property in this type, we ignore
+                    // base types here since that will be handled by the inheritance
+                    // checks in the default ValidatableTypeInfo implementation.
+                    var correspondingProperty = typeSymbol.GetMembers()
+                        .OfType<IPropertySymbol>()
+                        .FirstOrDefault(p => string.Equals(p.Name, parameter.Name, System.StringComparison.OrdinalIgnoreCase));
+
+                    if (correspondingProperty != null)
+                    {
+                        resolvedRecordProperty.Add(correspondingProperty);
+
+                        // Check if the property's type is validatable, this resolves
+                        // validatable types in the inheritance hierarchy
+                        var hasValidatableType = TryExtractValidatableType(
+                            correspondingProperty.Type.UnwrapType(requiredSymbols.IEnumerable),
+                            requiredSymbols,
+                            ref validatableTypes,
+                            ref visitedTypes);
+
+                        members.Add(new ValidatableProperty(
+                            ContainingType: correspondingProperty.ContainingType,
+                            Type: correspondingProperty.Type,
+                            Name: correspondingProperty.Name,
+                            DisplayName: parameter.GetDisplayName(requiredSymbols.DisplayAttribute) ??
+                                        correspondingProperty.GetDisplayName(requiredSymbols.DisplayAttribute),
+                            Attributes: []));
+                    }
+                }
+            }
+        }
+
+        // Handle properties for classes and any properties not handled by the constructor
         foreach (var member in typeSymbol.GetMembers().OfType<IPropertySymbol>())
         {
+            // Skip compiler generated properties and properties already processed via
+            // the record processing logic above.
+            if (member.IsImplicitlyDeclared || resolvedRecordProperty.Contains(member, SymbolEqualityComparer.Default))
+            {
+                continue;
+            }
+
             var hasValidatableType = TryExtractValidatableType(member.Type.UnwrapType(requiredSymbols.IEnumerable), requiredSymbols, ref validatableTypes, ref visitedTypes);
             var attributes = ExtractValidationAttributes(member, requiredSymbols, out var isRequired);
+
             // If the member has no validation attributes or validatable types and is not required, skip it.
             if (attributes.IsDefaultOrEmpty && !hasValidatableType && !isRequired)
             {
                 continue;
             }
+
             members.Add(new ValidatableProperty(
                 ContainingType: member.ContainingType,
                 Type: member.Type,

+ 7 - 7
src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs

@@ -39,11 +39,11 @@ public class ComplexType
     public int IntegerWithRangeAndDisplayName { get; set; } = 50;
 
     [Required]
-    public SubType PropertyWithMemberAttributes { get; set; } = new SubType();
+    public SubType PropertyWithMemberAttributes { get; set; } = new SubType("some-value", default);
 
-    public SubType PropertyWithoutMemberAttributes { get; set; } = new SubType();
+    public SubType PropertyWithoutMemberAttributes { get; set; } = new SubType("some-value", default);
 
-    public SubTypeWithInheritance PropertyWithInheritance { get; set; } = new SubTypeWithInheritance();
+    public SubTypeWithInheritance PropertyWithInheritance { get; set; } = new SubTypeWithInheritance("some-value", default);
 
     public List<SubType> ListOfSubTypes { get; set; } = [];
 
@@ -62,16 +62,16 @@ public class DerivedValidationAttribute : ValidationAttribute
     public override bool IsValid(object? value) => value is int number && number % 2 == 0;
 }
 
-public class SubType
+public class SubType(string? requiredProperty, string? stringWithLength)
 {
     [Required]
-    public string RequiredProperty { get; set; } = "some-value";
+    public string RequiredProperty { get; } = requiredProperty;
 
     [StringLength(10)]
-    public string? StringWithLength { get; set; }
+    public string? StringWithLength { get; } = stringWithLength;
 }
 
-public class SubTypeWithInheritance : SubType
+public class SubTypeWithInheritance(string? requiredProperty, string? stringWithLength) : SubType(requiredProperty, stringWithLength)
 {
     [EmailAddress]
     public string? EmailString { get; set; }

+ 371 - 0
src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs

@@ -0,0 +1,371 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Http.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+    [Fact]
+    public async Task CanValidateRecordTypes()
+    {
+        // 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.AspNetCore.Http.Validation;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services.AddValidation();
+
+var app = builder.Build();
+
+app.MapPost("/validatable-record", (ValidatableRecord validatableRecord) => Results.Ok("Passed"!));
+
+app.Run();
+
+public class DerivedValidationAttribute : ValidationAttribute
+{
+    public override bool IsValid(object? value) => value is int number && number % 2 == 0;
+}
+
+public record SubType([Required] string RequiredProperty = "some-value", [StringLength(10)] string? StringWithLength = default);
+
+public record SubTypeWithInheritance([EmailAddress] string? EmailString, string RequiredProperty, string? StringWithLength) : SubType(RequiredProperty, StringWithLength);
+
+public record SubTypeWithoutConstructor
+{
+    [Required]
+    public string RequiredProperty { get; set; } = "some-value";
+
+    [StringLength(10)]
+    public string? StringWithLength { get; set; }
+}
+
+public static class CustomValidators
+{
+    public static ValidationResult Validate(int number, ValidationContext validationContext)
+    {
+        var parent = (ValidatableRecord)validationContext.ObjectInstance;
+        if (number == parent.IntegerWithRange)
+        {
+            return new ValidationResult(
+                "Can't use the same number value in two properties on the same class.",
+                new[] { validationContext.MemberName });
+        }
+
+        return ValidationResult.Success;
+    }
+}
+
+public record ValidatableRecord(
+    [Range(10, 100)]
+    int IntegerWithRange = 10,
+    [Range(10, 100), Display(Name = "Valid identifier")]
+    int IntegerWithRangeAndDisplayName = 50,
+    SubType PropertyWithMemberAttributes = default,
+    SubType PropertyWithoutMemberAttributes = default,
+    SubTypeWithInheritance PropertyWithInheritance = default,
+    SubTypeWithoutConstructor PropertyOfSubtypeWithoutConstructor = default,
+    List<SubType> ListOfSubTypes = default,
+    [DerivedValidation(ErrorMessage = "Value must be an even number")]
+    int IntegerWithDerivedValidationAttribute = 0,
+    [CustomValidation(typeof(CustomValidators), nameof(CustomValidators.Validate))]
+    int IntegerWithCustomValidation = 0,
+    [DerivedValidation, Range(10, 100)]
+    int PropertyWithMultipleAttributes = 10
+);
+""";
+        await Verify(source, out var compilation);
+        await VerifyEndpoint(compilation, "/validatable-record", async (endpoint, serviceProvider) =>
+        {
+            await InvalidIntegerWithRangeProducesError(endpoint);
+            await InvalidIntegerWithRangeAndDisplayNameProducesError(endpoint);
+            await InvalidRequiredSubtypePropertyProducesError(endpoint);
+            await InvalidSubTypeWithInheritancePropertyProducesError(endpoint);
+            await InvalidListOfSubTypesProducesError(endpoint);
+            await InvalidPropertyWithDerivedValidationAttributeProducesError(endpoint);
+            await InvalidPropertyWithMultipleAttributesProducesError(endpoint);
+            await InvalidPropertyWithCustomValidationProducesError(endpoint);
+            await InvalidPropertyOfSubtypeWithoutConstructorProducesError(endpoint);
+            await ValidInputProducesNoWarnings(endpoint);
+
+            async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint)
+            {
+
+                var payload = """
+                    {
+                        "IntegerWithRange": 5
+                    }
+                    """;
+                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 = """
+                    {
+                        "IntegerWithRangeAndDisplayName": 5
+                    }
+                    """;
+                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 InvalidRequiredSubtypePropertyProducesError(Endpoint endpoint)
+            {
+                var payload = """
+                    {
+                        "PropertyWithMemberAttributes": {
+                            "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("PropertyWithMemberAttributes.RequiredProperty", kvp.Key);
+                    Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
+                },
+                kvp =>
+                {
+                    Assert.Equal("PropertyWithMemberAttributes.StringWithLength", kvp.Key);
+                    Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
+                });
+            }
+
+            async Task InvalidSubTypeWithInheritancePropertyProducesError(Endpoint endpoint)
+            {
+                var payload = """
+                    {
+                        "PropertyWithInheritance": {
+                            "RequiredProperty": "",
+                            "StringWithLength": "way-too-long",
+                            "EmailString": "not-an-email"
+                        }
+                    }
+                    """;
+                var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+                await endpoint.RequestDelegate(context);
+
+                var problemDetails = await AssertBadRequest(context);
+                Assert.Collection(problemDetails.Errors,
+                kvp =>
+                {
+                    Assert.Equal("PropertyWithInheritance.EmailString", kvp.Key);
+                    Assert.Equal("The EmailString field is not a valid e-mail address.", kvp.Value.Single());
+                },
+                kvp =>
+                {
+                    Assert.Equal("PropertyWithInheritance.RequiredProperty", kvp.Key);
+                    Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
+                },
+                kvp =>
+                {
+                    Assert.Equal("PropertyWithInheritance.StringWithLength", kvp.Key);
+                    Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
+                });
+            }
+
+            async Task InvalidListOfSubTypesProducesError(Endpoint endpoint)
+            {
+                var payload = """
+                    {
+                        "ListOfSubTypes": [
+                            {
+                                "RequiredProperty": "",
+                                "StringWithLength": "way-too-long"
+                            },
+                            {
+                                "RequiredProperty": "valid",
+                                "StringWithLength": "way-too-long"
+                            },
+                            {
+                                "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("ListOfSubTypes[0].RequiredProperty", kvp.Key);
+                    Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
+                },
+                kvp =>
+                {
+                    Assert.Equal("ListOfSubTypes[0].StringWithLength", kvp.Key);
+                    Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
+                },
+                kvp =>
+                {
+                    Assert.Equal("ListOfSubTypes[1].StringWithLength", kvp.Key);
+                    Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single());
+                });
+            }
+
+            async Task InvalidPropertyWithDerivedValidationAttributeProducesError(Endpoint endpoint)
+            {
+                var payload = """
+                    {
+                        "IntegerWithDerivedValidationAttribute": 5
+                    }
+                    """;
+                var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+                await endpoint.RequestDelegate(context);
+
+                var problemDetails = await AssertBadRequest(context);
+                Assert.Collection(problemDetails.Errors, kvp =>
+                {
+                    Assert.Equal("IntegerWithDerivedValidationAttribute", kvp.Key);
+                    Assert.Equal("Value must be an even number", kvp.Value.Single());
+                });
+            }
+
+            async Task InvalidPropertyWithMultipleAttributesProducesError(Endpoint endpoint)
+            {
+                var payload = """
+                    {
+                        "PropertyWithMultipleAttributes": 5
+                    }
+                    """;
+                var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+                await endpoint.RequestDelegate(context);
+
+                var problemDetails = await AssertBadRequest(context);
+                Assert.Collection(problemDetails.Errors, kvp =>
+                {
+                    Assert.Equal("PropertyWithMultipleAttributes", kvp.Key);
+                    Assert.Collection(kvp.Value,
+                    error =>
+                    {
+                        Assert.Equal("The field PropertyWithMultipleAttributes is invalid.", error);
+                    },
+                    error =>
+                    {
+                        Assert.Equal("The field PropertyWithMultipleAttributes must be between 10 and 100.", error);
+                    });
+                });
+            }
+
+            async Task InvalidPropertyWithCustomValidationProducesError(Endpoint endpoint)
+            {
+                var payload = """
+                    {
+                        "IntegerWithRange": 42,
+                        "IntegerWithCustomValidation": 42
+                    }
+                    """;
+                var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+                await endpoint.RequestDelegate(context);
+
+                var problemDetails = await AssertBadRequest(context);
+                Assert.Collection(problemDetails.Errors, kvp =>
+                {
+                    Assert.Equal("IntegerWithCustomValidation", kvp.Key);
+                    var error = Assert.Single(kvp.Value);
+                    Assert.Equal("Can't use the same number value in two properties on the same class.", error);
+                });
+            }
+
+            async Task InvalidPropertyOfSubtypeWithoutConstructorProducesError(Endpoint endpoint)
+            {
+                var payload = """
+                    {
+                        "PropertyOfSubtypeWithoutConstructor": {
+                            "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("PropertyOfSubtypeWithoutConstructor.RequiredProperty", kvp.Key);
+                    Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single());
+                },
+                kvp =>
+                {
+                    Assert.Equal("PropertyOfSubtypeWithoutConstructor.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,
+                        "PropertyWithMemberAttributes": {
+                            "RequiredProperty": "valid",
+                            "StringWithLength": "valid"
+                        },
+                        "PropertyWithoutMemberAttributes": {
+                            "RequiredProperty": "valid",
+                            "StringWithLength": "valid"
+                        },
+                        "PropertyWithInheritance": {
+                            "RequiredProperty": "valid",
+                            "StringWithLength": "valid",
+                            "EmailString": "[email protected]"
+                        },
+                        "ListOfSubTypes": [],
+                        "IntegerWithDerivedValidationAttribute": 2,
+                        "IntegerWithCustomValidation": 0,
+                        "PropertyWithMultipleAttributes": 12
+                    }
+                    """;
+                var context = CreateHttpContextWithPayload(payload, serviceProvider);
+                await endpoint.RequestDelegate(context);
+
+                Assert.Equal(200, context.Response.StatusCode);
+            }
+        });
+
+    }
+}

+ 30 - 4
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs

@@ -189,7 +189,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         [InterceptsLocation]
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -216,13 +216,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }

+ 30 - 4
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs

@@ -140,7 +140,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         [InterceptsLocation]
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -167,13 +167,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }

+ 30 - 4
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs

@@ -78,7 +78,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         [InterceptsLocation]
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -105,13 +105,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }

+ 30 - 4
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs

@@ -178,7 +178,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         [InterceptsLocation]
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -205,13 +205,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }

+ 286 - 0
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs

@@ -0,0 +1,286 @@
+//HintName: ValidatableInfoResolver.g.cs
+#nullable enable annotations
+//------------------------------------------------------------------------------
+// <auto-generated>
+//     This code was generated by a tool.
+//
+//     Changes to this file may cause incorrect behavior and will be lost if
+//     the code is regenerated.
+// </auto-generated>
+//------------------------------------------------------------------------------
+#nullable enable
+
+namespace System.Runtime.CompilerServices
+{
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+    file sealed class InterceptsLocationAttribute : System.Attribute
+    {
+        public InterceptsLocationAttribute(int version, string data)
+        {
+        }
+    }
+}
+
+namespace Microsoft.AspNetCore.Http.Validation.Generated
+{
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+    file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo
+    {
+        public GeneratedValidatablePropertyInfo(
+            [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)]
+            global::System.Type containingType,
+            global::System.Type propertyType,
+            string name,
+            string displayName) : base(containingType, propertyType, name, displayName)
+        {
+            ContainingType = containingType;
+            Name = name;
+        }
+
+        internal global::System.Type ContainingType { get; }
+        internal string Name { get; }
+
+        protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
+            => ValidationAttributeCache.GetValidationAttributes(ContainingType, Name);
+    }
+
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+    file sealed class GeneratedValidatableTypeInfo : global::Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo
+    {
+        public GeneratedValidatableTypeInfo(
+            [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
+            global::System.Type type,
+            ValidatablePropertyInfo[] members) : base(type, members) { }
+    }
+
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+    file class GeneratedValidatableInfoResolver : global::Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver
+    {
+        public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo)
+        {
+            validatableInfo = null;
+            if (type == typeof(global::SubType))
+            {
+                validatableInfo = CreateSubType();
+                return true;
+            }
+            if (type == typeof(global::SubTypeWithInheritance))
+            {
+                validatableInfo = CreateSubTypeWithInheritance();
+                return true;
+            }
+            if (type == typeof(global::SubTypeWithoutConstructor))
+            {
+                validatableInfo = CreateSubTypeWithoutConstructor();
+                return true;
+            }
+            if (type == typeof(global::ValidatableRecord))
+            {
+                validatableInfo = CreateValidatableRecord();
+                return true;
+            }
+
+            return false;
+        }
+
+        // No-ops, rely on runtime code for ParameterInfo-based resolution
+        public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo)
+        {
+            validatableInfo = null;
+            return false;
+        }
+
+        private ValidatableTypeInfo CreateSubType()
+        {
+            return new GeneratedValidatableTypeInfo(
+                type: typeof(global::SubType),
+                members: [
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::SubType),
+                        propertyType: typeof(string),
+                        name: "RequiredProperty",
+                        displayName: "RequiredProperty"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::SubType),
+                        propertyType: typeof(string),
+                        name: "StringWithLength",
+                        displayName: "StringWithLength"
+                    ),
+                ]
+            );
+        }
+        private ValidatableTypeInfo CreateSubTypeWithInheritance()
+        {
+            return new GeneratedValidatableTypeInfo(
+                type: typeof(global::SubTypeWithInheritance),
+                members: [
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::SubTypeWithInheritance),
+                        propertyType: typeof(string),
+                        name: "EmailString",
+                        displayName: "EmailString"
+                    ),
+                ]
+            );
+        }
+        private ValidatableTypeInfo CreateSubTypeWithoutConstructor()
+        {
+            return new GeneratedValidatableTypeInfo(
+                type: typeof(global::SubTypeWithoutConstructor),
+                members: [
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::SubTypeWithoutConstructor),
+                        propertyType: typeof(string),
+                        name: "RequiredProperty",
+                        displayName: "RequiredProperty"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::SubTypeWithoutConstructor),
+                        propertyType: typeof(string),
+                        name: "StringWithLength",
+                        displayName: "StringWithLength"
+                    ),
+                ]
+            );
+        }
+        private ValidatableTypeInfo CreateValidatableRecord()
+        {
+            return new GeneratedValidatableTypeInfo(
+                type: typeof(global::ValidatableRecord),
+                members: [
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(int),
+                        name: "IntegerWithRange",
+                        displayName: "IntegerWithRange"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(int),
+                        name: "IntegerWithRangeAndDisplayName",
+                        displayName: "Valid identifier"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(global::SubType),
+                        name: "PropertyWithMemberAttributes",
+                        displayName: "PropertyWithMemberAttributes"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(global::SubType),
+                        name: "PropertyWithoutMemberAttributes",
+                        displayName: "PropertyWithoutMemberAttributes"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(global::SubTypeWithInheritance),
+                        name: "PropertyWithInheritance",
+                        displayName: "PropertyWithInheritance"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(global::SubTypeWithoutConstructor),
+                        name: "PropertyOfSubtypeWithoutConstructor",
+                        displayName: "PropertyOfSubtypeWithoutConstructor"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(global::System.Collections.Generic.List<global::SubType>),
+                        name: "ListOfSubTypes",
+                        displayName: "ListOfSubTypes"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(int),
+                        name: "IntegerWithDerivedValidationAttribute",
+                        displayName: "IntegerWithDerivedValidationAttribute"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(int),
+                        name: "IntegerWithCustomValidation",
+                        displayName: "IntegerWithCustomValidation"
+                    ),
+                    new GeneratedValidatablePropertyInfo(
+                        containingType: typeof(global::ValidatableRecord),
+                        propertyType: typeof(int),
+                        name: "PropertyWithMultipleAttributes",
+                        displayName: "PropertyWithMultipleAttributes"
+                    ),
+                ]
+            );
+        }
+
+    }
+
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+    file static class GeneratedServiceCollectionExtensions
+    {
+        [InterceptsLocation]
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
+        {
+            // Use non-extension method to avoid infinite recursion.
+            return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
+            {
+                options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver());
+                if (configureOptions is not null)
+                {
+                    configureOptions(options);
+                }
+            });
+        }
+    }
+
+    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+    file static class ValidationAttributeCache
+    {
+        private sealed record CacheKey(global::System.Type ContainingType, string PropertyName);
+        private static readonly global::System.Collections.Concurrent.ConcurrentDictionary<CacheKey, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> _cache = new();
+
+        public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes(
+            global::System.Type containingType,
+            string propertyName)
+        {
+            var key = new CacheKey(containingType, propertyName);
+            return _cache.GetOrAdd(key, static k =>
+            {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
+                var property = k.ContainingType.GetProperty(k.PropertyName);
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
+                {
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
+                }
+
+                return results.ToArray();
+            });
+        }
+    }
+}

+ 30 - 4
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs

@@ -103,7 +103,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         [InterceptsLocation]
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -130,13 +130,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }

+ 30 - 4
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs

@@ -183,7 +183,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         [InterceptsLocation]
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -210,13 +210,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }

+ 30 - 4
src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs

@@ -97,7 +97,7 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
     file static class GeneratedServiceCollectionExtensions
     {
         [InterceptsLocation]
-        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<ValidationOptions>? configureOptions = null)
+        public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.AspNetCore.Http.Validation.ValidationOptions>? configureOptions = null)
         {
             // Use non-extension method to avoid infinite recursion.
             return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
@@ -124,13 +124,39 @@ namespace Microsoft.AspNetCore.Http.Validation.Generated
             var key = new CacheKey(containingType, propertyName);
             return _cache.GetOrAdd(key, static k =>
             {
+                var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
+
+                // Get attributes from the property
                 var property = k.ContainingType.GetProperty(k.PropertyName);
-                if (property == null)
+                if (property != null)
+                {
+                    var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+                        .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
+
+                    results.AddRange(propertyAttributes);
+                }
+
+                // Check constructors for parameters that match the property name
+                // to handle record scenarios
+                foreach (var constructor in k.ContainingType.GetConstructors())
                 {
-                    return [];
+                    // Look for parameter with matching name (case insensitive)
+                    var parameter = global::System.Linq.Enumerable.FirstOrDefault(
+                        constructor.GetParameters(),
+                        p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
+
+                    if (parameter != null)
+                    {
+                        var paramAttributes = global::System.Reflection.CustomAttributeExtensions
+                            .GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
+
+                        results.AddRange(paramAttributes);
+
+                        break;
+                    }
                 }
 
-                return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true)];
+                return results.ToArray();
             });
         }
     }