From b65017b3c7eaa4e5e85cc722e13ea50fd6452f2c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 22 May 2025 15:56:12 +0000
Subject: [PATCH 1/7] Initial plan for issue
From 0f681b4e11032ab339694517e2c67d06d607c2ff Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 22 May 2025 16:12:42 +0000
Subject: [PATCH 2/7] Created project structure and moved source files
Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com>
---
eng/ProjectReferences.props | 1 +
...rosoft.AspNetCore.Http.Abstractions.csproj | 1 +
.../ForwardingValidationDirectives.cs | 18 +
...spNetCore.Http.ValidationsGenerator.csproj | 19 +-
.../ValidationsGeneratorForwarding.cs | 20 +
...icrosoft.AspNetCore.Http.Extensions.csproj | 1 +
src/Validation/Validations.slnf | 11 +
src/Validation/build.cmd | 4 +
src/Validation/build.sh | 6 +
.../Emitters/ValidationsGenerator.Emitter.cs | 222 +++++
.../gen/Extensions/ISymbolExtensions.cs | 35 +
.../gen/Extensions/ITypeSymbolExtensions.cs | 141 ++++
.../IncrementalValuesProviderExtensions.cs | 112 +++
...ons.Validation.ValidationsGenerator.csproj | 34 +
src/Validation/gen/Models/RequiredSymbols.cs | 25 +
.../gen/Models/ValidatableProperty.cs | 15 +
src/Validation/gen/Models/ValidatableType.cs | 12 +
.../gen/Models/ValidatableTypeComparer.cs | 30 +
.../gen/Models/ValidationAttribute.cs | 14 +
.../ValidationsGenerator.AddValidation.cs | 38 +
.../ValidationsGenerator.AttributeParser.cs | 31 +
.../ValidationsGenerator.EndpointsParser.cs | 50 ++
.../ValidationsGenerator.TypesParser.cs | 212 +++++
src/Validation/gen/ValidationsGenerator.cs | 47 ++
src/Validation/src/IValidatableInfo.cs | 22 +
.../src/IValidatableInfoResolver.cs | 33 +
.../Microsoft.Extensions.Validation.csproj | 22 +
src/Validation/src/PublicAPI.Shipped.txt | 1 +
src/Validation/src/PublicAPI.Unshipped.txt | 40 +
...RuntimeValidatableParameterInfoResolver.cs | 111 +++
src/Validation/src/TypeExtensions.cs | 134 +++
.../src/ValidatableParameterInfo.cs | 139 +++
src/Validation/src/ValidatablePropertyInfo.cs | 173 ++++
.../src/ValidatableTypeAttribute.cs | 16 +
src/Validation/src/ValidatableTypeInfo.cs | 133 +++
src/Validation/src/ValidateContext.cs | 100 +++
src/Validation/src/ValidationOptions.cs | 75 ++
.../ValidationServiceCollectionExtensions.cs | 34 +
...crosoft.Extensions.Validation.Tests.csproj | 12 +
...meValidatableParameterInfoResolverTests.cs | 191 +++++
.../ValidatableInfoResolverTests.cs | 223 +++++
.../ValidatableParameterInfoTests.cs | 432 ++++++++++
.../ValidatableTypeInfoTests.cs | 790 ++++++++++++++++++
...lidation.ValidationsGenerator.Tests.csproj | 22 +
.../ValidationsGenerator.ComplexType.cs | 374 +++++++++
...ValidationsGenerator.IValidatableObject.cs | 208 +++++
...ValidationsGenerator.MultipleNamespaces.cs | 126 +++
.../ValidationsGenerator.NoOp.cs | 178 ++++
.../ValidationsGenerator.Parameters.cs | 100 +++
.../ValidationsGenerator.Parsable.cs | 122 +++
.../ValidationsGenerator.Polymorphism.cs | 202 +++++
.../ValidationsGenerator.RecordType.cs | 371 ++++++++
.../ValidationsGenerator.Recursion.cs | 158 ++++
.../ValidationsGenerator.ValidatableType.cs | 381 +++++++++
.../ValidationsGeneratorTestBase.cs | 586 +++++++++++++
...ypes#ValidatableInfoResolver.g.verified.cs | 244 ++++++
...ject#ValidatableInfoResolver.g.verified.cs | 195 +++++
...aces#ValidatableInfoResolver.g.verified.cs | 175 ++++
...ters#ValidatableInfoResolver.g.verified.cs | 193 +++++
...ypes#ValidatableInfoResolver.g.verified.cs | 225 +++++
...ypes#ValidatableInfoResolver.g.verified.cs | 271 ++++++
...ypes#ValidatableInfoResolver.g.verified.cs | 166 ++++
...ties#ValidatableInfoResolver.g.verified.cs | 208 +++++
...bute#ValidatableInfoResolver.g.verified.cs | 238 ++++++
...ypes#ValidatableInfoResolver.g.verified.cs | 160 ++++
65 files changed, 8667 insertions(+), 16 deletions(-)
create mode 100644 src/Http/Http.Abstractions/src/Validation/ForwardingValidationDirectives.cs
create mode 100644 src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/ValidationsGeneratorForwarding.cs
create mode 100644 src/Validation/Validations.slnf
create mode 100644 src/Validation/build.cmd
create mode 100755 src/Validation/build.sh
create mode 100644 src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs
create mode 100644 src/Validation/gen/Extensions/ISymbolExtensions.cs
create mode 100644 src/Validation/gen/Extensions/ITypeSymbolExtensions.cs
create mode 100644 src/Validation/gen/Extensions/IncrementalValuesProviderExtensions.cs
create mode 100644 src/Validation/gen/Microsoft.Extensions.Validation.ValidationsGenerator.csproj
create mode 100644 src/Validation/gen/Models/RequiredSymbols.cs
create mode 100644 src/Validation/gen/Models/ValidatableProperty.cs
create mode 100644 src/Validation/gen/Models/ValidatableType.cs
create mode 100644 src/Validation/gen/Models/ValidatableTypeComparer.cs
create mode 100644 src/Validation/gen/Models/ValidationAttribute.cs
create mode 100644 src/Validation/gen/Parsers/ValidationsGenerator.AddValidation.cs
create mode 100644 src/Validation/gen/Parsers/ValidationsGenerator.AttributeParser.cs
create mode 100644 src/Validation/gen/Parsers/ValidationsGenerator.EndpointsParser.cs
create mode 100644 src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
create mode 100644 src/Validation/gen/ValidationsGenerator.cs
create mode 100644 src/Validation/src/IValidatableInfo.cs
create mode 100644 src/Validation/src/IValidatableInfoResolver.cs
create mode 100644 src/Validation/src/Microsoft.Extensions.Validation.csproj
create mode 100644 src/Validation/src/PublicAPI.Shipped.txt
create mode 100644 src/Validation/src/PublicAPI.Unshipped.txt
create mode 100644 src/Validation/src/RuntimeValidatableParameterInfoResolver.cs
create mode 100644 src/Validation/src/TypeExtensions.cs
create mode 100644 src/Validation/src/ValidatableParameterInfo.cs
create mode 100644 src/Validation/src/ValidatablePropertyInfo.cs
create mode 100644 src/Validation/src/ValidatableTypeAttribute.cs
create mode 100644 src/Validation/src/ValidatableTypeInfo.cs
create mode 100644 src/Validation/src/ValidateContext.cs
create mode 100644 src/Validation/src/ValidationOptions.cs
create mode 100644 src/Validation/src/ValidationServiceCollectionExtensions.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableInfoResolverTests.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableParameterInfoTests.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/Microsoft.Extensions.Validation.ValidationsGenerator.Tests.csproj
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ComplexType.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.IValidatableObject.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.MultipleNamespaces.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.NoOp.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parameters.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parsable.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Polymorphism.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.RecordType.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Recursion.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ValidatableType.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGeneratorTestBase.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateMultipleNamespaces#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateTypeWithParsableProperties#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs
create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs
diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props
index 58eb2c5d2d61..6efcc28f2f2b 100644
--- a/eng/ProjectReferences.props
+++ b/eng/ProjectReferences.props
@@ -38,6 +38,7 @@
+
diff --git a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
index d383efa6a20e..3196824956cf 100644
--- a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
+++ b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
@@ -21,6 +21,7 @@ Microsoft.AspNetCore.Http.HttpResponse
+
diff --git a/src/Http/Http.Abstractions/src/Validation/ForwardingValidationDirectives.cs b/src/Http/Http.Abstractions/src/Validation/ForwardingValidationDirectives.cs
new file mode 100644
index 000000000000..0d7c3c48d63d
--- /dev/null
+++ b/src/Http/Http.Abstractions/src/Validation/ForwardingValidationDirectives.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+// Forward validation-related types from Microsoft.Extensions.Validation
+// to maintain backward compatibility
+
+using Microsoft.Extensions.DependencyInjection;
+using System.Runtime.CompilerServices;
+
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.IValidatableInfo))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.IValidatableInfoResolver))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.ValidatableParameterInfo))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.ValidatablePropertyInfo))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.ValidatableTypeAttribute))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.ValidatableTypeInfo))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.ValidateContext))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.Validation.ValidationOptions))]
+[assembly: TypeForwardedTo(typeof(Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions))]
\ No newline at end of file
diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Microsoft.AspNetCore.Http.ValidationsGenerator.csproj b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Microsoft.AspNetCore.Http.ValidationsGenerator.csproj
index a55218bb4185..d666ac8015e6 100644
--- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Microsoft.AspNetCore.Http.ValidationsGenerator.csproj
+++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Microsoft.AspNetCore.Http.ValidationsGenerator.csproj
@@ -11,25 +11,12 @@
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/ValidationsGeneratorForwarding.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/ValidationsGeneratorForwarding.cs
new file mode 100644
index 000000000000..c8a432c4a00a
--- /dev/null
+++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/ValidationsGeneratorForwarding.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.Extensions.Validation.ValidationsGenerator;
+
+// This class forwards to the new generator implementation
+namespace Microsoft.AspNetCore.Http.ValidationsGenerator;
+
+public sealed partial class ValidationsGenerator : IIncrementalGenerator
+{
+ private static readonly Microsoft.Extensions.Validation.ValidationsGenerator.ValidationsGenerator _forwardingGenerator =
+ new Microsoft.Extensions.Validation.ValidationsGenerator.ValidationsGenerator();
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ _forwardingGenerator.Initialize(context);
+ }
+}
\ No newline at end of file
diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
index aa9645a5faa1..e1560b3d4428 100644
--- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
+++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
@@ -43,6 +43,7 @@
+
diff --git a/src/Validation/Validations.slnf b/src/Validation/Validations.slnf
new file mode 100644
index 000000000000..51c95f704d46
--- /dev/null
+++ b/src/Validation/Validations.slnf
@@ -0,0 +1,11 @@
+{
+ "solution": {
+ "path": "..\\..\\AspNetCore.sln",
+ "projects": [
+ "src\\Validation\\src\\Microsoft.Extensions.Validation.csproj",
+ "src\\Validation\\test\\Microsoft.Extensions.Validation.Tests\\Microsoft.Extensions.Validation.Tests.csproj",
+ "src\\Validation\\gen\\Microsoft.Extensions.Validation.ValidationsGenerator.csproj",
+ "src\\Validation\\test\\Microsoft.Extensions.Validation.ValidationsGenerator.Tests\\Microsoft.Extensions.Validation.ValidationsGenerator.Tests.csproj"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/Validation/build.cmd b/src/Validation/build.cmd
new file mode 100644
index 000000000000..7be8285e8a42
--- /dev/null
+++ b/src/Validation/build.cmd
@@ -0,0 +1,4 @@
+@ECHO OFF
+SET RepoRoot=%~dp0..\..
+
+call %RepoRoot%\eng\build.cmd -projects %RepoRoot%\src\Validation\**\*.csproj %*
\ No newline at end of file
diff --git a/src/Validation/build.sh b/src/Validation/build.sh
new file mode 100755
index 000000000000..2663e52d431d
--- /dev/null
+++ b/src/Validation/build.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+"$DIR/../../eng/build.sh" --projects "$DIR/**/*.csproj" "$@"
\ No newline at end of file
diff --git a/src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs b/src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs
new file mode 100644
index 000000000000..729215931d93
--- /dev/null
+++ b/src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs
@@ -0,0 +1,222 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+using System.Text;
+using Microsoft.CodeAnalysis.CSharp;
+using System.IO;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+public sealed partial class ValidationsGenerator : IIncrementalGenerator
+{
+ public static string GeneratedCodeConstructor => $@"global::System.CodeDom.Compiler.GeneratedCodeAttribute(""{typeof(ValidationsGenerator).Assembly.FullName}"", ""{typeof(ValidationsGenerator).Assembly.GetName().Version}"")";
+ public static string GeneratedCodeAttribute => $"[{GeneratedCodeConstructor}]";
+
+ internal static void Emit(SourceProductionContext context, (InterceptableLocation? AddValidation, ImmutableArray ValidatableTypes) emitInputs)
+ {
+ if (emitInputs.AddValidation is null)
+ {
+ // Avoid generating code if no AddValidation call was found.
+ return;
+ }
+ var source = Emit(emitInputs.AddValidation, emitInputs.ValidatableTypes);
+ context.AddSource("ValidatableInfoResolver.g.cs", SourceText.From(source, Encoding.UTF8));
+ }
+
+ private static string Emit(InterceptableLocation addValidation, ImmutableArray validatableTypes) => $$"""
+#nullable enable annotations
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+#nullable enable
+#pragma warning disable ASP0029
+
+namespace System.Runtime.CompilerServices
+{
+ {{GeneratedCodeAttribute}}
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : System.Attribute
+ {
+ public InterceptsLocationAttribute(int version, string data)
+ {
+ }
+ }
+}
+
+namespace Microsoft.Extensions.Validation.Generated
+{
+ {{GeneratedCodeAttribute}}
+ file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.Extensions.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;
+ }
+
+ [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)]
+ internal global::System.Type ContainingType { get; }
+ internal string Name { get; }
+
+ protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
+ => ValidationAttributeCache.GetValidationAttributes(ContainingType, Name);
+ }
+
+ {{GeneratedCodeAttribute}}
+ file sealed class GeneratedValidatableTypeInfo : global::Microsoft.Extensions.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) { }
+ }
+
+ {{GeneratedCodeAttribute}}
+ file class GeneratedValidatableInfoResolver : global::Microsoft.Extensions.Validation.IValidatableInfoResolver
+ {
+ public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo)
+ {
+ validatableInfo = null;
+{{EmitTypeChecks(validatableTypes)}}
+ 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.Extensions.Validation.IValidatableInfo? validatableInfo)
+ {
+ validatableInfo = null;
+ return false;
+ }
+ }
+
+ {{GeneratedCodeAttribute}}
+ file static class GeneratedServiceCollectionExtensions
+ {
+ {{addValidation.GetInterceptsLocationAttributeSyntax()}}
+ public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? 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);
+ }
+ });
+ }
+ }
+
+ {{GeneratedCodeAttribute}}
+ file static class ValidationAttributeCache
+ {
+ private sealed record CacheKey([property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] global::System.Type ContainingType, string PropertyName);
+ private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _cache = new();
+
+ public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes(
+ [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)]
+ 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();
+
+ // Get attributes from the property
+ var property = k.ContainingType.GetProperty(k.PropertyName);
+ if (property != null)
+ {
+ var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
+ .GetCustomAttributes(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(parameter, inherit: true);
+
+ results.AddRange(paramAttributes);
+
+ break;
+ }
+ }
+
+ return results.ToArray();
+ });
+ }
+ }
+}
+""";
+
+ private static string EmitTypeChecks(ImmutableArray validatableTypes)
+ {
+ var sw = new StringWriter();
+ var cw = new CodeWriter(sw, baseIndent: 3);
+ foreach (var validatableType in validatableTypes)
+ {
+ var typeName = validatableType.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ cw.WriteLine($"if (type == typeof({typeName}))");
+ cw.StartBlock();
+ cw.WriteLine($"validatableInfo = new GeneratedValidatableTypeInfo(");
+ cw.Indent++;
+ cw.WriteLine($"type: typeof({typeName}),");
+ if (validatableType.Members.IsDefaultOrEmpty)
+ {
+ cw.WriteLine("members: []");
+ }
+ else
+ {
+ cw.WriteLine("members: [");
+ cw.Indent++;
+ foreach (var member in validatableType.Members)
+ {
+ EmitValidatableMemberForCreate(member, cw);
+ }
+ cw.Indent--;
+ cw.WriteLine("]");
+ }
+ cw.Indent--;
+ cw.WriteLine(");");
+ cw.WriteLine("return true;");
+ cw.EndBlock();
+ }
+ return sw.ToString();
+ }
+
+ private static void EmitValidatableMemberForCreate(ValidatableProperty member, CodeWriter cw)
+ {
+ cw.WriteLine("new GeneratedValidatablePropertyInfo(");
+ cw.Indent++;
+ cw.WriteLine($"containingType: typeof({member.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}),");
+ cw.WriteLine($"propertyType: typeof({member.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}),");
+ cw.WriteLine($"name: \"{member.Name}\",");
+ cw.WriteLine($"displayName: \"{member.DisplayName}\"");
+ cw.Indent--;
+ cw.WriteLine("),");
+ }
+}
diff --git a/src/Validation/gen/Extensions/ISymbolExtensions.cs b/src/Validation/gen/Extensions/ISymbolExtensions.cs
new file mode 100644
index 000000000000..2e7d72d852fc
--- /dev/null
+++ b/src/Validation/gen/Extensions/ISymbolExtensions.cs
@@ -0,0 +1,35 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal static class ISymbolExtensions
+{
+ public static string GetDisplayName(this ISymbol property, INamedTypeSymbol displayAttribute)
+ {
+ var displayNameAttribute = property.GetAttributes()
+ .FirstOrDefault(attribute =>
+ attribute.AttributeClass is { } attributeClass &&
+ SymbolEqualityComparer.Default.Equals(attributeClass, displayAttribute));
+
+ if (displayNameAttribute is not null)
+ {
+ if (!displayNameAttribute.NamedArguments.IsDefaultOrEmpty)
+ {
+ foreach (var namedArgument in displayNameAttribute.NamedArguments)
+ {
+ if (string.Equals(namedArgument.Key, "Name", StringComparison.Ordinal))
+ {
+ return namedArgument.Value.Value?.ToString() ?? property.Name;
+ }
+ }
+ }
+ }
+
+ return property.Name;
+ }
+}
diff --git a/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs
new file mode 100644
index 000000000000..38a4105e67f0
--- /dev/null
+++ b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs
@@ -0,0 +1,141 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal static class ITypeSymbolExtensions
+{
+ public static bool IsEnumerable(this ITypeSymbol type, INamedTypeSymbol enumerable)
+ {
+ if (type.SpecialType == SpecialType.System_String)
+ {
+ return false;
+ }
+
+ return type.ImplementsInterface(enumerable) || SymbolEqualityComparer.Default.Equals(type, enumerable);
+ }
+
+ public static bool ImplementsValidationAttribute(this ITypeSymbol typeSymbol, INamedTypeSymbol validationAttributeSymbol)
+ {
+ var baseType = typeSymbol.BaseType;
+ while (baseType != null)
+ {
+ if (SymbolEqualityComparer.Default.Equals(baseType, validationAttributeSymbol))
+ {
+ return true;
+ }
+ baseType = baseType.BaseType;
+ }
+
+ return false;
+ }
+
+ public static ITypeSymbol UnwrapType(this ITypeSymbol type, INamedTypeSymbol enumerable)
+ {
+ if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
+ type is INamedTypeSymbol { TypeArguments.Length: 1 })
+ {
+ // Extract the T from a Nullable
+ type = ((INamedTypeSymbol)type).TypeArguments[0];
+ }
+
+ if (type.NullableAnnotation == NullableAnnotation.Annotated)
+ {
+ // Remove the nullable annotation but keep any generic arguments, e.g. List? → List
+ // so we can retain them in future steps.
+ type = type.WithNullableAnnotation(NullableAnnotation.NotAnnotated);
+ }
+
+ if (type is INamedTypeSymbol namedType && namedType.IsEnumerable(enumerable) && namedType.TypeArguments.Length == 1)
+ {
+ // Extract the T from an IEnumerable or List
+ type = namedType.TypeArguments[0];
+ }
+
+ return type;
+ }
+
+ internal static bool ImplementsInterface(this ITypeSymbol type, ITypeSymbol interfaceType)
+ {
+ foreach (var iface in type.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(interfaceType, iface))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ internal static ImmutableArray? GetJsonDerivedTypes(this ITypeSymbol type, INamedTypeSymbol jsonDerivedTypeAttribute)
+ {
+ var derivedTypes = ImmutableArray.CreateBuilder();
+ foreach (var attribute in type.GetAttributes())
+ {
+ if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, jsonDerivedTypeAttribute))
+ {
+ var derivedType = (INamedTypeSymbol?)attribute.ConstructorArguments[0].Value;
+ if (derivedType is not null && !SymbolEqualityComparer.Default.Equals(derivedType, type))
+ {
+ derivedTypes.Add(derivedType);
+ }
+ }
+ }
+
+ return derivedTypes.Count == 0 ? null : derivedTypes.ToImmutable();
+ }
+
+ // Types exempted here have special binding rules in RDF and RDG and are not validatable
+ // types themselves so we short-circuit on them.
+ internal static bool IsExemptType(this ITypeSymbol type, WellKnownTypes wellKnownTypes)
+ {
+ return SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_HttpContext))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_HttpRequest))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_HttpResponse))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_Threading_CancellationToken))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_IFormCollection))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_IFormFileCollection))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_IFormFile))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_IO_Stream))
+ || SymbolEqualityComparer.Default.Equals(type, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_IO_Pipelines_PipeReader));
+ }
+
+ internal static IPropertySymbol? FindPropertyIncludingBaseTypes(this INamedTypeSymbol typeSymbol, string propertyName)
+ {
+ var property = typeSymbol.GetMembers()
+ .OfType()
+ .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;
+ }
+
+ ///
+ /// Checks if the parameter is marked with [FromService] or [FromKeyedService] attributes.
+ ///
+ /// The parameter to check.
+ /// The symbol representing the [FromService] attribute.
+ /// The symbol representing the [FromKeyedService] attribute.
+ internal static bool IsServiceParameter(this IParameterSymbol parameter, INamedTypeSymbol fromServiceMetadataSymbol, INamedTypeSymbol fromKeyedServiceAttributeSymbol)
+ {
+ return parameter.GetAttributes().Any(attr =>
+ attr.AttributeClass is not null &&
+ (attr.AttributeClass.ImplementsInterface(fromServiceMetadataSymbol) ||
+ SymbolEqualityComparer.Default.Equals(attr.AttributeClass, fromKeyedServiceAttributeSymbol)));
+ }
+}
diff --git a/src/Validation/gen/Extensions/IncrementalValuesProviderExtensions.cs b/src/Validation/gen/Extensions/IncrementalValuesProviderExtensions.cs
new file mode 100644
index 000000000000..5b7aa171fac8
--- /dev/null
+++ b/src/Validation/gen/Extensions/IncrementalValuesProviderExtensions.cs
@@ -0,0 +1,112 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal static class IncrementalValuesProviderExtensions
+{
+ public static IncrementalValuesProvider Distinct(this IncrementalValuesProvider source, IEqualityComparer comparer)
+ {
+ return source
+ .Collect()
+ .WithComparer(ImmutableArrayEqualityComparer.Instance)
+ .SelectMany((values, cancellationToken) =>
+ {
+ if (values.IsEmpty)
+ {
+ return values;
+ }
+
+ var results = ImmutableArray.CreateBuilder(values.Length);
+ HashSet set = new(comparer);
+
+ foreach (var value in values)
+ {
+ if (set.Add(value))
+ {
+ results.Add(value);
+ }
+ }
+
+ return results.DrainToImmutable();
+ });
+ }
+
+ public static IncrementalValuesProvider Concat(
+ this IncrementalValuesProvider> first,
+ IncrementalValuesProvider> second)
+ {
+ return first.Collect()
+ .Combine(second.Collect())
+ .SelectMany((tuple, _) =>
+ {
+ if (tuple.Left.IsEmpty && tuple.Right.IsEmpty)
+ {
+ return [];
+ }
+
+ var results = ImmutableArray.CreateBuilder(tuple.Left.Length + tuple.Right.Length);
+ for (var i = 0; i < tuple.Left.Length; i++)
+ {
+ results.AddRange(tuple.Left[i]);
+ }
+ for (var i = 0; i < tuple.Right.Length; i++)
+ {
+ results.AddRange(tuple.Right[i]);
+ }
+ return results.DrainToImmutable();
+ });
+ }
+
+ private sealed class ImmutableArrayEqualityComparer : IEqualityComparer>
+ {
+ public static readonly ImmutableArrayEqualityComparer Instance = new();
+
+ public bool Equals(ImmutableArray x, ImmutableArray y)
+ {
+ if (x.IsDefault)
+ {
+ return y.IsDefault;
+ }
+ else if (y.IsDefault)
+ {
+ return false;
+ }
+
+ if (x.Length != y.Length)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < x.Length; i++)
+ {
+ if (!EqualityComparer.Default.Equals(x[i], y[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public int GetHashCode(ImmutableArray obj)
+ {
+ if (obj.IsDefault)
+ {
+ return 0;
+ }
+ var hashCode = -450793227;
+ foreach (var item in obj)
+ {
+ hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(item);
+ }
+
+ return hashCode;
+ }
+ }
+}
diff --git a/src/Validation/gen/Microsoft.Extensions.Validation.ValidationsGenerator.csproj b/src/Validation/gen/Microsoft.Extensions.Validation.ValidationsGenerator.csproj
new file mode 100644
index 000000000000..b05a37f970f6
--- /dev/null
+++ b/src/Validation/gen/Microsoft.Extensions.Validation.ValidationsGenerator.csproj
@@ -0,0 +1,34 @@
+
+
+
+ netstandard2.0
+ true
+ false
+ true
+ false
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Validation/gen/Models/RequiredSymbols.cs b/src/Validation/gen/Models/RequiredSymbols.cs
new file mode 100644
index 000000000000..c67e1e63c38d
--- /dev/null
+++ b/src/Validation/gen/Models/RequiredSymbols.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal sealed record class RequiredSymbols(
+ INamedTypeSymbol DisplayAttribute,
+ INamedTypeSymbol ValidationAttribute,
+ INamedTypeSymbol IEnumerable,
+ INamedTypeSymbol IValidatableObject,
+ INamedTypeSymbol JsonDerivedTypeAttribute,
+ INamedTypeSymbol RequiredAttribute,
+ INamedTypeSymbol CustomValidationAttribute,
+ INamedTypeSymbol HttpContext,
+ INamedTypeSymbol HttpRequest,
+ INamedTypeSymbol HttpResponse,
+ INamedTypeSymbol CancellationToken,
+ INamedTypeSymbol IFormCollection,
+ INamedTypeSymbol IFormFileCollection,
+ INamedTypeSymbol IFormFile,
+ INamedTypeSymbol Stream,
+ INamedTypeSymbol PipeReader
+);
diff --git a/src/Validation/gen/Models/ValidatableProperty.cs b/src/Validation/gen/Models/ValidatableProperty.cs
new file mode 100644
index 000000000000..8dee45af31cc
--- /dev/null
+++ b/src/Validation/gen/Models/ValidatableProperty.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal sealed record class ValidatableProperty(
+ ITypeSymbol ContainingType,
+ ITypeSymbol Type,
+ string Name,
+ string DisplayName,
+ ImmutableArray Attributes
+);
diff --git a/src/Validation/gen/Models/ValidatableType.cs b/src/Validation/gen/Models/ValidatableType.cs
new file mode 100644
index 000000000000..784663750929
--- /dev/null
+++ b/src/Validation/gen/Models/ValidatableType.cs
@@ -0,0 +1,12 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal sealed record class ValidatableType(
+ ITypeSymbol Type,
+ ImmutableArray Members
+);
diff --git a/src/Validation/gen/Models/ValidatableTypeComparer.cs b/src/Validation/gen/Models/ValidatableTypeComparer.cs
new file mode 100644
index 000000000000..2338a1fa2cca
--- /dev/null
+++ b/src/Validation/gen/Models/ValidatableTypeComparer.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal sealed class ValidatableTypeComparer : IEqualityComparer
+{
+ public static ValidatableTypeComparer Instance { get; } = new();
+
+ public bool Equals(ValidatableType? x, ValidatableType? y)
+ {
+ if (x is null && y is null)
+ {
+ return true;
+ }
+ if (x is null || y is null)
+ {
+ return false;
+ }
+ return SymbolEqualityComparer.Default.Equals(x.Type, y.Type);
+ }
+
+ public int GetHashCode(ValidatableType? obj)
+ {
+ return SymbolEqualityComparer.Default.GetHashCode(obj?.Type);
+ }
+}
diff --git a/src/Validation/gen/Models/ValidationAttribute.cs b/src/Validation/gen/Models/ValidationAttribute.cs
new file mode 100644
index 000000000000..55adcd0835f9
--- /dev/null
+++ b/src/Validation/gen/Models/ValidationAttribute.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+internal sealed record class ValidationAttribute(
+ string Name,
+ string ClassName,
+ List Arguments,
+ Dictionary NamedArguments,
+ bool IsCustomValidationAttribute
+);
diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.AddValidation.cs b/src/Validation/gen/Parsers/ValidationsGenerator.AddValidation.cs
new file mode 100644
index 000000000000..a3fb17b099c1
--- /dev/null
+++ b/src/Validation/gen/Parsers/ValidationsGenerator.AddValidation.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Threading;
+using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+public sealed partial class ValidationsGenerator : IIncrementalGenerator
+{
+ internal bool FindAddValidation(SyntaxNode syntaxNode, CancellationToken cancellationToken)
+ {
+ if (syntaxNode is InvocationExpressionSyntax
+ && syntaxNode.TryGetMapMethodName(out var method)
+ && method == "AddValidation")
+ {
+ return true;
+ }
+ return false;
+ }
+
+ internal InterceptableLocation? TransformAddValidation(GeneratorSyntaxContext context, CancellationToken cancellationToken)
+ {
+ var node = (InvocationExpressionSyntax)context.Node;
+ var semanticModel = context.SemanticModel;
+ var symbol = semanticModel.GetSymbolInfo(node, cancellationToken).Symbol;
+ if (symbol is not IMethodSymbol methodSymbol
+ || methodSymbol.ContainingType.Name != "ValidationServiceCollectionExtensions"
+ || methodSymbol.ContainingAssembly.Name != "Microsoft.AspNetCore.Http.Abstractions")
+ {
+ return null;
+ }
+ return semanticModel.GetInterceptableLocation(node, cancellationToken);
+ }
+}
diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.AttributeParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.AttributeParser.cs
new file mode 100644
index 000000000000..959c9bbfb948
--- /dev/null
+++ b/src/Validation/gen/Parsers/ValidationsGenerator.AttributeParser.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Threading;
+using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+public sealed partial class ValidationsGenerator : IIncrementalGenerator
+{
+ internal static bool ShouldTransformSymbolWithAttribute(SyntaxNode syntaxNode, CancellationToken cancellationToken)
+ {
+ return syntaxNode is ClassDeclarationSyntax;
+ }
+
+ internal ImmutableArray TransformValidatableTypeWithAttribute(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken)
+ {
+ var validatableTypes = new HashSet(ValidatableTypeComparer.Instance);
+ List visitedTypes = [];
+ var wellKnownTypes = WellKnownTypes.GetOrCreate(context.SemanticModel.Compilation);
+ if (TryExtractValidatableType((ITypeSymbol)context.TargetSymbol, wellKnownTypes, ref validatableTypes, ref visitedTypes))
+ {
+ return [..validatableTypes];
+ }
+ return [];
+ }
+}
diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.EndpointsParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.EndpointsParser.cs
new file mode 100644
index 000000000000..f6c9b566b725
--- /dev/null
+++ b/src/Validation/gen/Parsers/ValidationsGenerator.EndpointsParser.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using Microsoft.AspNetCore.Analyzers.Infrastructure;
+using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
+using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+public sealed partial class ValidationsGenerator : IIncrementalGenerator
+{
+ internal bool FindEndpoints(SyntaxNode syntaxNode, CancellationToken cancellationToken)
+ {
+ if (syntaxNode is InvocationExpressionSyntax
+ && syntaxNode.TryGetMapMethodName(out var method))
+ {
+ return method == "MapMethods" || InvocationOperationExtensions.KnownMethods.Contains(method);
+ }
+ return false;
+ }
+
+ internal IInvocationOperation? TransformEndpoints(GeneratorSyntaxContext context, CancellationToken cancellationToken)
+ {
+ if (context.Node is not InvocationExpressionSyntax node)
+ {
+ return null;
+ }
+ var operation = context.SemanticModel.GetOperation(node, cancellationToken);
+ AnalyzerDebug.Assert(operation != null, "Operation should not be null.");
+ return operation is IInvocationOperation invocationOperation
+ ? invocationOperation
+ : null;
+ }
+
+ internal ImmutableArray ExtractValidatableEndpoint(IInvocationOperation? operation, CancellationToken cancellationToken)
+ {
+ AnalyzerDebug.Assert(operation != null, "Operation should not be null.");
+ AnalyzerDebug.Assert(operation.SemanticModel != null, "Operation should have a semantic model.");
+ var wellKnownTypes = WellKnownTypes.GetOrCreate(operation.SemanticModel.Compilation);
+ var validatableTypes = ExtractValidatableTypes(operation, wellKnownTypes);
+ return validatableTypes;
+ }
+}
diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
new file mode 100644
index 000000000000..30fbf38368fc
--- /dev/null
+++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
@@ -0,0 +1,212 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.AspNetCore.Analyzers.Infrastructure;
+using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
+using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+public sealed partial class ValidationsGenerator : IIncrementalGenerator
+{
+ private static readonly SymbolDisplayFormat _symbolDisplayFormat = new(
+ globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included,
+ typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces);
+
+ internal ImmutableArray ExtractValidatableTypes(IInvocationOperation operation, WellKnownTypes wellKnownTypes)
+ {
+ AnalyzerDebug.Assert(operation.SemanticModel != null, "SemanticModel should not be null.");
+ var parameters = operation.TryGetRouteHandlerMethod(operation.SemanticModel, out var method)
+ ? method.Parameters
+ : [];
+
+ var fromServiceMetadataSymbol = wellKnownTypes.Get(
+ WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromServiceMetadata);
+ var fromKeyedServiceAttributeSymbol = wellKnownTypes.Get(
+ WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_FromKeyedServicesAttribute);
+
+ var validatableTypes = new HashSet(ValidatableTypeComparer.Instance);
+ List visitedTypes = [];
+
+ foreach (var parameter in parameters)
+ {
+ // Skip parameters that are injected as services
+ if (parameter.IsServiceParameter(fromServiceMetadataSymbol, fromKeyedServiceAttributeSymbol))
+ {
+ continue;
+ }
+
+ _ = TryExtractValidatableType(parameter.Type, wellKnownTypes, ref validatableTypes, ref visitedTypes);
+ }
+ return [.. validatableTypes];
+ }
+
+ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnownTypes wellKnownTypes, ref HashSet validatableTypes, ref List visitedTypes)
+ {
+ var typeSymbol = incomingTypeSymbol.UnwrapType(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_Collections_IEnumerable));
+ if (typeSymbol.SpecialType != SpecialType.None)
+ {
+ return false;
+ }
+
+ if (visitedTypes.Contains(typeSymbol))
+ {
+ return true;
+ }
+
+ if (typeSymbol.IsExemptType(wellKnownTypes))
+ {
+ return false;
+ }
+
+ visitedTypes.Add(typeSymbol);
+
+ // Extract validatable types discovered in base types of this type and add them to the top-level list.
+ var current = typeSymbol.BaseType;
+ var hasValidatableBaseType = false;
+ while (current != null && current.SpecialType != SpecialType.System_Object)
+ {
+ hasValidatableBaseType |= TryExtractValidatableType(current, wellKnownTypes, ref validatableTypes, ref visitedTypes);
+ current = current.BaseType;
+ }
+
+ // Extract validatable types discovered in members of this type and add them to the top-level list.
+ ImmutableArray members = [];
+ if (ParsabilityHelper.GetParsability(typeSymbol, wellKnownTypes) is Parsability.NotParsable)
+ {
+ members = ExtractValidatableMembers(typeSymbol, wellKnownTypes, ref validatableTypes, ref visitedTypes);
+ }
+
+ // Extract the validatable types discovered in the JsonDerivedTypeAttributes of this type and add them to the top-level list.
+ var derivedTypes = typeSymbol.GetJsonDerivedTypes(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_Text_Json_Serialization_JsonDerivedTypeAttribute));
+ var hasValidatableDerivedTypes = false;
+ foreach (var derivedType in derivedTypes ?? [])
+ {
+ hasValidatableDerivedTypes |= TryExtractValidatableType(derivedType, wellKnownTypes, ref validatableTypes, ref visitedTypes);
+ }
+
+ // No validatable members or derived types found, so we don't need to add this type.
+ if (members.IsDefaultOrEmpty && !hasValidatableBaseType && !hasValidatableDerivedTypes)
+ {
+ return false;
+ }
+
+ // Add the type itself as a validatable type itself.
+ validatableTypes.Add(new ValidatableType(
+ Type: typeSymbol,
+ Members: members));
+
+ return true;
+ }
+
+ internal ImmutableArray ExtractValidatableMembers(ITypeSymbol typeSymbol, WellKnownTypes wellKnownTypes, ref HashSet validatableTypes, ref List visitedTypes)
+ {
+ var members = new List();
+ var resolvedRecordProperty = new List();
+
+ // 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()
+ .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,
+ wellKnownTypes,
+ ref validatableTypes,
+ ref visitedTypes);
+
+ members.Add(new ValidatableProperty(
+ ContainingType: correspondingProperty.ContainingType,
+ Type: correspondingProperty.Type,
+ Name: correspondingProperty.Name,
+ DisplayName: parameter.GetDisplayName(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_DisplayAttribute)) ??
+ correspondingProperty.GetDisplayName(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_DisplayAttribute)),
+ Attributes: []));
+ }
+ }
+ }
+ }
+
+ // Handle properties for classes and any properties not handled by the constructor
+ foreach (var member in typeSymbol.GetMembers().OfType())
+ {
+ // 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, wellKnownTypes, ref validatableTypes, ref visitedTypes);
+ var attributes = ExtractValidationAttributes(member, wellKnownTypes, 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,
+ Name: member.Name,
+ DisplayName: member.GetDisplayName(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_DisplayAttribute)),
+ Attributes: attributes));
+ }
+
+ return [.. members];
+ }
+
+ internal static ImmutableArray ExtractValidationAttributes(ISymbol symbol, WellKnownTypes wellKnownTypes, out bool isRequired)
+ {
+ var attributes = symbol.GetAttributes();
+ if (attributes.Length == 0)
+ {
+ isRequired = false;
+ return [];
+ }
+
+ var validationAttributes = attributes
+ .Where(attribute => attribute.AttributeClass != null)
+ .Where(attribute => attribute.AttributeClass!.ImplementsValidationAttribute(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_ValidationAttribute)));
+ isRequired = validationAttributes.Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_RequiredAttribute)));
+ return [.. validationAttributes
+ .Where(attr => !SymbolEqualityComparer.Default.Equals(attr.AttributeClass, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_ValidationAttribute)))
+ .Select(attribute => new ValidationAttribute(
+ Name: symbol.Name + attribute.AttributeClass!.Name,
+ ClassName: attribute.AttributeClass!.ToDisplayString(_symbolDisplayFormat),
+ Arguments: [.. attribute.ConstructorArguments.Select(a => a.ToCSharpString())],
+ NamedArguments: attribute.NamedArguments.ToDictionary(namedArgument => namedArgument.Key, namedArgument => namedArgument.Value.ToCSharpString()),
+ IsCustomValidationAttribute: SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_CustomValidationAttribute))))];
+ }
+}
diff --git a/src/Validation/gen/ValidationsGenerator.cs b/src/Validation/gen/ValidationsGenerator.cs
new file mode 100644
index 000000000000..f67e964ab76b
--- /dev/null
+++ b/src/Validation/gen/ValidationsGenerator.cs
@@ -0,0 +1,47 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator;
+
+[Generator]
+public sealed partial class ValidationsGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Find the builder.Services.AddValidation() call in the application.
+ var addValidation = context.SyntaxProvider.CreateSyntaxProvider(
+ predicate: FindAddValidation,
+ transform: TransformAddValidation
+ );
+ // Extract types that have been marked with [ValidatableType].
+ var validatableTypesWithAttribute = context.SyntaxProvider.ForAttributeWithMetadataName(
+ "Microsoft.Extensions.Validation.ValidatableTypeAttribute",
+ predicate: ShouldTransformSymbolWithAttribute,
+ transform: TransformValidatableTypeWithAttribute
+ );
+ // Extract all minimal API endpoints in the application.
+ var endpoints = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ predicate: FindEndpoints,
+ transform: TransformEndpoints)
+ .Where(endpoint => endpoint is not null);
+ // Extract validatable types from all endpoints.
+ var validatableTypesFromEndpoints = endpoints
+ .Select(ExtractValidatableEndpoint);
+ // Join all validatable types encountered in the type graph.
+ var validatableTypes = validatableTypesWithAttribute
+ .Concat(validatableTypesFromEndpoints)
+ .Distinct(ValidatableTypeComparer.Instance)
+ .Collect();
+
+ var emitInputs = addValidation
+ .Combine(validatableTypes);
+
+ // Emit the IValidatableInfo resolver injection and
+ // ValidatableTypeInfo for all validatable types.
+ context.RegisterSourceOutput(emitInputs, Emit);
+ }
+}
diff --git a/src/Validation/src/IValidatableInfo.cs b/src/Validation/src/IValidatableInfo.cs
new file mode 100644
index 000000000000..4b5ab3cd24b2
--- /dev/null
+++ b/src/Validation/src/IValidatableInfo.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Represents an interface for validating a value.
+///
+[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public interface IValidatableInfo
+{
+ ///
+ /// Validates the specified .
+ ///
+ /// The value to validate.
+ /// The validation context.
+ /// A cancellation token to support cancellation of the validation.
+ /// A representing the asynchronous operation.
+ Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
+}
diff --git a/src/Validation/src/IValidatableInfoResolver.cs b/src/Validation/src/IValidatableInfoResolver.cs
new file mode 100644
index 000000000000..706204ab6284
--- /dev/null
+++ b/src/Validation/src/IValidatableInfoResolver.cs
@@ -0,0 +1,33 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Provides an interface for resolving the validation information associated
+/// with a given or .
+///
+[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public interface IValidatableInfoResolver
+{
+ ///
+ /// Gets validation information for the specified type.
+ ///
+ /// The type to get validation information for.
+ ///
+ /// The output parameter that will contain the validatable information if found.
+ ///
+ /// if the validatable type information was found; otherwise, false.
+ bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableInfo);
+
+ ///
+ /// Gets validation information for the specified parameter.
+ ///
+ /// The parameter to get validation information for.
+ /// The output parameter that will contain the validatable information if found.
+ /// if the validatable parameter information was found; otherwise, false.
+ bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo);
+}
diff --git a/src/Validation/src/Microsoft.Extensions.Validation.csproj b/src/Validation/src/Microsoft.Extensions.Validation.csproj
new file mode 100644
index 000000000000..495114e86263
--- /dev/null
+++ b/src/Validation/src/Microsoft.Extensions.Validation.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Common validation abstractions and validation infrastructure for .NET applications.
+ $(DefaultNetCoreTargetFramework)
+ true
+ true
+ validation
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Validation/src/PublicAPI.Shipped.txt b/src/Validation/src/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..815c92006af7
--- /dev/null
+++ b/src/Validation/src/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
\ No newline at end of file
diff --git a/src/Validation/src/PublicAPI.Unshipped.txt b/src/Validation/src/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..ce3b07db31b0
--- /dev/null
+++ b/src/Validation/src/PublicAPI.Unshipped.txt
@@ -0,0 +1,40 @@
+#nullable enable
+Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions
+Microsoft.Extensions.Validation.IValidatableInfo
+Microsoft.Extensions.Validation.IValidatableInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Validation.IValidatableInfoResolver
+Microsoft.Extensions.Validation.IValidatableInfoResolver.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) -> bool
+Microsoft.Extensions.Validation.IValidatableInfoResolver.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) -> bool
+Microsoft.Extensions.Validation.ValidatableParameterInfo
+Microsoft.Extensions.Validation.ValidatableParameterInfo.ValidatableParameterInfo(System.Type! parameterType, string! name, string! displayName) -> void
+Microsoft.Extensions.Validation.ValidatablePropertyInfo
+Microsoft.Extensions.Validation.ValidatablePropertyInfo.ValidatablePropertyInfo(System.Type! declaringType, System.Type! propertyType, string! name, string! displayName) -> void
+Microsoft.Extensions.Validation.ValidatableTypeAttribute
+Microsoft.Extensions.Validation.ValidatableTypeAttribute.ValidatableTypeAttribute() -> void
+Microsoft.Extensions.Validation.ValidatableTypeInfo
+Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidatableTypeInfo(System.Type! type, System.Collections.Generic.IReadOnlyList! members) -> void
+Microsoft.Extensions.Validation.ValidateContext
+Microsoft.Extensions.Validation.ValidateContext.CurrentDepth.get -> int
+Microsoft.Extensions.Validation.ValidateContext.CurrentDepth.set -> void
+Microsoft.Extensions.Validation.ValidateContext.CurrentValidationPath.get -> string!
+Microsoft.Extensions.Validation.ValidateContext.CurrentValidationPath.set -> void
+Microsoft.Extensions.Validation.ValidateContext.ValidateContext() -> void
+Microsoft.Extensions.Validation.ValidateContext.ValidationContext.get -> System.ComponentModel.DataAnnotations.ValidationContext!
+Microsoft.Extensions.Validation.ValidateContext.ValidationContext.set -> void
+Microsoft.Extensions.Validation.ValidateContext.ValidationErrors.get -> System.Collections.Generic.Dictionary?
+Microsoft.Extensions.Validation.ValidateContext.ValidationErrors.set -> void
+Microsoft.Extensions.Validation.ValidateContext.ValidationOptions.get -> Microsoft.Extensions.Validation.ValidationOptions!
+Microsoft.Extensions.Validation.ValidateContext.ValidationOptions.set -> void
+Microsoft.Extensions.Validation.ValidationOptions
+Microsoft.Extensions.Validation.ValidationOptions.MaxDepth.get -> int
+Microsoft.Extensions.Validation.ValidationOptions.MaxDepth.set -> void
+Microsoft.Extensions.Validation.ValidationOptions.Resolvers.get -> System.Collections.Generic.IList!
+Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) -> bool
+Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.Extensions.Validation.IValidatableInfo? validatableTypeInfo) -> bool
+Microsoft.Extensions.Validation.ValidationOptions.ValidationOptions() -> void
+abstract Microsoft.Extensions.Validation.ValidatableParameterInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
+abstract Microsoft.Extensions.Validation.ValidatablePropertyInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
+static Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configureOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+virtual Microsoft.Extensions.Validation.ValidatableParameterInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
+virtual Microsoft.Extensions.Validation.ValidatablePropertyInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
+virtual Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
\ No newline at end of file
diff --git a/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs
new file mode 100644
index 000000000000..e6aa1e01836d
--- /dev/null
+++ b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs
@@ -0,0 +1,111 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.IO.Pipelines;
+using System.Linq;
+using System.Reflection;
+using System.Security.Claims;
+
+namespace Microsoft.Extensions.Validation;
+
+internal sealed class RuntimeValidatableParameterInfoResolver : IValidatableInfoResolver
+{
+ // TODO: the implementation currently relies on static discovery of types.
+ public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ validatableInfo = null;
+ return false;
+ }
+
+ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ if (parameterInfo.Name == null)
+ {
+ throw new InvalidOperationException($"Encountered a parameter of type '{parameterInfo.ParameterType}' without a name. Parameters must have a name.");
+ }
+
+ var validationAttributes = parameterInfo
+ .GetCustomAttributes()
+ .ToArray();
+
+ // 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))
+ {
+ validatableInfo = null;
+ return false;
+ }
+ validatableInfo = new RuntimeValidatableParameterInfo(
+ parameterType: parameterInfo.ParameterType,
+ name: parameterInfo.Name,
+ displayName: GetDisplayName(parameterInfo),
+ validationAttributes: validationAttributes
+ );
+ return true;
+ }
+
+ private static string GetDisplayName(ParameterInfo parameterInfo)
+ {
+ var displayAttribute = parameterInfo.GetCustomAttribute();
+ if (displayAttribute != null)
+ {
+ return displayAttribute.Name ?? parameterInfo.Name!;
+ }
+
+ return parameterInfo.Name!;
+ }
+
+ internal sealed class RuntimeValidatableParameterInfo(
+ Type parameterType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes) :
+ ValidatableParameterInfo(parameterType, name, displayName)
+ {
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+
+ private readonly ValidationAttribute[] _validationAttributes = validationAttributes;
+ }
+
+ private static bool IsClass(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
+ if (type.IsPrimitive ||
+ type.IsEnum ||
+ type == typeof(string) ||
+ type == typeof(decimal) ||
+ type == typeof(DateTime) ||
+ type == typeof(DateTimeOffset) ||
+ type == typeof(TimeOnly) ||
+ type == typeof(DateOnly) ||
+ type == typeof(TimeSpan) ||
+ type == typeof(Guid) ||
+ type == typeof(IFormFile) ||
+ type == typeof(IFormFileCollection) ||
+ type == typeof(IFormCollection) ||
+ type == typeof(HttpContext) ||
+ type == typeof(HttpRequest) ||
+ type == typeof(HttpResponse) ||
+ type == typeof(ClaimsPrincipal) ||
+ type == typeof(CancellationToken) ||
+ type == typeof(Stream) ||
+ type == typeof(PipeReader))
+ {
+ return false;
+ }
+
+ // Check if the underlying type in a nullable is valid
+ if (Nullable.GetUnderlyingType(type) is { } nullableType)
+ {
+ return IsClass(nullableType);
+ }
+
+ return type.IsClass;
+ }
+}
diff --git a/src/Validation/src/TypeExtensions.cs b/src/Validation/src/TypeExtensions.cs
new file mode 100644
index 000000000000..0b11e6ec45e5
--- /dev/null
+++ b/src/Validation/src/TypeExtensions.cs
@@ -0,0 +1,134 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation;
+
+internal static class TypeExtensions
+{
+ ///
+ /// Determines whether the specified type is an enumerable type.
+ ///
+ /// The type to check.
+ /// if the type is enumerable; otherwise, .
+ public static bool IsEnumerable(this Type type)
+ {
+ // Check if type itself is an IEnumerable
+ if (type.IsGenericType &&
+ (type.GetGenericTypeDefinition() == typeof(IEnumerable<>) ||
+ type.GetGenericTypeDefinition() == typeof(ICollection<>) ||
+ type.GetGenericTypeDefinition() == typeof(List<>) ||
+ type.GetGenericTypeDefinition() == typeof(IList<>)))
+ {
+ return true;
+ }
+
+ // Or an array
+ if (type.IsArray)
+ {
+ return true;
+ }
+
+ // Then evaluate if it implements IEnumerable and is not a string
+ if (typeof(IEnumerable).IsAssignableFrom(type) &&
+ type != typeof(string))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Determines whether the specified type is a nullable type.
+ ///
+ /// The type to check.
+ /// if the type is nullable; otherwise, .
+ public static bool IsNullable(this Type type)
+ {
+ if (type.IsValueType)
+ {
+ return false;
+ }
+
+ if (type.IsGenericType &&
+ type.GetGenericTypeDefinition() == typeof(Nullable<>))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Tries to get the from the specified array of validation attributes.
+ ///
+ /// The array of to search.
+ /// The found if available, otherwise null.
+ /// if a is found; otherwise, .
+ public static bool TryGetRequiredAttribute(this ValidationAttribute[] attributes, [NotNullWhen(true)] out RequiredAttribute? requiredAttribute)
+ {
+ foreach (var attribute in attributes)
+ {
+ if (attribute is RequiredAttribute requiredAttr)
+ {
+ requiredAttribute = requiredAttr;
+ return true;
+ }
+ }
+
+ requiredAttribute = null;
+ return false;
+ }
+
+ ///
+ /// Gets all types that the specified type implements or inherits from.
+ ///
+ /// The type to analyze.
+ /// A collection containing all implemented interfaces and all base types of the given type.
+ public static List GetAllImplementedTypes([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type)
+ {
+ ArgumentNullException.ThrowIfNull(type);
+
+ var implementedTypes = new List();
+
+ // Yield all interfaces directly and indirectly implemented by this type
+ foreach (var interfaceType in type.GetInterfaces())
+ {
+ implementedTypes.Add(interfaceType);
+ }
+
+ // Finally, walk up the inheritance chain
+ var baseType = type.BaseType;
+ while (baseType != null && baseType != typeof(object))
+ {
+ implementedTypes.Add(baseType);
+ baseType = baseType.BaseType;
+ }
+
+ return implementedTypes;
+ }
+
+ ///
+ /// Determines whether the specified type implements the given interface.
+ ///
+ /// The type to check.
+ /// The interface type to check for.
+ /// True if the type implements the specified interface; otherwise, false.
+ public static bool ImplementsInterface(this Type type, Type interfaceType)
+ {
+ ArgumentNullException.ThrowIfNull(type);
+ ArgumentNullException.ThrowIfNull(interfaceType);
+
+ // Check if interfaceType is actually an interface
+ if (!interfaceType.IsInterface)
+ {
+ throw new ArgumentException($"Type {interfaceType.FullName} is not an interface.", nameof(interfaceType));
+ }
+
+ return interfaceType.IsAssignableFrom(type);
+ }
+}
diff --git a/src/Validation/src/ValidatableParameterInfo.cs b/src/Validation/src/ValidatableParameterInfo.cs
new file mode 100644
index 000000000000..8481887ff8ce
--- /dev/null
+++ b/src/Validation/src/ValidatableParameterInfo.cs
@@ -0,0 +1,139 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Contains validation information for a parameter.
+///
+[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public abstract class ValidatableParameterInfo : IValidatableInfo
+{
+ private RequiredAttribute? _requiredAttribute;
+
+ ///
+ /// Creates a new instance of .
+ ///
+ /// The associated with the parameter.
+ /// The parameter name.
+ /// The display name for the parameter.
+ protected ValidatableParameterInfo(
+ Type parameterType,
+ string name,
+ string displayName)
+ {
+ ParameterType = parameterType;
+ Name = name;
+ DisplayName = displayName;
+ }
+
+ ///
+ /// Gets the parameter type.
+ ///
+ internal Type ParameterType { get; }
+
+ ///
+ /// Gets the parameter name.
+ ///
+ internal string Name { get; }
+
+ ///
+ /// Gets the display name for the parameter.
+ ///
+ internal string DisplayName { get; }
+
+ ///
+ /// Gets the validation attributes for this parameter.
+ ///
+ /// An array of validation attributes to apply to this parameter.
+ protected abstract ValidationAttribute[] GetValidationAttributes();
+
+ ///
+ ///
+ /// If the parameter is a collection, each item in the collection will be validated.
+ /// If the parameter is not a collection but has a validatable type, the single value will be validated.
+ ///
+ public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
+ {
+ // Skip validation if value is null and parameter is optional
+ if (value == null && ParameterType.IsNullable())
+ {
+ return;
+ }
+
+ context.ValidationContext.DisplayName = DisplayName;
+ context.ValidationContext.MemberName = Name;
+
+ var validationAttributes = GetValidationAttributes();
+
+ if (_requiredAttribute is not null || validationAttributes.TryGetRequiredAttribute(out _requiredAttribute))
+ {
+ var result = _requiredAttribute.GetValidationResult(value, context.ValidationContext);
+
+ if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null)
+ {
+ var key = string.IsNullOrEmpty(context.CurrentValidationPath) ? Name : $"{context.CurrentValidationPath}.{Name}";
+ context.AddValidationError(key, [result.ErrorMessage]);
+ return;
+ }
+ }
+
+ // Validate against validation attributes
+ for (var i = 0; i < validationAttributes.Length; i++)
+ {
+ var attribute = validationAttributes[i];
+ try
+ {
+ var result = attribute.GetValidationResult(value, context.ValidationContext);
+ if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null)
+ {
+ var key = string.IsNullOrEmpty(context.CurrentValidationPath) ? Name : $"{context.CurrentValidationPath}.{Name}";
+ context.AddOrExtendValidationErrors(key, [result.ErrorMessage]);
+ }
+ }
+ catch (Exception ex)
+ {
+ var key = string.IsNullOrEmpty(context.CurrentValidationPath) ? Name : $"{context.CurrentValidationPath}.{Name}";
+ context.AddValidationError(key, [ex.Message]);
+ }
+ }
+
+ // If the parameter is a collection, validate each item
+ if (ParameterType.IsEnumerable() && value is IEnumerable enumerable)
+ {
+ var index = 0;
+ var currentPrefix = context.CurrentValidationPath;
+
+ foreach (var item in enumerable)
+ {
+ if (item != null)
+ {
+ context.CurrentValidationPath = string.IsNullOrEmpty(currentPrefix)
+ ? $"{Name}[{index}]"
+ : $"{currentPrefix}.{Name}[{index}]";
+
+ if (context.ValidationOptions.TryGetValidatableTypeInfo(item.GetType(), out var validatableType))
+ {
+ await validatableType.ValidateAsync(item, context, cancellationToken);
+ }
+ }
+ index++;
+ }
+
+ context.CurrentValidationPath = currentPrefix;
+ }
+ // If not enumerable, validate the single value
+ else if (value != null)
+ {
+ var valueType = value.GetType();
+ if (context.ValidationOptions.TryGetValidatableTypeInfo(valueType, out var validatableType))
+ {
+ await validatableType.ValidateAsync(value, context, cancellationToken);
+ }
+ }
+ }
+}
diff --git a/src/Validation/src/ValidatablePropertyInfo.cs b/src/Validation/src/ValidatablePropertyInfo.cs
new file mode 100644
index 000000000000..b73a8968fd62
--- /dev/null
+++ b/src/Validation/src/ValidatablePropertyInfo.cs
@@ -0,0 +1,173 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Contains validation information for a member of a type.
+///
+[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public abstract class ValidatablePropertyInfo : IValidatableInfo
+{
+ private RequiredAttribute? _requiredAttribute;
+
+ ///
+ /// Creates a new instance of .
+ ///
+ protected ValidatablePropertyInfo(
+ [param: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
+ Type declaringType,
+ Type propertyType,
+ string name,
+ string displayName)
+ {
+ DeclaringType = declaringType;
+ PropertyType = propertyType;
+ Name = name;
+ DisplayName = displayName;
+ }
+
+ ///
+ /// Gets the member type.
+ ///
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
+ internal Type DeclaringType { get; }
+
+ ///
+ /// Gets the member type.
+ ///
+ internal Type PropertyType { get; }
+
+ ///
+ /// Gets the member name.
+ ///
+ internal string Name { get; }
+
+ ///
+ /// Gets the display name for the member as designated by the .
+ ///
+ internal string DisplayName { get; }
+
+ ///
+ /// Gets the validation attributes for this member.
+ ///
+ /// An array of validation attributes to apply to this member.
+ protected abstract ValidationAttribute[] GetValidationAttributes();
+
+ ///
+ public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
+ {
+ var property = DeclaringType.GetProperty(Name) ?? throw new InvalidOperationException($"Property '{Name}' not found on type '{DeclaringType.Name}'.");
+ var propertyValue = property.GetValue(value);
+ var validationAttributes = GetValidationAttributes();
+
+ // Calculate and save the current path
+ var originalPrefix = context.CurrentValidationPath;
+ if (string.IsNullOrEmpty(originalPrefix))
+ {
+ context.CurrentValidationPath = Name;
+ }
+ else
+ {
+ context.CurrentValidationPath = $"{originalPrefix}.{Name}";
+ }
+
+ context.ValidationContext.DisplayName = DisplayName;
+ context.ValidationContext.MemberName = Name;
+
+ // Check required attribute first
+ if (_requiredAttribute is not null || validationAttributes.TryGetRequiredAttribute(out _requiredAttribute))
+ {
+ var result = _requiredAttribute.GetValidationResult(propertyValue, context.ValidationContext);
+
+ if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null)
+ {
+ context.AddValidationError(context.CurrentValidationPath, [result.ErrorMessage]);
+ context.CurrentValidationPath = originalPrefix; // Restore prefix
+ return;
+ }
+ }
+
+ // Validate any other attributes
+ ValidateValue(propertyValue, context.CurrentValidationPath, validationAttributes);
+
+ // Check if we've reached the maximum depth before validating complex properties
+ if (context.CurrentDepth >= context.ValidationOptions.MaxDepth)
+ {
+ throw new InvalidOperationException(
+ $"Maximum validation depth of {context.ValidationOptions.MaxDepth} exceeded at '{context.CurrentValidationPath}' in '{DeclaringType.Name}.{Name}'. " +
+ "This is likely caused by a circular reference in the object graph. " +
+ "Consider increasing the MaxDepth in ValidationOptions if deeper validation is required.");
+ }
+
+ // Increment depth counter
+ context.CurrentDepth++;
+
+ try
+ {
+ // Handle enumerable values
+ if (PropertyType.IsEnumerable() && propertyValue is System.Collections.IEnumerable enumerable)
+ {
+ var index = 0;
+ var currentPrefix = context.CurrentValidationPath;
+
+ foreach (var item in enumerable)
+ {
+ context.CurrentValidationPath = $"{currentPrefix}[{index}]";
+
+ if (item != null)
+ {
+ var itemType = item.GetType();
+ if (context.ValidationOptions.TryGetValidatableTypeInfo(itemType, out var validatableType))
+ {
+ await validatableType.ValidateAsync(item, context, cancellationToken);
+ }
+ }
+
+ index++;
+ }
+
+ // Restore prefix to the property name before validating the next item
+ context.CurrentValidationPath = currentPrefix;
+ }
+ else if (propertyValue != null)
+ {
+ // Validate as a complex object
+ var valueType = propertyValue.GetType();
+ if (context.ValidationOptions.TryGetValidatableTypeInfo(valueType, out var validatableType))
+ {
+ await validatableType.ValidateAsync(propertyValue, context, cancellationToken);
+ }
+ }
+ }
+ finally
+ {
+ // Always decrement the depth counter and restore prefix
+ context.CurrentDepth--;
+ context.CurrentValidationPath = originalPrefix;
+ }
+
+ void ValidateValue(object? val, string errorPrefix, ValidationAttribute[] validationAttributes)
+ {
+ for (var i = 0; i < validationAttributes.Length; i++)
+ {
+ var attribute = validationAttributes[i];
+ try
+ {
+ var result = attribute.GetValidationResult(val, context.ValidationContext);
+ if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null)
+ {
+ context.AddOrExtendValidationErrors(errorPrefix.TrimStart('.'), [result.ErrorMessage]);
+ }
+ }
+ catch (Exception ex)
+ {
+ context.AddOrExtendValidationErrors(errorPrefix.TrimStart('.'), [ex.Message]);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Validation/src/ValidatableTypeAttribute.cs b/src/Validation/src/ValidatableTypeAttribute.cs
new file mode 100644
index 000000000000..c2af4cb2a7da
--- /dev/null
+++ b/src/Validation/src/ValidatableTypeAttribute.cs
@@ -0,0 +1,16 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Indicates that a type is validatable to support discovery by the
+/// validations generator.
+///
+[AttributeUsage(AttributeTargets.Class)]
+[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class ValidatableTypeAttribute : Attribute
+{
+}
diff --git a/src/Validation/src/ValidatableTypeInfo.cs b/src/Validation/src/ValidatableTypeInfo.cs
new file mode 100644
index 000000000000..936a30d52d3b
--- /dev/null
+++ b/src/Validation/src/ValidatableTypeInfo.cs
@@ -0,0 +1,133 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Contains validation information for a type.
+///
+[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public abstract class ValidatableTypeInfo : IValidatableInfo
+{
+ private readonly int _membersCount;
+ private readonly List _subTypes;
+
+ ///
+ /// Creates a new instance of .
+ ///
+ /// The type being validated.
+ /// The members that can be validated.
+ protected ValidatableTypeInfo(
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type,
+ IReadOnlyList members)
+ {
+ Type = type;
+ Members = members;
+ _membersCount = members.Count;
+ _subTypes = type.GetAllImplementedTypes();
+ }
+
+ ///
+ /// The type being validated.
+ ///
+ internal Type Type { get; }
+
+ ///
+ /// The members that can be validated.
+ ///
+ internal IReadOnlyList Members { get; }
+
+ ///
+ public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
+ {
+ if (value == null)
+ {
+ return;
+ }
+
+ // Check if we've exceeded the maximum depth
+ if (context.CurrentDepth >= context.ValidationOptions.MaxDepth)
+ {
+ throw new InvalidOperationException(
+ $"Maximum validation depth of {context.ValidationOptions.MaxDepth} exceeded at '{context.CurrentValidationPath}' in '{Type.Name}'. " +
+ "This is likely caused by a circular reference in the object graph. " +
+ "Consider increasing the MaxDepth in ValidationOptions if deeper validation is required.");
+ }
+
+ var originalPrefix = context.CurrentValidationPath;
+
+ try
+ {
+ var actualType = value.GetType();
+
+ // First validate members
+ for (var i = 0; i < _membersCount; i++)
+ {
+ await Members[i].ValidateAsync(value, context, cancellationToken);
+ context.CurrentValidationPath = originalPrefix;
+ }
+
+ // Then validate sub-types if any
+ foreach (var subType in _subTypes)
+ {
+ // Check if the actual type is assignable to the sub-type
+ // and validate it if it is
+ if (subType.IsAssignableFrom(actualType))
+ {
+ if (context.ValidationOptions.TryGetValidatableTypeInfo(subType, out var subTypeInfo))
+ {
+ await subTypeInfo.ValidateAsync(value, context, cancellationToken);
+ context.CurrentValidationPath = originalPrefix;
+ }
+ }
+ }
+
+ // Finally validate IValidatableObject if implemented
+ if (Type.ImplementsInterface(typeof(IValidatableObject)) && value is IValidatableObject validatable)
+ {
+ // Important: Set the DisplayName to the type name for top-level validations
+ // and restore the original validation context properties
+ var originalDisplayName = context.ValidationContext.DisplayName;
+ var originalMemberName = context.ValidationContext.MemberName;
+
+ // Set the display name to the class name for IValidatableObject validation
+ context.ValidationContext.DisplayName = Type.Name;
+ context.ValidationContext.MemberName = null;
+
+ var validationResults = validatable.Validate(context.ValidationContext);
+ foreach (var validationResult in validationResults)
+ {
+ if (validationResult != ValidationResult.Success && validationResult.ErrorMessage is not null)
+ {
+ // Create a validation error for each member name that is provided
+ foreach (var memberName in validationResult.MemberNames)
+ {
+ var key = string.IsNullOrEmpty(originalPrefix) ?
+ memberName :
+ $"{originalPrefix}.{memberName}";
+ context.AddOrExtendValidationError(key, validationResult.ErrorMessage);
+ }
+
+ if (!validationResult.MemberNames.Any())
+ {
+ // If no member names are specified, then treat this as a top-level error
+ context.AddOrExtendValidationError(string.Empty, validationResult.ErrorMessage);
+ }
+ }
+ }
+
+ // Restore the original validation context properties
+ context.ValidationContext.DisplayName = originalDisplayName;
+ context.ValidationContext.MemberName = originalMemberName;
+ }
+ }
+ finally
+ {
+ context.CurrentValidationPath = originalPrefix;
+ }
+ }
+}
diff --git a/src/Validation/src/ValidateContext.cs b/src/Validation/src/ValidateContext.cs
new file mode 100644
index 000000000000..b27408ce4844
--- /dev/null
+++ b/src/Validation/src/ValidateContext.cs
@@ -0,0 +1,100 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Represents the context for validating a validatable object.
+///
+[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class ValidateContext
+{
+ ///
+ /// Gets or sets the validation context used for validating objects that implement or have .
+ /// This context provides access to service provider and other validation metadata.
+ ///
+ ///
+ /// This property should be set by the consumer of the
+ /// interface to provide the necessary context for validation. The object should be initialized
+ /// with the current object being validated, the display name, and the service provider to support
+ /// the complete set of validation scenarios.
+ ///
+ ///
+ ///
+ /// var validationContext = new ValidationContext(objectToValidate, serviceProvider, items);
+ /// var validationOptions = serviceProvider.GetService<IOptions<ValidationOptions>>()?.Value;
+ /// var validateContext = new ValidateContext
+ /// {
+ /// ValidationContext = validationContext,
+ /// ValidationOptions = validationOptions
+ /// };
+ ///
+ ///
+ public required ValidationContext ValidationContext { get; set; }
+
+ ///
+ /// Gets or sets the prefix used to identify the current object being validated in a complex object graph.
+ /// This is used to build property paths in validation error messages (e.g., "Customer.Address.Street").
+ ///
+ public string CurrentValidationPath { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the validation options that control validation behavior,
+ /// including validation depth limits and resolver registration.
+ ///
+ public required ValidationOptions ValidationOptions { get; set; }
+
+ ///
+ /// Gets or sets the dictionary of validation errors collected during validation.
+ /// Keys are property names or paths, and values are arrays of error messages.
+ /// In the default implementation, this dictionary is initialized when the first error is added.
+ ///
+ public Dictionary? ValidationErrors { get; set; }
+
+ ///
+ /// Gets or sets the current depth in the validation hierarchy.
+ /// This is used to prevent stack overflows from circular references.
+ ///
+ public int CurrentDepth { get; set; }
+
+ internal void AddValidationError(string key, string[] error)
+ {
+ ValidationErrors ??= [];
+
+ ValidationErrors[key] = error;
+ }
+
+ internal void AddOrExtendValidationErrors(string key, string[] errors)
+ {
+ ValidationErrors ??= [];
+
+ if (ValidationErrors.TryGetValue(key, out var existingErrors))
+ {
+ var newErrors = new string[existingErrors.Length + errors.Length];
+ existingErrors.CopyTo(newErrors, 0);
+ errors.CopyTo(newErrors, existingErrors.Length);
+ ValidationErrors[key] = newErrors;
+ }
+ else
+ {
+ ValidationErrors[key] = errors;
+ }
+ }
+
+ internal void AddOrExtendValidationError(string key, string error)
+ {
+ ValidationErrors ??= [];
+
+ if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error))
+ {
+ ValidationErrors[key] = [.. existingErrors, error];
+ }
+ else
+ {
+ ValidationErrors[key] = [error];
+ }
+ }
+}
diff --git a/src/Validation/src/ValidationOptions.cs b/src/Validation/src/ValidationOptions.cs
new file mode 100644
index 000000000000..13729b5f124a
--- /dev/null
+++ b/src/Validation/src/ValidationOptions.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Provides configuration options for the validation system.
+///
+public class ValidationOptions
+{
+ ///
+ /// Gets the list of resolvers that provide validation metadata for types and parameters.
+ /// Resolvers are processed in order, with the first resolver providing a non-null result being used.
+ ///
+ ///
+ /// Source-generated resolvers are typically inserted at the beginning of this list
+ /// to ensure they are checked before any runtime-based resolvers.
+ ///
+ [Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+ public IList Resolvers { get; } = [];
+
+ ///
+ /// Gets or sets the maximum depth for validation of nested objects.
+ /// This prevents stack overflows from circular references or extremely deep object graphs.
+ /// Default value is 32.
+ ///
+ public int MaxDepth { get; set; } = 32;
+
+ ///
+ /// Attempts to get validation information for the specified type.
+ ///
+ /// The type to get validation information for.
+ /// When this method returns, contains the validation information for the specified type,
+ /// if the type was found; otherwise, null.
+ /// true if validation information was found for the specified type; otherwise, false.
+ [Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+ public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableTypeInfo)
+ {
+ foreach (var resolver in Resolvers)
+ {
+ if (resolver.TryGetValidatableTypeInfo(type, out validatableTypeInfo))
+ {
+ return true;
+ }
+ }
+
+ validatableTypeInfo = null;
+ return false;
+ }
+
+ ///
+ /// Attempts to get validation information for the specified parameter.
+ ///
+ /// The parameter to get validation information for.
+ /// When this method returns, contains the validation information for the specified parameter,
+ /// if validation information was found; otherwise, null.
+ /// true if validation information was found for the specified parameter; otherwise, false.
+ [Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ foreach (var resolver in Resolvers)
+ {
+ if (resolver.TryGetValidatableParameterInfo(parameterInfo, out validatableInfo))
+ {
+ return true;
+ }
+ }
+
+ validatableInfo = null;
+ return false;
+ }
+}
diff --git a/src/Validation/src/ValidationServiceCollectionExtensions.cs b/src/Validation/src/ValidationServiceCollectionExtensions.cs
new file mode 100644
index 000000000000..91d45a8d263e
--- /dev/null
+++ b/src/Validation/src/ValidationServiceCollectionExtensions.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Validation;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extension methods for adding validation services.
+///
+public static class ValidationServiceCollectionExtensions
+{
+ ///
+ /// Adds the validation services to the specified .
+ ///
+ /// The to add the services to.
+ /// An optional action to configure the .
+ /// The for chaining.
+ public static IServiceCollection AddValidation(this IServiceCollection services, Action? configureOptions = null)
+ {
+ services.Configure(options =>
+ {
+ if (configureOptions is not null)
+ {
+ configureOptions(options);
+ }
+ // Support ParameterInfo resolution at runtime
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ options.Resolvers.Add(new RuntimeValidatableParameterInfoResolver());
+#pragma warning restore ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ });
+ return services;
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj b/src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj
new file mode 100644
index 000000000000..73b39892474c
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/Microsoft.Extensions.Validation.Tests.csproj
@@ -0,0 +1,12 @@
+
+
+
+ $(DefaultNetCoreTargetFramework)
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs
new file mode 100644
index 000000000000..ada596165dea
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs
@@ -0,0 +1,191 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.IO.Pipelines;
+using System.Reflection;
+using System.Security.Claims;
+
+namespace Microsoft.Extensions.Validation.Tests;
+
+public class RuntimeValidatableParameterInfoResolverTests
+{
+ private readonly RuntimeValidatableParameterInfoResolver _resolver = new();
+
+ [Fact]
+ public void TryGetValidatableTypeInfo_AlwaysReturnsFalse()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(string), out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithNullName_ThrowsInvalidOperationException()
+ {
+ var parameterInfo = new NullNameParameterInfo();
+
+ var exception = Assert.Throws(() =>
+ _resolver.TryGetValidatableParameterInfo(parameterInfo, out _));
+
+ Assert.Contains("without a name", exception.Message);
+ }
+
+ [Theory]
+ [InlineData(typeof(string))]
+ [InlineData(typeof(int))]
+ [InlineData(typeof(bool))]
+ [InlineData(typeof(DateTime))]
+ [InlineData(typeof(Guid))]
+ [InlineData(typeof(decimal))]
+ [InlineData(typeof(DayOfWeek))] // Enum
+ [InlineData(typeof(ClaimsPrincipal))]
+ [InlineData(typeof(PipeReader))]
+ [InlineData(typeof(DateTimeOffset))]
+ [InlineData(typeof(TimeOnly))]
+ [InlineData(typeof(DateOnly))]
+ [InlineData(typeof(TimeSpan))]
+ [InlineData(typeof(IFormFile))]
+ [InlineData(typeof(IFormFileCollection))]
+ [InlineData(typeof(IFormCollection))]
+ [InlineData(typeof(HttpContext))]
+ [InlineData(typeof(HttpRequest))]
+ [InlineData(typeof(HttpResponse))]
+ [InlineData(typeof(CancellationToken))]
+ public void TryGetValidatableParameterInfo_WithSimpleTypesAndNoAttributes_ReturnsFalse(Type parameterType)
+ {
+ var parameterInfo = GetParameter(parameterType);
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithClassTypeAndNoAttributes_ReturnsTrue()
+ {
+ var parameterInfo = GetParameter(typeof(TestClass));
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+ var parameterValidatableInfo = Assert.IsType(validatableInfo);
+ Assert.Equal("testParam", parameterValidatableInfo.Name);
+ Assert.Equal("testParam", parameterValidatableInfo.DisplayName);
+ }
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithSimpleTypeAndAttributes_ReturnsTrue()
+ {
+ var parameterInfo = typeof(TestController)
+ .GetMethod(nameof(TestController.MethodWithAttributedParam))!
+ .GetParameters()[0];
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+ var parameterValidatableInfo = Assert.IsType(validatableInfo);
+ Assert.Equal("value", parameterValidatableInfo.Name);
+ Assert.Equal("value", parameterValidatableInfo.DisplayName);
+ }
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithDisplayAttribute_UsesDisplayNameFromAttribute()
+ {
+ var parameterInfo = typeof(TestController)
+ .GetMethod(nameof(TestController.MethodWithDisplayAttribute))!
+ .GetParameters()[0];
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+ var parameterValidatableInfo = Assert.IsType(validatableInfo);
+ Assert.Equal("value", parameterValidatableInfo.Name);
+ Assert.Equal("Custom Display Name", parameterValidatableInfo.DisplayName);
+ }
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithDisplayAttributeWithNullName_UsesParameterName()
+ {
+ var parameterInfo = typeof(TestController)
+ .GetMethod(nameof(TestController.MethodWithNullDisplayName))!
+ .GetParameters()[0];
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+ var parameterValidatableInfo = Assert.IsType(validatableInfo);
+ Assert.Equal("value", parameterValidatableInfo.Name);
+ Assert.Equal("value", parameterValidatableInfo.DisplayName);
+ }
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithNullableValueType_ReturnsFalse()
+ {
+ var parameterInfo = GetParameter(typeof(int?));
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithNullableReferenceType_ReturnsTrue()
+ {
+ var parameterInfo = GetNullableParameter(typeof(TestClass));
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+ var parameterValidatableInfo = Assert.IsType(validatableInfo);
+ Assert.Equal("testParam", parameterValidatableInfo.Name);
+ Assert.Equal("testParam", parameterValidatableInfo.DisplayName);
+ }
+
+ private static ParameterInfo GetParameter(Type parameterType)
+ {
+ return typeof(TestParameterHolder)
+ .GetMethod(nameof(TestParameterHolder.Method))!
+ .MakeGenericMethod(parameterType)
+ .GetParameters()[0];
+ }
+
+ private static ParameterInfo GetNullableParameter(Type parameterType)
+ {
+ return typeof(TestParameterHolder)
+ .GetMethod(nameof(TestParameterHolder.MethodWithNullable))!
+ .MakeGenericMethod(parameterType)
+ .GetParameters()[0];
+ }
+
+ private class TestClass { }
+
+ private class TestParameterHolder
+ {
+ public void Method(T testParam) { }
+ public void MethodWithNullable(T? testParam) { }
+ }
+
+ private class TestController
+ {
+ public void MethodWithAttributedParam([Required] string value) { }
+
+ public void MethodWithDisplayAttribute([Display(Name = "Custom Display Name")][Required] string value) { }
+
+ public void MethodWithNullDisplayName([Display(Name = null)][Required] string value) { }
+ }
+
+ private class NullNameParameterInfo : ParameterInfo
+ {
+ public override string? Name => null;
+ public override Type ParameterType => typeof(string);
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableInfoResolverTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableInfoResolverTests.cs
new file mode 100644
index 000000000000..0a745a62a209
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableInfoResolverTests.cs
@@ -0,0 +1,223 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Moq;
+
+namespace Microsoft.Extensions.Validation.Tests;
+
+public class ValidatableInfoResolverTests
+{
+ public delegate void TryGetValidatableTypeInfoCallback(Type type, out IValidatableInfo? validatableInfo);
+ public delegate void TryGetValidatableParameterInfoCallback(ParameterInfo parameter, out IValidatableInfo? validatableInfo);
+
+ [Fact]
+ public void GetValidatableTypeInfo_ReturnsNull_ForNonValidatableType()
+ {
+ // Arrange
+ var resolver = new Mock();
+ IValidatableInfo? validatableInfo = null;
+ resolver.Setup(r => r.TryGetValidatableTypeInfo(It.IsAny(), out validatableInfo)).Returns(false);
+
+ // Act
+ var result = resolver.Object.TryGetValidatableTypeInfo(typeof(NonValidatableType), out validatableInfo);
+
+ // Assert
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public void GetValidatableTypeInfo_ReturnsTypeInfo_ForValidatableType()
+ {
+ // Arrange
+ var mockTypeInfo = new Mock(
+ typeof(ValidatableType),
+ Array.Empty()).Object;
+
+ var resolver = new Mock();
+ IValidatableInfo? validatableInfo = null;
+ resolver
+ .Setup(r => r.TryGetValidatableTypeInfo(typeof(ValidatableType), out validatableInfo))
+ .Callback(new TryGetValidatableTypeInfoCallback((t, out info) =>
+ {
+ info = mockTypeInfo; // Set the out parameter to our mock
+ }))
+ .Returns(true);
+
+ // Act
+ var result = resolver.Object.TryGetValidatableTypeInfo(typeof(ValidatableType), out validatableInfo);
+
+ // Assert
+ Assert.True(result);
+ var validatableTypeInfo = Assert.IsAssignableFrom(validatableInfo);
+ Assert.Equal(typeof(ValidatableType), validatableTypeInfo.Type);
+ }
+
+ [Fact]
+ public void GetValidatableParameterInfo_ReturnsNull_ForNonValidatableParameter()
+ {
+ // Arrange
+ var method = typeof(TestMethods).GetMethod(nameof(TestMethods.MethodWithNonValidatableParam))!;
+ var parameter = method.GetParameters()[0];
+
+ var resolver = new Mock();
+ IValidatableInfo? validatableInfo = null;
+ resolver.Setup(r => r.TryGetValidatableParameterInfo(It.IsAny(), out validatableInfo)).Returns(false);
+
+ // Act
+ var result = resolver.Object.TryGetValidatableParameterInfo(parameter, out validatableInfo);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void GetValidatableParameterInfo_ReturnsParameterInfo_ForValidatableParameter()
+ {
+ // Arrange
+ var method = typeof(TestMethods).GetMethod(nameof(TestMethods.MethodWithValidatableParam))!;
+ var parameter = method.GetParameters()[0];
+
+ var mockParamInfo = new Mock(
+ typeof(string),
+ "model",
+ "model").Object;
+
+ var resolver = new Mock();
+
+ // Setup using the same pattern as in the type info test
+ resolver.Setup(r => r.TryGetValidatableParameterInfo(parameter, out It.Ref.IsAny))
+ .Callback(new TryGetValidatableParameterInfoCallback((ParameterInfo p, out IValidatableInfo? info) =>
+ {
+ info = mockParamInfo; // Set the out parameter to our mock
+ }))
+ .Returns(true);
+
+ // Act
+ var result = resolver.Object.TryGetValidatableParameterInfo(parameter, out var validatableInfo);
+
+ // Assert
+ Assert.True(result);
+ var validatableParamInfo = Assert.IsAssignableFrom(validatableInfo);
+ Assert.Equal("model", validatableParamInfo.Name);
+ }
+
+ [Fact]
+ public void ResolversChain_ProcessesInCorrectOrder()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ var resolver1 = new Mock();
+ var resolver2 = new Mock();
+ var resolver3 = new Mock();
+
+ // Create the object that will be returned by resolver2
+ var mockTypeInfo = new Mock(typeof(ValidatableType), Array.Empty()).Object;
+
+ // Setup resolver1 to return false (doesn't handle this type)
+ resolver1
+ .Setup(r => r.TryGetValidatableTypeInfo(typeof(ValidatableType), out It.Ref.IsAny))
+ .Callback(new TryGetValidatableTypeInfoCallback((Type t, out IValidatableInfo? info) =>
+ {
+ info = null;
+ }))
+ .Returns(false);
+
+ // Setup resolver2 to return true and set the mock type info
+ resolver2
+ .Setup(r => r.TryGetValidatableTypeInfo(typeof(ValidatableType), out It.Ref.IsAny))
+ .Callback(new TryGetValidatableTypeInfoCallback((Type t, out IValidatableInfo? info) =>
+ {
+ info = mockTypeInfo;
+ }))
+ .Returns(true);
+
+ services.AddValidation(Options =>
+ {
+ Options.Resolvers.Add(resolver1.Object);
+ Options.Resolvers.Add(resolver2.Object);
+ Options.Resolvers.Add(resolver3.Object);
+ });
+
+ var serviceProvider = services.BuildServiceProvider();
+ var validationOptions = serviceProvider.GetRequiredService>().Value;
+
+ // Act
+ var result = validationOptions.TryGetValidatableTypeInfo(typeof(ValidatableType), out var validatableInfo);
+
+ // Assert
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+ Assert.Equal(typeof(ValidatableType), ((ValidatableTypeInfo)validatableInfo).Type);
+
+ // Verify that resolvers were called in the expected order
+ resolver1.Verify(r => r.TryGetValidatableTypeInfo(typeof(ValidatableType), out It.Ref.IsAny), Times.Once);
+ resolver2.Verify(r => r.TryGetValidatableTypeInfo(typeof(ValidatableType), out It.Ref.IsAny), Times.Once);
+ resolver3.Verify(r => r.TryGetValidatableTypeInfo(typeof(ValidatableType), out It.Ref.IsAny), Times.Never);
+ }
+
+ // Test types
+ private class NonValidatableType { }
+
+ [ValidatableType]
+ private class ValidatableType
+ {
+ [Required]
+ public string Name { get; set; } = "";
+ }
+
+ private static class TestMethods
+ {
+ public static void MethodWithNonValidatableParam(NonValidatableType param) { }
+ public static void MethodWithValidatableParam(ValidatableType model) { }
+ }
+
+ // Test implementations
+ private class TestValidatablePropertyInfo : ValidatablePropertyInfo
+ {
+ private readonly ValidationAttribute[] _validationAttributes;
+
+ public TestValidatablePropertyInfo(
+ Type containingType,
+ Type propertyType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes)
+ : base(containingType, propertyType, name, displayName)
+ {
+ _validationAttributes = validationAttributes;
+ }
+
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+ }
+
+ private class TestValidatableParameterInfo : ValidatableParameterInfo
+ {
+ private readonly ValidationAttribute[] _validationAttributes;
+
+ public TestValidatableParameterInfo(
+ Type parameterType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes)
+ : base(parameterType, name, displayName)
+ {
+ _validationAttributes = validationAttributes;
+ }
+
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+ }
+
+ private class TestValidatableTypeInfo(
+ Type type,
+ ValidatablePropertyInfo[] members) : ValidatableTypeInfo(type, members)
+ {
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableParameterInfoTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableParameterInfoTests.cs
new file mode 100644
index 000000000000..3ede11071d3d
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableParameterInfoTests.cs
@@ -0,0 +1,432 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Extensions.Validation.Tests;
+
+public class ValidatableParameterInfoTests
+{
+ [Fact]
+ public async Task Validate_RequiredParameter_AddsErrorWhenNull()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(string),
+ name: "testParam",
+ displayName: "Test Parameter",
+ validationAttributes: [new RequiredAttribute()]);
+
+ var context = CreateValidatableContext();
+
+ // Act
+ await paramInfo.ValidateAsync(null, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("testParam", error.Key);
+ Assert.Equal("The Test Parameter field is required.", error.Value.Single());
+ }
+
+ [Fact]
+ public async Task Validate_RequiredParameter_ShortCircuitsOtherValidations()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(string),
+ name: "testParam",
+ displayName: "Test Parameter",
+ // Most ValidationAttributes skip validation if the value is null
+ // so we use a custom one that always fails to assert on the behavior here
+ validationAttributes: [new RequiredAttribute(), new CustomTestValidationAttribute()]);
+
+ var context = CreateValidatableContext();
+
+ // Act
+ await paramInfo.ValidateAsync(null, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("testParam", error.Key);
+ Assert.Equal("The Test Parameter field is required.", error.Value.Single());
+ }
+
+ [Fact]
+ public async Task Validate_SkipsValidation_WhenNullAndNotRequired()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(string),
+ name: "testParam",
+ displayName: "Test Parameter",
+ validationAttributes: [new StringLengthAttribute(10)]);
+
+ var context = CreateValidatableContext();
+
+ // Act
+ await paramInfo.ValidateAsync(null, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.Null(errors); // No errors added
+ }
+
+ [Fact]
+ public async Task Validate_WithRangeAttribute_ValidatesCorrectly()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(int),
+ name: "testParam",
+ displayName: "Test Parameter",
+ validationAttributes: [new RangeAttribute(10, 100)]);
+
+ var context = CreateValidatableContext();
+
+ // Act
+ await paramInfo.ValidateAsync(5, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("testParam", error.Key);
+ Assert.Equal("The field Test Parameter must be between 10 and 100.", error.Value.First());
+ }
+
+ [Fact]
+ public async Task Validate_WithDisplayNameAttribute_UsesDisplayNameInErrorMessage()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(string),
+ name: "testParam",
+ displayName: "Custom Display Name",
+ validationAttributes: [new RequiredAttribute()]);
+
+ var context = CreateValidatableContext();
+
+ // Act
+ await paramInfo.ValidateAsync(null, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("testParam", error.Key);
+ // The error message should use the display name
+ Assert.Equal("The Custom Display Name field is required.", error.Value.First());
+ }
+
+ [Fact]
+ public async Task Validate_WhenValidatableTypeHasErrors_AddsNestedErrors()
+ {
+ // Arrange
+ var personTypeInfo = new TestValidatableTypeInfo(
+ typeof(Person),
+ [
+ new TestValidatablePropertyInfo(
+ typeof(Person),
+ typeof(string),
+ "Name",
+ "Name",
+ [new RequiredAttribute()])
+ ]);
+
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(Person),
+ name: "person",
+ displayName: "Person",
+ validationAttributes: []);
+
+ var typeMapping = new Dictionary
+ {
+ { typeof(Person), personTypeInfo }
+ };
+
+ var context = CreateValidatableContext(typeMapping);
+ var person = new Person(); // Name is null, so should fail validation
+
+ // Act
+ await paramInfo.ValidateAsync(person, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("Name", error.Key);
+ Assert.Equal("The Name field is required.", error.Value[0]);
+ }
+
+ [Fact]
+ public async Task Validate_WithEnumerableOfValidatableType_ValidatesEachItem()
+ {
+ // Arrange
+ var personTypeInfo = new TestValidatableTypeInfo(
+ typeof(Person),
+ [
+ new TestValidatablePropertyInfo(
+ typeof(Person),
+ typeof(string),
+ "Name",
+ "Name",
+ [new RequiredAttribute()])
+ ]);
+
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(IEnumerable),
+ name: "people",
+ displayName: "People",
+ validationAttributes: []);
+
+ var typeMapping = new Dictionary
+ {
+ { typeof(Person), personTypeInfo }
+ };
+
+ var context = CreateValidatableContext(typeMapping);
+ var people = new List
+ {
+ new() { Name = "Valid" },
+ new() // Name is null, should fail
+ };
+
+ // Act
+ await paramInfo.ValidateAsync(people, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("people[1].Name", error.Key);
+ Assert.Equal("The Name field is required.", error.Value[0]);
+ }
+
+ [Fact]
+ public async Task Validate_MultipleErrorsOnSameParameter_CollectsAllErrors()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(int),
+ name: "testParam",
+ displayName: "Test Parameter",
+ validationAttributes:
+ [
+ new RangeAttribute(10, 100) { ErrorMessage = "Range error" },
+ new CustomTestValidationAttribute { ErrorMessage = "Custom error" }
+ ]);
+
+ var context = CreateValidatableContext();
+
+ // Act
+ await paramInfo.ValidateAsync(5, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("testParam", error.Key);
+ Assert.Collection(error.Value,
+ e => Assert.Equal("Range error", e),
+ e => Assert.Equal("Custom error", e));
+ }
+
+ [Fact]
+ public async Task Validate_WithContextPrefix_AddsErrorsWithCorrectPrefix()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(int),
+ name: "testParam",
+ displayName: "Test Parameter",
+ validationAttributes: [new RangeAttribute(10, 100)]);
+
+ var context = CreateValidatableContext();
+ context.CurrentValidationPath = "parent";
+
+ // Act
+ await paramInfo.ValidateAsync(5, context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("parent.testParam", error.Key);
+ Assert.Equal("The field Test Parameter must be between 10 and 100.", error.Value.First());
+ }
+
+ [Fact]
+ public async Task Validate_ExceptionDuringValidation_CapturesExceptionAsError()
+ {
+ // Arrange
+ var paramInfo = CreateTestParameterInfo(
+ parameterType: typeof(string),
+ name: "testParam",
+ displayName: "Test Parameter",
+ validationAttributes: [new ThrowingValidationAttribute()]);
+
+ var context = CreateValidatableContext();
+
+ // Act
+ await paramInfo.ValidateAsync("test", context, default);
+
+ // Assert
+ var errors = context.ValidationErrors;
+ Assert.NotNull(errors);
+ var error = Assert.Single(errors);
+ Assert.Equal("testParam", error.Key);
+ Assert.Equal("Test exception", error.Value.First());
+ }
+
+ private TestValidatableParameterInfo CreateTestParameterInfo(
+ Type parameterType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes)
+ {
+ return new TestValidatableParameterInfo(
+ parameterType,
+ name,
+ displayName,
+ validationAttributes);
+ }
+
+ private ValidateContext CreateValidatableContext(
+ Dictionary? typeMapping = null)
+ {
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+ var validationContext = new ValidationContext(new object(), serviceProvider, null);
+
+ return new ValidateContext
+ {
+ ValidationContext = validationContext,
+ ValidationOptions = new TestValidationOptions(typeMapping ?? new Dictionary())
+ };
+ }
+
+ private class TestValidatableParameterInfo : ValidatableParameterInfo
+ {
+ private readonly ValidationAttribute[] _validationAttributes;
+
+ public TestValidatableParameterInfo(
+ Type parameterType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes)
+ : base(parameterType, name, displayName)
+ {
+ _validationAttributes = validationAttributes;
+ }
+
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+ }
+
+ private class TestValidatablePropertyInfo : ValidatablePropertyInfo
+ {
+ private readonly ValidationAttribute[] _validationAttributes;
+
+ public TestValidatablePropertyInfo(
+ Type containingType,
+ Type propertyType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes)
+ : base(containingType, propertyType, name, displayName)
+ {
+ _validationAttributes = validationAttributes;
+ }
+
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+ }
+
+ private class TestValidatableTypeInfo(
+ Type type,
+ ValidatablePropertyInfo[] members) : ValidatableTypeInfo(type, members)
+ {
+ }
+
+ private class TestValidationOptions : ValidationOptions
+ {
+ public TestValidationOptions(Dictionary typeInfoMappings)
+ {
+ // Create a custom resolver that uses the dictionary
+ var resolver = new DictionaryBasedResolver(typeInfoMappings);
+
+ // Add it to the resolvers collection
+ Resolvers.Add(resolver);
+ }
+
+ // Private resolver implementation that uses a dictionary lookup
+ private class DictionaryBasedResolver : IValidatableInfoResolver
+ {
+ private readonly Dictionary _typeInfoMappings;
+
+ public DictionaryBasedResolver(Dictionary typeInfoMappings)
+ {
+ _typeInfoMappings = typeInfoMappings;
+ }
+
+ public ValidatableTypeInfo? TryGetValidatableTypeInfo(Type type)
+ {
+ _typeInfoMappings.TryGetValue(type, out var info);
+ return info;
+ }
+
+ public ValidatableParameterInfo? GetValidatableParameterInfo(ParameterInfo parameterInfo)
+ {
+ // Not implemented in the test
+ return null;
+ }
+
+ public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ if (_typeInfoMappings.TryGetValue(type, out var validatableTypeInfo))
+ {
+ validatableInfo = validatableTypeInfo;
+ return true;
+ }
+ validatableInfo = null;
+ return false;
+ }
+
+ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ validatableInfo = null;
+ return false;
+ }
+ }
+ }
+
+ // Test data classes and validation attributes
+
+ private class Person
+ {
+ public string? Name { get; set; }
+ }
+
+ private class CustomTestValidationAttribute : ValidationAttribute
+ {
+ public override bool IsValid(object? value)
+ {
+ // Always fail for testing
+ return false;
+ }
+ }
+
+ private class ThrowingValidationAttribute : ValidationAttribute
+ {
+ public override bool IsValid(object? value)
+ {
+ throw new InvalidOperationException("Test exception");
+ }
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs
new file mode 100644
index 000000000000..775ad0e947ee
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs
@@ -0,0 +1,790 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+
+namespace Microsoft.Extensions.Validation.Tests;
+
+public class ValidatableTypeInfoTests
+{
+ [Fact]
+ public async Task Validate_ValidatesComplexType_WithNestedProperties()
+ {
+ // Arrange
+ var personType = new TestValidatableTypeInfo(
+ typeof(Person),
+ [
+ CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name",
+ [new RequiredAttribute()]),
+ CreatePropertyInfo(typeof(Person), typeof(int), "Age", "Age",
+ [new RangeAttribute(0, 120)]),
+ CreatePropertyInfo(typeof(Person), typeof(Address), "Address", "Address",
+ [])
+ ]);
+
+ var addressType = new TestValidatableTypeInfo(
+ typeof(Address),
+ [
+ CreatePropertyInfo(typeof(Address), typeof(string), "Street", "Street",
+ [new RequiredAttribute()]),
+ CreatePropertyInfo(typeof(Address), typeof(string), "City", "City",
+ [new RequiredAttribute()])
+ ]);
+
+ var validationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(Person), personType },
+ { typeof(Address), addressType }
+ });
+
+ var personWithMissingRequiredFields = new Person
+ {
+ Age = 150, // Invalid age
+ Address = new Address() // Missing required City and Street
+ };
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(personWithMissingRequiredFields)
+ };
+
+ // Act
+ await personType.ValidateAsync(personWithMissingRequiredFields, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Age", kvp.Key);
+ Assert.Equal("The field Age must be between 0 and 120.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Address.Street", kvp.Key);
+ Assert.Equal("The Street field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Address.City", kvp.Key);
+ Assert.Equal("The City field is required.", kvp.Value.First());
+ });
+ }
+
+ [Fact]
+ public async Task Validate_HandlesIValidatableObject_Implementation()
+ {
+ // Arrange
+ var employeeType = new TestValidatableTypeInfo(
+ typeof(Employee),
+ [
+ CreatePropertyInfo(typeof(Employee), typeof(string), "Name", "Name",
+ [new RequiredAttribute()]),
+ CreatePropertyInfo(typeof(Employee), typeof(string), "Department", "Department",
+ []),
+ CreatePropertyInfo(typeof(Employee), typeof(decimal), "Salary", "Salary",
+ [])
+ ]);
+
+ var employee = new Employee
+ {
+ Name = "John Doe",
+ Department = "IT",
+ Salary = -5000 // Negative salary will trigger IValidatableObject validation
+ };
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(Employee), employeeType }
+ }),
+ ValidationContext = new ValidationContext(employee)
+ };
+
+ // Act
+ await employeeType.ValidateAsync(employee, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ var error = Assert.Single(context.ValidationErrors);
+ Assert.Equal("Salary", error.Key);
+ Assert.Equal("Salary must be a positive value.", error.Value.First());
+ }
+
+ [Fact]
+ public async Task Validate_HandlesPolymorphicTypes_WithSubtypes()
+ {
+ // Arrange
+ var baseType = new TestValidatableTypeInfo(
+ typeof(Vehicle),
+ [
+ CreatePropertyInfo(typeof(Vehicle), typeof(string), "Make", "Make",
+ [new RequiredAttribute()]),
+ CreatePropertyInfo(typeof(Vehicle), typeof(string), "Model", "Model",
+ [new RequiredAttribute()])
+ ]);
+
+ var derivedType = new TestValidatableTypeInfo(
+ typeof(Car),
+ [
+ CreatePropertyInfo(typeof(Car), typeof(int), "Doors", "Doors",
+ [new RangeAttribute(2, 5)])
+ ]);
+
+ var car = new Car
+ {
+ // Missing Make and Model (required in base type)
+ Doors = 7 // Invalid number of doors
+ };
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(Vehicle), baseType },
+ { typeof(Car), derivedType }
+ }),
+ ValidationContext = new ValidationContext(car)
+ };
+
+ // Act
+ await derivedType.ValidateAsync(car, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Doors", kvp.Key);
+ Assert.Equal("The field Doors must be between 2 and 5.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Make", kvp.Key);
+ Assert.Equal("The Make field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Model", kvp.Key);
+ Assert.Equal("The Model field is required.", kvp.Value.First());
+ });
+ }
+
+ [Fact]
+ public async Task Validate_HandlesCollections_OfValidatableTypes()
+ {
+ // Arrange
+ var itemType = new TestValidatableTypeInfo(
+ typeof(OrderItem),
+ [
+ CreatePropertyInfo(typeof(OrderItem), typeof(string), "ProductName", "ProductName",
+ [new RequiredAttribute()]),
+ CreatePropertyInfo(typeof(OrderItem), typeof(int), "Quantity", "Quantity",
+ [new RangeAttribute(1, 100)])
+ ]);
+
+ var orderType = new TestValidatableTypeInfo(
+ typeof(Order),
+ [
+ CreatePropertyInfo(typeof(Order), typeof(string), "OrderNumber", "OrderNumber",
+ [new RequiredAttribute()]),
+ CreatePropertyInfo(typeof(Order), typeof(List), "Items", "Items",
+ [])
+ ]);
+
+ var order = new Order
+ {
+ OrderNumber = "ORD-12345",
+ Items =
+ [
+ new OrderItem { ProductName = "Valid Product", Quantity = 5 },
+ new OrderItem { /* Missing ProductName (required) */ Quantity = 0 /* Invalid quantity */ },
+ new OrderItem { ProductName = "Another Product", Quantity = 200 /* Invalid quantity */ }
+ ]
+ };
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(OrderItem), itemType },
+ { typeof(Order), orderType }
+ }),
+ ValidationContext = new ValidationContext(order)
+ };
+
+ // Act
+ await orderType.ValidateAsync(order, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Items[1].ProductName", kvp.Key);
+ Assert.Equal("The ProductName field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Items[1].Quantity", kvp.Key);
+ Assert.Equal("The field Quantity must be between 1 and 100.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Items[2].Quantity", kvp.Key);
+ Assert.Equal("The field Quantity must be between 1 and 100.", kvp.Value.First());
+ });
+ }
+
+ [Fact]
+ public async Task Validate_HandlesNullValues_Appropriately()
+ {
+ // Arrange
+ var personType = new TestValidatableTypeInfo(
+ typeof(Person),
+ [
+ CreatePropertyInfo(typeof(Person), typeof(string), "Name", "Name",
+ []),
+ CreatePropertyInfo(typeof(Person), typeof(Address), "Address", "Address",
+ [])
+ ]);
+
+ var person = new Person
+ {
+ Name = null,
+ Address = null
+ };
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(Person), personType }
+ }),
+ ValidationContext = new ValidationContext(person)
+ };
+
+ // Act
+ await personType.ValidateAsync(person, context, default);
+
+ // Assert
+ Assert.Null(context.ValidationErrors); // No validation errors for nullable properties with null values
+ }
+
+ [Fact]
+ public async Task Validate_RespectsMaxDepthOption_ForCircularReferences()
+ {
+ // Arrange
+ // Create a type that can contain itself (circular reference)
+ var nodeType = new TestValidatableTypeInfo(
+ typeof(TreeNode),
+ [
+ CreatePropertyInfo(typeof(TreeNode), typeof(string), "Name", "Name",
+ [new RequiredAttribute()]),
+ CreatePropertyInfo(typeof(TreeNode), typeof(TreeNode), "Parent", "Parent",
+ []),
+ CreatePropertyInfo(typeof(TreeNode), typeof(List), "Children", "Children",
+ [])
+ ]);
+
+ // Create a validation options with a small max depth
+ var validationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(TreeNode), nodeType }
+ });
+ validationOptions.MaxDepth = 3; // Set a small max depth to trigger the limit
+
+ // Create a deep tree with circular references
+ var rootNode = new TreeNode { Name = "Root" };
+ var level1 = new TreeNode { Name = "Level1", Parent = rootNode };
+ var level2 = new TreeNode { Name = "Level2", Parent = level1 };
+ var level3 = new TreeNode { Name = "Level3", Parent = level2 };
+ var level4 = new TreeNode { Name = "" }; // Invalid: missing required name
+ var level5 = new TreeNode { Name = "" }; // Invalid but beyond max depth, should not be validated
+
+ rootNode.Children.Add(level1);
+ level1.Children.Add(level2);
+ level2.Children.Add(level3);
+ level3.Children.Add(level4);
+ level4.Children.Add(level5);
+
+ // Add a circular reference
+ level5.Children.Add(rootNode);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationErrors = [],
+ ValidationContext = new ValidationContext(rootNode)
+ };
+
+ // Act + Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await nodeType.ValidateAsync(rootNode, context, default));
+
+ Assert.NotNull(exception);
+ Assert.Equal("Maximum validation depth of 3 exceeded at 'Children[0].Parent.Children[0]' in 'TreeNode'. This is likely caused by a circular reference in the object graph. Consider increasing the MaxDepth in ValidationOptions if deeper validation is required.", exception.Message);
+ Assert.Equal(0, context.CurrentDepth);
+ }
+
+ [Fact]
+ public async Task Validate_HandlesCustomValidationAttributes()
+ {
+ // Arrange
+ var productType = new TestValidatableTypeInfo(
+ typeof(Product),
+ [
+ CreatePropertyInfo(typeof(Product), typeof(string), "SKU", "SKU", [new RequiredAttribute(), new CustomSkuValidationAttribute()]),
+ ]);
+
+ var product = new Product { SKU = "INVALID-SKU" };
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(Product), productType }
+ }),
+ ValidationContext = new ValidationContext(product)
+ };
+
+ // Act
+ await productType.ValidateAsync(product, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ var error = Assert.Single(context.ValidationErrors);
+ Assert.Equal("SKU", error.Key);
+ Assert.Equal("SKU must start with 'PROD-'.", error.Value.First());
+ }
+
+ [Fact]
+ public async Task Validate_HandlesMultipleErrorsOnSameProperty()
+ {
+ // Arrange
+ var userType = new TestValidatableTypeInfo(
+ typeof(User),
+ [
+ CreatePropertyInfo(typeof(User), typeof(string), "Password", "Password",
+ [
+ new RequiredAttribute(),
+ new MinLengthAttribute(8) { ErrorMessage = "Password must be at least 8 characters." },
+ new PasswordComplexityAttribute()
+ ])
+ ]);
+
+ var user = new User { Password = "abc" }; // Too short and not complex enough
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(User), userType }
+ }),
+ ValidationContext = new ValidationContext(user)
+ };
+
+ // Act
+ await userType.ValidateAsync(user, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Single(context.ValidationErrors.Keys); // Only the "Password" key
+ Assert.Equal(2, context.ValidationErrors["Password"].Length); // But with 2 errors
+ Assert.Contains("Password must be at least 8 characters.", context.ValidationErrors["Password"]);
+ Assert.Contains("Password must contain at least one number and one special character.", context.ValidationErrors["Password"]);
+ }
+
+ [Fact]
+ public async Task Validate_HandlesMultiLevelInheritance()
+ {
+ // Arrange
+ var baseType = new TestValidatableTypeInfo(
+ typeof(BaseEntity),
+ [
+ CreatePropertyInfo(typeof(BaseEntity), typeof(Guid), "Id", "Id", [])
+ ]);
+
+ var intermediateType = new TestValidatableTypeInfo(
+ typeof(IntermediateEntity),
+ [
+ CreatePropertyInfo(typeof(IntermediateEntity), typeof(DateTime), "CreatedAt", "CreatedAt", [new PastDateAttribute()])
+ ]);
+
+ var derivedType = new TestValidatableTypeInfo(
+ typeof(DerivedEntity),
+ [
+ CreatePropertyInfo(typeof(DerivedEntity), typeof(string), "Name", "Name", [new RequiredAttribute()])
+ ]);
+
+ var entity = new DerivedEntity
+ {
+ Name = "", // Invalid: required
+ CreatedAt = DateTime.Now.AddDays(1) // Invalid: future date
+ };
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(BaseEntity), baseType },
+ { typeof(IntermediateEntity), intermediateType },
+ { typeof(DerivedEntity), derivedType }
+ }),
+ ValidationContext = new ValidationContext(entity)
+ };
+
+ // Act
+ await derivedType.ValidateAsync(entity, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("CreatedAt", kvp.Key);
+ Assert.Equal("Date must be in the past.", kvp.Value.First());
+ });
+ }
+
+ [Fact]
+ public async Task Validate_RequiredOnPropertyShortCircuitsOtherValidations()
+ {
+ // Arrange
+ var userType = new TestValidatableTypeInfo(
+ typeof(User),
+ [
+ CreatePropertyInfo(typeof(User), typeof(string), "Password", "Password",
+ [new RequiredAttribute(), new PasswordComplexityAttribute()])
+ ]);
+
+ var user = new User { Password = null }; // Invalid: required
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(User), userType }
+ }),
+ ValidationContext = new ValidationContext(user) // Invalid: required
+ };
+
+ // Act
+ await userType.ValidateAsync(user, context, default);
+
+ // Assert
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Single(context.ValidationErrors.Keys);
+ var error = Assert.Single(context.ValidationErrors);
+ Assert.Equal("Password", error.Key);
+ Assert.Equal("The Password field is required.", error.Value.Single());
+ }
+
+ [Fact]
+ public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_BehavesAsExpected()
+ {
+ var globalType = new TestValidatableTypeInfo(
+ typeof(GlobalErrorObject),
+ []); // no properties – nothing sets MemberName
+ var globalErrorInstance = new GlobalErrorObject { Data = -1 };
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(GlobalErrorObject), globalType }
+ }),
+ ValidationContext = new ValidationContext(globalErrorInstance)
+ };
+
+ await globalType.ValidateAsync(globalErrorInstance, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ var globalError = Assert.Single(context.ValidationErrors);
+ Assert.Equal(string.Empty, globalError.Key);
+ Assert.Equal("Data must be positive.", globalError.Value.Single());
+
+ var multiType = new TestValidatableTypeInfo(
+ typeof(MultiMemberErrorObject),
+ [
+ CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "FirstName", "FirstName", []),
+ CreatePropertyInfo(typeof(MultiMemberErrorObject), typeof(string), "LastName", "LastName", [])
+ ]);
+
+ context.ValidationErrors = [];
+ context.ValidationOptions = new TestValidationOptions(new Dictionary
+ {
+ { typeof(MultiMemberErrorObject), multiType }
+ });
+
+ var multiErrorInstance = new MultiMemberErrorObject { FirstName = "", LastName = "" };
+ context.ValidationContext = new ValidationContext(multiErrorInstance);
+
+ await multiType.ValidateAsync(multiErrorInstance, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("FirstName", kvp.Key);
+ Assert.Equal("FirstName and LastName are required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("LastName", kvp.Key);
+ Assert.Equal("FirstName and LastName are required.", kvp.Value.First());
+ });
+ }
+
+ // Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739
+ private class GlobalErrorObject : IValidatableObject
+ {
+ public int Data { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (Data <= 0)
+ {
+ yield return new ValidationResult("Data must be positive.");
+ }
+ }
+ }
+
+ // Returns multiple member names to validate https://github.com/dotnet/aspnetcore/issues/61739
+ private class MultiMemberErrorObject : IValidatableObject
+ {
+ public string? FirstName { get; set; }
+ public string? LastName { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (string.IsNullOrEmpty(FirstName) || string.IsNullOrEmpty(LastName))
+ {
+ // MULTIPLE member names
+ yield return new ValidationResult(
+ "FirstName and LastName are required.",
+ [nameof(FirstName), nameof(LastName)]);
+ }
+ }
+ }
+
+ private ValidatablePropertyInfo CreatePropertyInfo(
+ Type containingType,
+ Type propertyType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes)
+ {
+ return new TestValidatablePropertyInfo(
+ containingType,
+ propertyType,
+ name,
+ displayName,
+ validationAttributes);
+ }
+
+ // Test model classes
+ private class Person
+ {
+ public string? Name { get; set; }
+ public int Age { get; set; }
+ public Address? Address { get; set; }
+ }
+
+ private class Address
+ {
+ public string? Street { get; set; }
+ public string? City { get; set; }
+ }
+
+ private class Employee : IValidatableObject
+ {
+ public string? Name { get; set; }
+ public string? Department { get; set; }
+ public decimal Salary { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (Salary < 0)
+ {
+ yield return new ValidationResult("Salary must be a positive value.", ["Salary"]);
+ }
+ }
+ }
+
+ private class Vehicle
+ {
+ public string? Make { get; set; }
+ public string? Model { get; set; }
+ }
+
+ private class Car : Vehicle
+ {
+ public int Doors { get; set; }
+ }
+
+ private class Order
+ {
+ public string? OrderNumber { get; set; }
+ public List Items { get; set; } = [];
+ }
+
+ private class OrderItem
+ {
+ public string? ProductName { get; set; }
+ public int Quantity { get; set; }
+ }
+
+ private class TreeNode
+ {
+ public string Name { get; set; } = string.Empty;
+ public TreeNode? Parent { get; set; }
+ public List Children { get; set; } = [];
+ }
+
+ private class Product
+ {
+ public string SKU { get; set; } = string.Empty;
+ }
+
+ private class User
+ {
+ public string? Password { get; set; } = string.Empty;
+ }
+
+ private class BaseEntity
+ {
+ public Guid Id { get; set; } = Guid.NewGuid();
+ }
+
+ private class IntermediateEntity : BaseEntity
+ {
+ public DateTime CreatedAt { get; set; }
+ }
+
+ private class DerivedEntity : IntermediateEntity
+ {
+ public string Name { get; set; } = string.Empty;
+ }
+
+ private class PastDateAttribute : ValidationAttribute
+ {
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value is DateTime date && date > DateTime.Now)
+ {
+ return new ValidationResult("Date must be in the past.");
+ }
+
+ return ValidationResult.Success;
+ }
+ }
+
+ private class CustomSkuValidationAttribute : ValidationAttribute
+ {
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value is string sku && !sku.StartsWith("PROD-", StringComparison.Ordinal))
+ {
+ return new ValidationResult("SKU must start with 'PROD-'.");
+ }
+
+ return ValidationResult.Success;
+ }
+ }
+
+ private class PasswordComplexityAttribute : ValidationAttribute
+ {
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value is string password)
+ {
+ var hasDigit = password.Any(c => char.IsDigit(c));
+ var hasSpecial = password.Any(c => !char.IsLetterOrDigit(c));
+
+ if (!hasDigit || !hasSpecial)
+ {
+ return new ValidationResult("Password must contain at least one number and one special character.");
+ }
+ }
+
+ return ValidationResult.Success;
+ }
+ }
+
+ // Test implementations
+ private class TestValidatablePropertyInfo : ValidatablePropertyInfo
+ {
+ private readonly ValidationAttribute[] _validationAttributes;
+
+ public TestValidatablePropertyInfo(
+ Type containingType,
+ Type propertyType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes)
+ : base(containingType, propertyType, name, displayName)
+ {
+ _validationAttributes = validationAttributes;
+ }
+
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+ }
+
+ private class TestValidatableTypeInfo : ValidatableTypeInfo
+ {
+ public TestValidatableTypeInfo(
+ Type type,
+ ValidatablePropertyInfo[] members)
+ : base(type, members)
+ {
+ }
+ }
+
+ private class TestValidationOptions : ValidationOptions
+ {
+ public TestValidationOptions(Dictionary typeInfoMappings)
+ {
+ // Create a custom resolver that uses the dictionary
+ var resolver = new DictionaryBasedResolver(typeInfoMappings);
+
+ // Add it to the resolvers collection
+ Resolvers.Add(resolver);
+ }
+
+ // Private resolver implementation that uses a dictionary lookup
+ private class DictionaryBasedResolver : IValidatableInfoResolver
+ {
+ private readonly Dictionary _typeInfoMappings;
+
+ public DictionaryBasedResolver(Dictionary typeInfoMappings)
+ {
+ _typeInfoMappings = typeInfoMappings;
+ }
+
+ public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ if (_typeInfoMappings.TryGetValue(type, out var info))
+ {
+ validatableInfo = info;
+ return true;
+ }
+ validatableInfo = null;
+ return false;
+ }
+
+ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ validatableInfo = null;
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/Microsoft.Extensions.Validation.ValidationsGenerator.Tests.csproj b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/Microsoft.Extensions.Validation.ValidationsGenerator.Tests.csproj
new file mode 100644
index 000000000000..43c387d71140
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/Microsoft.Extensions.Validation.ValidationsGenerator.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ $(DefaultNetCoreTargetFramework)
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ComplexType.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ComplexType.cs
new file mode 100644
index 000000000000..b694f809812c
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ComplexType.cs
@@ -0,0 +1,374 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidateComplexTypes()
+ {
+ // 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("/complex-type", (ComplexType complexType) => Results.Ok("Passed"!));
+
+app.Run();
+
+public class ComplexType
+{
+ [Range(10, 100)]
+ public int IntegerWithRange { get; set; } = 10;
+
+ [Range(10, 100), Display(Name = "Valid identifier")]
+ public int IntegerWithRangeAndDisplayName { get; set; } = 50;
+
+ [Required]
+ public SubType PropertyWithMemberAttributes { get; set; } = new SubType("some-value", default);
+
+ public SubType PropertyWithoutMemberAttributes { get; set; } = new SubType("some-value", default);
+
+ public SubTypeWithInheritance PropertyWithInheritance { get; set; } = new SubTypeWithInheritance("some-value", default);
+
+ // Nullable to validate https://github.com/dotnet/aspnetcore/issues/61737
+ public List? ListOfSubTypes { get; set; } = [];
+
+ [DerivedValidation(ErrorMessage = "Value must be an even number")]
+ public int IntegerWithDerivedValidationAttribute { get; set; }
+
+ [CustomValidation(typeof(CustomValidators), nameof(CustomValidators.Validate))]
+ public int IntegerWithCustomValidation { get; set; } = 0;
+
+ [DerivedValidation, Range(10, 100)]
+ public int PropertyWithMultipleAttributes { get; set; } = 10;
+}
+
+public class DerivedValidationAttribute : ValidationAttribute
+{
+ public override bool IsValid(object? value) => value is int number && number % 2 == 0;
+}
+
+public class SubType(string? requiredProperty, string? stringWithLength)
+{
+ [Required]
+ public string RequiredProperty { get; } = requiredProperty;
+
+ [StringLength(10)]
+ public string? StringWithLength { get; } = stringWithLength;
+}
+
+public class SubTypeWithInheritance(string? requiredProperty, string? stringWithLength) : SubType(requiredProperty, stringWithLength)
+{
+ [EmailAddress]
+ public string? EmailString { get; set; }
+}
+
+public static class CustomValidators
+{
+ public static ValidationResult Validate(int number, ValidationContext validationContext)
+ {
+ var parent = (ComplexType)validationContext.ObjectInstance;
+
+ if (parent.IntegerWithRange == number)
+ {
+ return new ValidationResult(
+ "Can't use the same number value in two properties on the same class.",
+ new[] { validationContext.MemberName });
+ }
+
+ return ValidationResult.Success;
+ }
+}
+""";
+ await Verify(source, out var compilation);
+ await VerifyEndpoint(compilation, "/complex-type", async (endpoint, serviceProvider) =>
+ {
+ await InvalidIntegerWithRangeProducesError(endpoint);
+ await InvalidIntegerWithRangeAndDisplayNameProducesError(endpoint);
+ await MissingRequiredSubtypePropertyProducesError(endpoint);
+ await InvalidRequiredSubtypePropertyProducesError(endpoint);
+ await InvalidSubTypeWithInheritancePropertyProducesError(endpoint);
+ await InvalidListOfSubTypesProducesError(endpoint);
+ await InvalidPropertyWithDerivedValidationAttributeProducesError(endpoint);
+ await InvalidPropertyWithMultipleAttributesProducesError(endpoint);
+ await InvalidPropertyWithCustomValidationProducesError(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 MissingRequiredSubtypePropertyProducesError(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "PropertyWithMemberAttributes": null
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ var problemDetails = await AssertBadRequest(context);
+ Assert.Collection(problemDetails.Errors, kvp =>
+ {
+ Assert.Equal("PropertyWithMemberAttributes", kvp.Key);
+ Assert.Equal("The PropertyWithMemberAttributes field is required.", 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 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": "test@example.com"
+ },
+ "ListOfSubTypes": [],
+ "IntegerWithDerivedValidationAttribute": 2,
+ "IntegerWithCustomValidation": 0,
+ "PropertyWithMultipleAttributes": 12
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(200, context.Response.StatusCode);
+ }
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.IValidatableObject.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.IValidatableObject.cs
new file mode 100644
index 000000000000..944aea69b187
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.IValidatableObject.cs
@@ -0,0 +1,208 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidateIValidatableObject()
+ {
+ var source = """
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Validation;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+builder.Services.AddSingleton();
+builder.Services.AddKeyedSingleton("serviceKey");
+builder.Services.AddValidation();
+
+var app = builder.Build();
+
+app.MapPost("/validatable-object", (
+ ComplexValidatableType model,
+ // Demonstrates that parameters that are annotated with [FromService] are not processed
+ // by the source generator and not emitted as ValidatableTypes in the generated code.
+ [FromServices] IRangeService rangeService,
+ [FromKeyedServices("serviceKey")] TestService testService) => Results.Ok(rangeService.GetMinimum()));
+
+app.Run();
+
+public class ComplexValidatableType: IValidatableObject
+{
+ [Display(Name = "Value 1")]
+ public int Value1 { get; set; }
+
+ [EmailAddress]
+ [Required]
+ public required string Value2 { get; set; } = "test@example.com";
+
+ public ValidatableSubType SubType { get; set; } = new ValidatableSubType();
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ var rangeService = (IRangeService?)validationContext.GetService(typeof(IRangeService));
+ var minimum = rangeService?.GetMinimum();
+ var maximum = rangeService?.GetMaximum();
+ if (Value1 < minimum || Value1 > maximum)
+ {
+ yield return new ValidationResult($"The field {nameof(Value1)} must be between {minimum} and {maximum}.", [nameof(Value1)]);
+ }
+ }
+}
+
+public class SubType
+{
+ [Required]
+ // This gets ignored since it has an unsupported constructor name
+ [Display(ShortName = "SubType")]
+ public string RequiredProperty { get; set; } = "some-value";
+
+ [StringLength(10)]
+ public string? StringWithLength { get; set; }
+}
+
+public class ValidatableSubType : SubType, IValidatableObject
+{
+ public string Value3 { get; set; } = "some-value";
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (Value3 != "some-value")
+ {
+ yield return new ValidationResult($"The field {validationContext.DisplayName} must be 'some-value'.", [nameof(Value3)]);
+ }
+ }
+}
+
+public interface IRangeService
+{
+ int GetMinimum();
+ int GetMaximum();
+}
+
+public class RangeService : IRangeService
+{
+ public int GetMinimum() => 10;
+ public int GetMaximum() => 100;
+}
+
+public class TestService
+{
+ [Range(10, 100)]
+ public int Value { get; set; } = 4;
+}
+""";
+
+ await Verify(source, out var compilation);
+ await VerifyEndpoint(compilation, "/validatable-object", async (endpoint, serviceProvider) =>
+ {
+ await ValidateMethodCalledIfPropertyValidationsFail();
+ await ValidateForSubtypeInvokedFirst();
+ await ValidateForTopLevelInvoked();
+
+ async Task ValidateMethodCalledIfPropertyValidationsFail()
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "Value1": 5,
+ "Value2": "",
+ "SubType": {
+ "Value3": "foo",
+ "RequiredProperty": "",
+ "StringWithLength": ""
+ }
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("Value2", error.Key);
+ Assert.Collection(error.Value,
+ msg => Assert.Equal("The Value2 field is required.", msg));
+ },
+ error =>
+ {
+ Assert.Equal("SubType.RequiredProperty", error.Key);
+ Assert.Equal("The RequiredProperty field is required.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("SubType.Value3", error.Key);
+ Assert.Equal("The field ValidatableSubType must be 'some-value'.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Value1", error.Key);
+ Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single());
+ });
+ }
+
+ async Task ValidateForSubtypeInvokedFirst()
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "Value1": 5,
+ "Value2": "test@test.com",
+ "SubType": {
+ "Value3": "foo",
+ "RequiredProperty": "some-value-2",
+ "StringWithLength": "element"
+ }
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("SubType.Value3", error.Key);
+ Assert.Equal("The field ValidatableSubType must be 'some-value'.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Value1", error.Key);
+ Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single());
+ });
+ }
+
+ async Task ValidateForTopLevelInvoked()
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "Value1": 5,
+ "Value2": "test@test.com",
+ "SubType": {
+ "Value3": "some-value",
+ "RequiredProperty": "some-value-2",
+ "StringWithLength": "element"
+ }
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("Value1", error.Key);
+ Assert.Equal("The field Value1 must be between 10 and 100.", error.Value.Single());
+ });
+ }
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.MultipleNamespaces.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.MultipleNamespaces.cs
new file mode 100644
index 000000000000..7272bc84ab92
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.MultipleNamespaces.cs
@@ -0,0 +1,126 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidateMultipleNamespaces()
+ {
+ // 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("/namespace-one", (NamespaceOne.Type obj) => Results.Ok("Passed"));
+app.MapPost("/namespace-two", (NamespaceTwo.Type obj) => Results.Ok("Passed"));
+
+app.Run();
+
+namespace NamespaceOne {
+ public class Type
+ {
+ [StringLength(10)]
+ public string StringWithLength { get; set; } = string.Empty;
+ }
+}
+
+namespace NamespaceTwo {
+ public class Type
+ {
+ [StringLength(20)]
+ public string StringWithLength { get; set; } = string.Empty;
+ }
+}
+""";
+ await Verify(source, out var compilation);
+ await VerifyEndpoint(compilation, "/namespace-one", async (endpoint, serviceProvider) =>
+ {
+ await InvalidStringWithLengthProducesError(endpoint);
+ await ValidInputProducesNoWarnings(endpoint);
+
+ async Task InvalidStringWithLengthProducesError(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "StringWithLength": "abcdefghijk"
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ var problemDetails = await AssertBadRequest(context);
+ Assert.Collection(problemDetails.Errors, kvp =>
+ {
+ Assert.Equal("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 = """
+ {
+ "StringWithLength": "abc"
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(200, context.Response.StatusCode);
+ }
+ });
+ await VerifyEndpoint(compilation, "/namespace-two", async (endpoint, serviceProvider) =>
+ {
+ await InvalidStringWithLengthProducesError(endpoint);
+ await ValidInputProducesNoWarnings(endpoint);
+
+ async Task InvalidStringWithLengthProducesError(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "StringWithLength": "abcdefghijklmnopqrstu"
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ var problemDetails = await AssertBadRequest(context);
+ Assert.Collection(problemDetails.Errors, kvp =>
+ {
+ Assert.Equal("StringWithLength", kvp.Key);
+ Assert.Equal("The field StringWithLength must be a string with a maximum length of 20.", kvp.Value.Single());
+ });
+ }
+
+ async Task ValidInputProducesNoWarnings(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "StringWithLength": "abcdefghijk"
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(200, context.Response.StatusCode);
+ }
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.NoOp.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.NoOp.cs
new file mode 100644
index 000000000000..843b6a7c2106
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.NoOp.cs
@@ -0,0 +1,178 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task DoesNotEmitIfNoAddValidationCallExists()
+ {
+ // 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();
+
+var app = builder.Build();
+
+app.MapPost("/complex-type", (ComplexType complexType) => Results.Ok("Passed"));
+
+app.Run();
+
+public class ComplexType
+{
+ [Range(10, 100)]
+ public int IntegerWithRange { get; set; } = 10;
+}
+""";
+ await Verify(source, out var compilation);
+ // Verify that we don't validate types if no AddValidation call exists
+ await VerifyEndpoint(compilation, "/complex-type", async (endpoint, serviceProvider) =>
+ {
+ var payload = """
+ {
+ "IntegerWithRange": 5
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ });
+ }
+
+ [Fact]
+ public async Task DoesNotEmitIfNotCorrectAddValidationCallExists()
+ {
+ // 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("example");
+SomeExtensions.AddValidation(builder.Services);
+
+var app = builder.Build();
+
+app.MapPost("/complex-type", (ComplexType complexType) => Results.Ok("Passed"));
+
+app.Run();
+
+public class ComplexType
+{
+ [Range(10, 100)]
+ public int IntegerWithRange { get; set; } = 10;
+}
+
+public static class SomeExtensions
+{
+ public static IServiceCollection AddValidation(this IServiceCollection services, string someString)
+ {
+ // This is not the correct AddValidation method
+ return services;
+ }
+
+ public static IServiceCollection AddValidation(this IServiceCollection services, Action? configureOptions = null)
+ {
+ // This is not the correct AddValidation method
+ return services;
+ }
+}
+""";
+ await Verify(source, out var compilation);
+ // Verify that we don't validate types if no AddValidation call exists
+ await VerifyEndpoint(compilation, "/complex-type", async (endpoint, serviceProvider) =>
+ {
+ var payload = """
+ {
+ "IntegerWithRange": 5
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ });
+ }
+
+ [Fact]
+ public async Task DoesNotEmitForExemptTypes()
+ {
+ var source = """
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.IO.Pipelines;
+using System.Threading;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Validation;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services.AddValidation();
+
+var app = builder.Build();
+
+app.MapGet("/exempt-1", (HttpContext context) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-2", (HttpRequest request) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-3", (HttpResponse response) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-4", (IFormCollection formCollection) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-5", (IFormFileCollection formFileCollection) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-6", (IFormFile formFile) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-7", (Stream stream) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-8", (PipeReader pipeReader) => Results.Ok("Exempt Passed!"));
+app.MapGet("/exempt-9", (CancellationToken cancellationToken) => Results.Ok("Exempt Passed!"));
+app.MapPost("/complex-type", (ComplexType complexType) => Results.Ok("Passed"));
+
+app.Run();
+
+public class ComplexType
+{
+ [Range(10, 100)]
+ public int IntegerWithRange { get; set; } = 10;
+}
+""";
+ await Verify(source, out var compilation);
+ // Verify that we can validate non-exempt types
+ await VerifyEndpoint(compilation, "/complex-type", async (endpoint, serviceProvider) =>
+ {
+ 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());
+ });
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parameters.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parameters.cs
new file mode 100644
index 000000000000..56eb8016cd4a
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parameters.cs
@@ -0,0 +1,100 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidateParameters()
+ {
+ var source = """
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Validation;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services.AddValidation();
+builder.Services.AddSingleton();
+builder.Services.AddKeyedSingleton("serviceKey");
+
+var app = builder.Build();
+
+app.MapPost("/params", (
+ // Skipped from validation because it is resolved as a service by IServiceProviderIsService
+ TestService testService,
+ // Skipped from validation because it is marked as a [FromKeyedService] parameter
+ [FromKeyedServices("serviceKey")] TestService testService2,
+ [Range(10, 100)] int value1,
+ [Range(10, 100), Display(Name = "Valid identifier")] int value2,
+ [Required] string value3 = "some-value",
+ [CustomValidation(ErrorMessage = "Value must be an even number")] int value4 = 4,
+ [CustomValidation, Range(10, 100)] int value5 = 10,
+ // Skipped from validation because it is marked as a [FromService] parameter
+ [FromServices] [Range(10, 100)] int? value6 = 4,
+ Dictionary? testDict = null) => "OK");
+
+app.Run();
+
+public class CustomValidationAttribute : ValidationAttribute
+{
+ public override bool IsValid(object? value) => value is int number && number % 2 == 0;
+}
+
+public class TestService
+{
+ [Range(10, 100)]
+ public int Value { get; set; } = 4;
+}
+""";
+ await Verify(source, out var compilation);
+ await VerifyEndpoint(compilation, "/params", async (endpoint, serviceProvider) =>
+ {
+ var context = CreateHttpContext(serviceProvider);
+ context.Request.QueryString = new QueryString("?value1=5&value2=5&value3=&value4=3&value5=5");
+ await endpoint.RequestDelegate(context);
+ var problemDetails = await AssertBadRequest(context);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("value1", error.Key);
+ Assert.Equal("The field value1 must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("value2", error.Key);
+ Assert.Equal("The field Valid identifier must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("value3", error.Key);
+ Assert.Equal("The value3 field is required.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("value4", error.Key);
+ Assert.Equal("Value must be an even number", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("value5", error.Key);
+ Assert.Collection(error.Value, error =>
+ {
+ Assert.Equal("The field value5 is invalid.", error);
+ },
+ error =>
+ {
+ Assert.Equal("The field value5 must be between 10 and 100.", error);
+ });
+ });
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parsable.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parsable.cs
new file mode 100644
index 000000000000..3c3ae69658ff
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Parsable.cs
@@ -0,0 +1,122 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidateTypeWithParsableProperties()
+ {
+ // 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("/complex-type-with-parsable-properties", (ComplexTypeWithParsableProperties complexType) => Results.Ok("Passed"!));
+
+app.Run();
+
+public class ComplexTypeWithParsableProperties
+{
+ [RegularExpression("^((?!00000000-0000-0000-0000-000000000000).)*$", ErrorMessage = "Cannot use default Guid")]
+ public Guid? GuidWithRegularExpression { get; set; } = default;
+
+ [Required]
+ public TimeOnly? TimeOnlyWithRequiredValue { get; set; } = TimeOnly.FromDateTime(DateTime.UtcNow);
+
+ [Url(ErrorMessage = "The field Url must be a valid URL.")]
+ public string? Url { get; set; } = "https://example.com";
+
+ [Required]
+ [Range(typeof(DateOnly), "2023-01-01", "2025-12-31", ErrorMessage = "Date must be between 2023-01-01 and 2025-12-31")]
+ public DateOnly? DateOnlyWithRange { get; set; } = DateOnly.FromDateTime(DateTime.UtcNow);
+
+ [Range(typeof(DateTime), "2023-01-01", "2025-12-31", ErrorMessage = "DateTime must be between 2023-01-01 and 2025-12-31")]
+ public DateTime? DateTimeWithRange { get; set; } = DateTime.UtcNow;
+
+ [Range(typeof(decimal), "0.1", "100.5", ErrorMessage = "Amount must be between 0.1 and 100.5")]
+ public decimal? DecimalWithRange { get; set; } = 50.5m;
+
+ [Range(0, 12, ErrorMessage = "Hours must be between 0 and 12")]
+ public TimeSpan? TimeSpanWithHourRange { get; set; } = TimeSpan.FromHours(12);
+
+ [Range(0, 1, ErrorMessage = "Boolean value must be 0 or 1")]
+ public bool BooleanWithRange { get; set; } = true;
+
+ [RegularExpression(@"^\d+\.\d+\.\d+$", ErrorMessage = "Must be a valid version number (e.g. 1.0.0)")]
+ public Version? VersionWithRegex { get; set; } = new Version(1, 0, 0);
+}
+""";
+ await Verify(source, out var compilation);
+ await VerifyEndpoint(compilation, "/complex-type-with-parsable-properties", async (endpoint, serviceProvider) =>
+ {
+ var payload = """
+ {
+ "TimeOnlyWithRequiredValue": null,
+ "IntWithRange": 150,
+ "StringWithLength": "AB",
+ "Email": "invalid-email",
+ "Url": "invalid-url",
+ "DateOnlyWithRange": "2026-05-01",
+ "DecimalWithRange": "150.75",
+ "TimeSpanWithHourRange": "22:00:00",
+ "VersionWithRegex": "1.0",
+ "EnumProperty": "Invalid"
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ var problemDetails = await AssertBadRequest(context);
+
+ // Assert on each error with Assert.Collection
+ Assert.Collection(problemDetails.Errors.OrderBy(kvp => kvp.Key),
+ error =>
+ {
+ Assert.Equal("DateOnlyWithRange", error.Key);
+ Assert.Contains("Date must be between 2023-01-01 and 2025-12-31", error.Value);
+ },
+ error =>
+ {
+ Assert.Equal("DecimalWithRange", error.Key);
+ Assert.Contains("Amount must be between 0.1 and 100.5", error.Value);
+ },
+ error =>
+ {
+ Assert.Equal("TimeOnlyWithRequiredValue", error.Key);
+ Assert.Contains("The TimeOnlyWithRequiredValue field is required.", error.Value);
+ },
+ error =>
+ {
+ Assert.Equal("TimeSpanWithHourRange", error.Key);
+ Assert.Contains("Hours must be between 0 and 12", error.Value);
+ },
+ error =>
+ {
+ Assert.Equal("Url", error.Key);
+ Assert.Contains("The field Url must be a valid URL.", error.Value);
+ },
+ error =>
+ {
+ Assert.Equal("VersionWithRegex", error.Key);
+ Assert.Contains("Must be a valid version number (e.g. 1.0.0)", error.Value);
+ }
+ );
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Polymorphism.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Polymorphism.cs
new file mode 100644
index 000000000000..f511aa8dad5f
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Polymorphism.cs
@@ -0,0 +1,202 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidatePolymorphicTypes()
+ {
+ var source = """
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+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("/basic-polymorphism", (BaseType model) => Results.Ok());
+app.MapPost("/validatable-polymorphism", (BaseValidatableType model) => Results.Ok());
+app.MapPost("/polymorphism-container", (ContainerType model) => Results.Ok());
+
+app.Run();
+
+public class ContainerType
+{
+ public BaseType BaseType { get; set; } = new BaseType();
+ public BaseValidatableType BaseValidatableType { get; set; } = new BaseValidatableType();
+}
+
+[JsonDerivedType(typeof(BaseType), typeDiscriminator: "base")]
+[JsonDerivedType(typeof(DerivedType), typeDiscriminator: "derived")]
+public class BaseType
+{
+ [Display(Name = "Value 1")]
+ [Range(10, 100)]
+ public int Value1 { get; set; }
+
+ [EmailAddress]
+ [Required]
+ public string Value2 { get; set; } = "test@example.com";
+}
+
+public class DerivedType : BaseType
+{
+ [Base64String]
+ public string? Value3 { get; set; }
+}
+
+[JsonDerivedType(typeof(BaseValidatableType), typeDiscriminator: "base")]
+[JsonDerivedType(typeof(DerivedValidatableType), typeDiscriminator: "derived")]
+public class BaseValidatableType : IValidatableObject
+{
+ [Display(Name = "Value 1")]
+ public int Value1 { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (Value1 < 10 || Value1 > 100)
+ {
+ yield return new ValidationResult("The field Value 1 must be between 10 and 100.", new[] { nameof(Value1) });
+ }
+ }
+}
+
+public class DerivedValidatableType : BaseValidatableType
+{
+ [EmailAddress]
+ public required string Value3 { get; set; }
+}
+""";
+ await Verify(source, out var compilation);
+
+ await VerifyEndpoint(compilation, "/basic-polymorphism", async (endpoint, serviceProvider) =>
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "$type": "derived",
+ "Value1": 5,
+ "Value2": "invalid-email",
+ "Value3": "invalid-base64"
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("Value3", error.Key);
+ Assert.Equal("The Value3 field is not a valid Base64 encoding.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Value1", error.Key);
+ Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Value2", error.Key);
+ Assert.Equal("The Value2 field is not a valid e-mail address.", error.Value.Single());
+ });
+ });
+
+ await VerifyEndpoint(compilation, "/validatable-polymorphism", async (endpoint, serviceProvider) =>
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "$type": "derived",
+ "Value1": 5,
+ "Value3": "invalid-email"
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("Value3", error.Key);
+ Assert.Equal("The Value3 field is not a valid e-mail address.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Value1", error.Key);
+ Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single());
+ });
+
+ httpContext = CreateHttpContextWithPayload("""
+ {
+ "$type": "derived",
+ "Value1": 5,
+ "Value3": "test@example.com"
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails1 = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails1.Errors,
+ error =>
+ {
+ Assert.Equal("Value1", error.Key);
+ Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single());
+ });
+ });
+
+ await VerifyEndpoint(compilation, "/polymorphism-container", async (endpoint, serviceProvider) =>
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "BaseType": {
+ "$type": "derived",
+ "Value1": 5,
+ "Value2": "invalid-email",
+ "Value3": "invalid-base64"
+ },
+ "BaseValidatableType": {
+ "$type": "derived",
+ "Value1": 5,
+ "Value3": "test@example.com"
+ }
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("BaseType.Value3", error.Key);
+ Assert.Equal("The Value3 field is not a valid Base64 encoding.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("BaseType.Value1", error.Key);
+ Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("BaseType.Value2", error.Key);
+ Assert.Equal("The Value2 field is not a valid e-mail address.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("BaseValidatableType.Value1", error.Key);
+ Assert.Equal("The field Value 1 must be between 10 and 100.", error.Value.Single());
+ });
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.RecordType.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.RecordType.cs
new file mode 100644
index 000000000000..f8254fd72bc3
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/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.Extensions.Validation.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.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", (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 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": "test@example.com"
+ },
+ "ListOfSubTypes": [],
+ "IntegerWithDerivedValidationAttribute": 2,
+ "IntegerWithCustomValidation": 0,
+ "PropertyWithMultipleAttributes": 12
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(200, context.Response.StatusCode);
+ }
+ });
+
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Recursion.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Recursion.cs
new file mode 100644
index 000000000000..464a91113085
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.Recursion.cs
@@ -0,0 +1,158 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidateRecursiveTypes()
+ {
+ var source = """
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+builder.Services.AddValidation(options =>
+{
+ options.MaxDepth = 8;
+});
+
+var app = builder.Build();
+
+app.MapPost("/recursive-type", (RecursiveType model) => Results.Ok());
+
+app.Run();
+
+public class RecursiveType
+{
+ [Range(10, 100)]
+ public int Value { get; set; }
+ public RecursiveType? Next { get; set; }
+}
+""";
+ await Verify(source, out var compilation);
+
+ await VerifyEndpoint(compilation, "/recursive-type", async (endpoint, serviceProvider) =>
+ {
+ await ThrowsExceptionForDeeplyNestedType(endpoint);
+ await ValidatesTypeWithLimitedNesting(endpoint);
+
+ async Task ThrowsExceptionForDeeplyNestedType(Endpoint endpoint)
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "value": 1,
+ "next": {
+ "value": 2,
+ "next": {
+ "value": 3,
+ "next": {
+ "value": 4,
+ "next": {
+ "value": 5,
+ "next": {
+ "value": 6,
+ "next": {
+ "value": 7,
+ "next": {
+ "value": 8,
+ "next": {
+ "value": 9,
+ "next": {
+ "value": 10
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """, serviceProvider);
+
+ var exception = await Assert.ThrowsAsync(async () => await endpoint.RequestDelegate(httpContext));
+ }
+
+ async Task ValidatesTypeWithLimitedNesting(Endpoint endpoint)
+ {
+ var httpContext = CreateHttpContextWithPayload("""
+ {
+ "value": 1,
+ "next": {
+ "value": 2,
+ "next": {
+ "value": 3,
+ "next": {
+ "value": 4,
+ "next": {
+ "value": 5,
+ "next": {
+ "value": 6,
+ "next": {
+ "value": 7,
+ "next": {
+ "value": 8
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """, serviceProvider);
+
+ await endpoint.RequestDelegate(httpContext);
+
+ var problemDetails = await AssertBadRequest(httpContext);
+ Assert.Collection(problemDetails.Errors,
+ error =>
+ {
+ Assert.Equal("Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Next.Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Next.Next.Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Next.Next.Next.Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Next.Next.Next.Next.Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Next.Next.Next.Next.Next.Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Next.Next.Next.Next.Next.Next.Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ },
+ error =>
+ {
+ Assert.Equal("Next.Next.Next.Next.Next.Next.Next.Value", error.Key);
+ Assert.Equal("The field Value must be between 10 and 100.", error.Value.Single());
+ });
+ }
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ValidatableType.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ValidatableType.cs
new file mode 100644
index 000000000000..12cb575ee150
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGenerator.ValidatableType.cs
@@ -0,0 +1,381 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using Microsoft.Extensions.Validation;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
+{
+ [Fact]
+ public async Task CanValidateTypesWithAttribute()
+ {
+ var source = """
+#pragma warning disable ASP0029
+
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Collections.Generic;
+using System.Linq;
+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.Run();
+
+[ValidatableType]
+public class ComplexType
+{
+ [Range(10, 100)]
+ public int IntegerWithRange { get; set; } = 10;
+
+ [Range(10, 100), Display(Name = "Valid identifier")]
+ public int IntegerWithRangeAndDisplayName { get; set; } = 50;
+
+ [Required]
+ public SubType PropertyWithMemberAttributes { get; set; } = new SubType();
+
+ public SubType PropertyWithoutMemberAttributes { get; set; } = new SubType();
+
+ public SubTypeWithInheritance PropertyWithInheritance { get; set; } = new SubTypeWithInheritance();
+
+ public List ListOfSubTypes { get; set; } = [];
+
+ [CustomValidation(ErrorMessage = "Value must be an even number")]
+ public int IntegerWithCustomValidationAttribute { get; set; }
+
+ [CustomValidation, Range(10, 100)]
+ public int PropertyWithMultipleAttributes { get; set; } = 10;
+}
+
+public class CustomValidationAttribute : ValidationAttribute
+{
+ public override bool IsValid(object? value) => value is int number && number % 2 == 0;
+}
+
+public class SubType
+{
+ [Required]
+ public string RequiredProperty { get; set; } = "some-value";
+
+ [StringLength(10)]
+ public string? StringWithLength { get; set; }
+}
+
+public class SubTypeWithInheritance : SubType
+{
+ [EmailAddress]
+ public string? EmailString { get; set; }
+}
+""";
+ await Verify(source, out var compilation);
+ VerifyValidatableType(compilation, "ComplexType", async (validationOptions, type) =>
+ {
+ Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var validatableTypeInfo));
+
+ await InvalidIntegerWithRangeProducesError(validatableTypeInfo);
+ await InvalidIntegerWithRangeAndDisplayNameProducesError(validatableTypeInfo);
+ await MissingRequiredSubtypePropertyProducesError(validatableTypeInfo);
+ await InvalidRequiredSubtypePropertyProducesError(validatableTypeInfo);
+ await InvalidSubTypeWithInheritancePropertyProducesError(validatableTypeInfo);
+ await InvalidListOfSubTypesProducesError(validatableTypeInfo);
+ await InvalidPropertyWithDerivedValidationAttributeProducesError(validatableTypeInfo);
+ await InvalidPropertyWithMultipleAttributesProducesError(validatableTypeInfo);
+ await InvalidPropertyWithCustomValidationProducesError(validatableTypeInfo);
+ await ValidInputProducesNoWarnings(validatableTypeInfo);
+
+ async Task InvalidIntegerWithRangeProducesError(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ type.GetProperty("IntegerWithRange")?.SetValue(instance, 5);
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors, kvp =>
+ {
+ Assert.Equal("IntegerWithRange", kvp.Key);
+ Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single());
+ });
+ }
+
+ async Task InvalidIntegerWithRangeAndDisplayNameProducesError(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ type.GetProperty("IntegerWithRangeAndDisplayName")?.SetValue(instance, 5);
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors, kvp =>
+ {
+ Assert.Equal("IntegerWithRangeAndDisplayName", kvp.Key);
+ Assert.Equal("The field Valid identifier must be between 10 and 100.", kvp.Value.Single());
+ });
+ }
+
+ async Task MissingRequiredSubtypePropertyProducesError(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ type.GetProperty("PropertyWithMemberAttributes")?.SetValue(instance, null);
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors, kvp =>
+ {
+ Assert.Equal("PropertyWithMemberAttributes", kvp.Key);
+ Assert.Equal("The PropertyWithMemberAttributes field is required.", kvp.Value.Single());
+ });
+ }
+
+ async Task InvalidRequiredSubtypePropertyProducesError(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ var subType = Activator.CreateInstance(type.Assembly.GetType("SubType")!);
+ subType.GetType().GetProperty("RequiredProperty")?.SetValue(subType, "");
+ subType.GetType().GetProperty("StringWithLength")?.SetValue(subType, "way-too-long");
+ type.GetProperty("PropertyWithMemberAttributes")?.SetValue(instance, subType);
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors,
+ 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(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ var inheritanceType = Activator.CreateInstance(type.Assembly.GetType("SubTypeWithInheritance")!);
+ inheritanceType.GetType().GetProperty("RequiredProperty")?.SetValue(inheritanceType, "");
+ inheritanceType.GetType().GetProperty("StringWithLength")?.SetValue(inheritanceType, "way-too-long");
+ inheritanceType.GetType().GetProperty("EmailString")?.SetValue(inheritanceType, "not-an-email");
+ type.GetProperty("PropertyWithInheritance")?.SetValue(instance, inheritanceType);
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors,
+ 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(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ var subTypeList = Activator.CreateInstance(typeof(List<>).MakeGenericType(type.Assembly.GetType("SubType")!));
+
+ // Create first invalid item
+ var subType1 = Activator.CreateInstance(type.Assembly.GetType("SubType")!);
+ subType1.GetType().GetProperty("RequiredProperty")?.SetValue(subType1, "");
+ subType1.GetType().GetProperty("StringWithLength")?.SetValue(subType1, "way-too-long");
+
+ // Create second invalid item
+ var subType2 = Activator.CreateInstance(type.Assembly.GetType("SubType")!);
+ subType2.GetType().GetProperty("RequiredProperty")?.SetValue(subType2, "valid");
+ subType2.GetType().GetProperty("StringWithLength")?.SetValue(subType2, "way-too-long");
+
+ // Create valid item
+ var subType3 = Activator.CreateInstance(type.Assembly.GetType("SubType")!);
+ subType3.GetType().GetProperty("RequiredProperty")?.SetValue(subType3, "valid");
+ subType3.GetType().GetProperty("StringWithLength")?.SetValue(subType3, "valid");
+
+ // Add to list
+ subTypeList.GetType().GetMethod("Add")?.Invoke(subTypeList, [subType1]);
+ subTypeList.GetType().GetMethod("Add")?.Invoke(subTypeList, [subType2]);
+ subTypeList.GetType().GetMethod("Add")?.Invoke(subTypeList, [subType3]);
+
+ type.GetProperty("ListOfSubTypes")?.SetValue(instance, subTypeList);
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors,
+ 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(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ type.GetProperty("IntegerWithCustomValidationAttribute")?.SetValue(instance, 5); // Odd number, should fail
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors, kvp =>
+ {
+ Assert.Equal("IntegerWithCustomValidationAttribute", kvp.Key);
+ Assert.Equal("Value must be an even number", kvp.Value.Single());
+ });
+ }
+
+ async Task InvalidPropertyWithMultipleAttributesProducesError(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ type.GetProperty("PropertyWithMultipleAttributes")?.SetValue(instance, 5);
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors, 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(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+ type.GetProperty("IntegerWithCustomValidationAttribute")?.SetValue(instance, 3); // Odd number should fail
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Collection(context.ValidationErrors, kvp =>
+ {
+ Assert.Equal("IntegerWithCustomValidationAttribute", kvp.Key);
+ Assert.Equal("Value must be an even number", kvp.Value.Single());
+ });
+ }
+
+ async Task ValidInputProducesNoWarnings(IValidatableInfo validatableInfo)
+ {
+ var instance = Activator.CreateInstance(type);
+
+ // Set all properties with valid values
+ type.GetProperty("IntegerWithRange")?.SetValue(instance, 50);
+ type.GetProperty("IntegerWithRangeAndDisplayName")?.SetValue(instance, 50);
+
+ // Create and set PropertyWithMemberAttributes
+ var subType1 = Activator.CreateInstance(type.Assembly.GetType("SubType")!);
+ subType1.GetType().GetProperty("RequiredProperty")?.SetValue(subType1, "valid");
+ subType1.GetType().GetProperty("StringWithLength")?.SetValue(subType1, "valid");
+ type.GetProperty("PropertyWithMemberAttributes")?.SetValue(instance, subType1);
+
+ // Create and set PropertyWithoutMemberAttributes
+ var subType2 = Activator.CreateInstance(type.Assembly.GetType("SubType")!);
+ subType2.GetType().GetProperty("RequiredProperty")?.SetValue(subType2, "valid");
+ subType2.GetType().GetProperty("StringWithLength")?.SetValue(subType2, "valid");
+ type.GetProperty("PropertyWithoutMemberAttributes")?.SetValue(instance, subType2);
+
+ // Create and set PropertyWithInheritance
+ var inheritanceType = Activator.CreateInstance(type.Assembly.GetType("SubTypeWithInheritance")!);
+ inheritanceType.GetType().GetProperty("RequiredProperty")?.SetValue(inheritanceType, "valid");
+ inheritanceType.GetType().GetProperty("StringWithLength")?.SetValue(inheritanceType, "valid");
+ inheritanceType.GetType().GetProperty("EmailString")?.SetValue(inheritanceType, "test@example.com");
+ type.GetProperty("PropertyWithInheritance")?.SetValue(instance, inheritanceType);
+
+ // Create empty list for ListOfSubTypes
+ var emptyList = Activator.CreateInstance(typeof(List<>).MakeGenericType(type.Assembly.GetType("SubType")!));
+ type.GetProperty("ListOfSubTypes")?.SetValue(instance, emptyList);
+
+ // Set custom validation attributes
+ type.GetProperty("IntegerWithCustomValidationAttribute")?.SetValue(instance, 2); // Even number should pass
+ type.GetProperty("PropertyWithMultipleAttributes")?.SetValue(instance, 12);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(instance)
+ };
+
+ await validatableInfo.ValidateAsync(instance, context, CancellationToken.None);
+
+ Assert.Null(context.ValidationErrors);
+ }
+ });
+ }
+}
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGeneratorTestBase.cs b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGeneratorTestBase.cs
new file mode 100644
index 000000000000..cd9affc4a8a4
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.ValidationsGenerator.Tests/ValidationsGeneratorTestBase.cs
@@ -0,0 +1,586 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.Loader;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Validation;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Emit;
+using Microsoft.CodeAnalysis.Text;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using static Microsoft.AspNetCore.Http.Generators.Tests.RequestDelegateCreationTestBase;
+
+namespace Microsoft.Extensions.Validation.ValidationsGenerator.Tests;
+
+[UsesVerify]
+public partial class ValidationsGeneratorTestBase : LoggedTestBase
+{
+ [GeneratedRegex(@"\[global::System\.Runtime\.CompilerServices\.InterceptsLocationAttribute\([^)]*\)\]")]
+ private static partial Regex InterceptsLocationRegex();
+
+ private static readonly CSharpParseOptions ParseOptions = new CSharpParseOptions(LanguageVersion.Preview)
+ .WithFeatures([new KeyValuePair("InterceptorsNamespaces", "Microsoft.Extensions.Validation.Generated")]);
+
+ internal static Task Verify(string source, out Compilation compilation)
+ {
+ var references = AppDomain.CurrentDomain.GetAssemblies()
+ .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location))
+ .Select(assembly => MetadataReference.CreateFromFile(assembly.Location))
+ .Concat(
+ [
+ MetadataReference.CreateFromFile(typeof(WebApplicationBuilder).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(EndpointRouteBuilderExtensions).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(IApplicationBuilder).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(Microsoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionProvider).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(Microsoft.AspNetCore.Mvc.ControllerBase).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(MvcCoreMvcBuilderExtensions).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(TypedResults).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.Text.Json.Nodes.JsonArray).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(Console).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(Uri).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.ComponentModel.DataAnnotations.ValidationAttribute).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(RouteData).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(IFeatureCollection).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(ValidateOptionsResult).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(IHttpMethodMetadata).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(IResult).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(HttpJsonServiceExtensions).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(IValidatableInfoResolver).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(EndpointFilterFactoryContext).Assembly.Location),
+ ]);
+ var inputCompilation = CSharpCompilation.Create("ValidationsGeneratorSample",
+ [CSharpSyntaxTree.ParseText(source, options: ParseOptions, path: "Program.cs")],
+ references,
+ new CSharpCompilationOptions(OutputKind.ConsoleApplication));
+ var generator = new ValidationsGenerator();
+ var driver = CSharpGeneratorDriver.Create(generators: [generator.AsSourceGenerator()], parseOptions: ParseOptions);
+ return Verifier
+ .Verify(driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out compilation, out var diagnostics))
+ .ScrubLinesWithReplace(line => InterceptsLocationRegex().Replace(line, "[InterceptsLocation]"))
+ .UseDirectory(SkipOnHelixAttribute.OnHelix()
+ ? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "ValidationsGenerator", "snapshots")
+ : "snapshots");
+ }
+
+ internal static void VerifyValidatableType(Compilation compilation, string typeName, Action verifyFunc)
+ {
+ if (TryResolveServicesFromCompilation(compilation, targetAssemblyName: "Microsoft.AspNetCore.Http.Abstractions", typeName: "Microsoft.Extensions.Validation.ValidationOptions", out var services, out var serviceType, out var outputAssemblyName) is false)
+ {
+ throw new InvalidOperationException("Could not resolve services from compilation.");
+ }
+ var targetAssembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(assembly => assembly.GetName().Name == outputAssemblyName);
+ var type = targetAssembly.GetType(typeName, throwOnError: false);
+
+ // Get IOptions first
+ var optionsType = typeof(IOptions<>).MakeGenericType(serviceType);
+ var optionsInstance = services.GetService(optionsType) ?? throw new InvalidOperationException("Could not resolve IOptions.");
+
+ // Then access the Value property
+ var valueProperty = optionsType.GetProperty("Value");
+ var service = (ValidationOptions)valueProperty.GetValue(optionsInstance) ?? throw new InvalidOperationException("Could not resolve ValidationOptions.");
+ verifyFunc(service, type);
+ }
+
+ internal static async Task VerifyEndpoint(Compilation compilation, string routePattern, Func verifyFunc)
+ {
+ if (TryResolveServicesFromCompilation(compilation, targetAssemblyName: "Microsoft.AspNetCore.Routing", typeName: "Microsoft.AspNetCore.Routing.EndpointDataSource", out var services, out var serviceType, out var outputAssemblyName) is false)
+ {
+ throw new InvalidOperationException("Could not resolve services from compilation.");
+ }
+ var service = services.GetService(serviceType) ?? throw new InvalidOperationException("Could not resolve EndpointDataSource.");
+ var endpoints = (IReadOnlyList)service.GetType().GetProperty("Endpoints", BindingFlags.Instance | BindingFlags.Public).GetValue(service);
+ var endpoint = endpoints.FirstOrDefault(endpoint => endpoint is RouteEndpoint routeEndpoint && routeEndpoint.RoutePattern.RawText == routePattern);
+ await verifyFunc(endpoint, services);
+ }
+
+ private static bool TryResolveServicesFromCompilation(Compilation compilation, string targetAssemblyName, string typeName, out IServiceProvider serviceProvider, out Type serviceType, out string outputAssemblyName)
+ {
+ serviceProvider = null;
+ serviceType = null;
+ outputAssemblyName = $"TestProject-{Guid.NewGuid()}";
+ var assemblyName = compilation.AssemblyName;
+ var symbolsName = Path.ChangeExtension(assemblyName, "pdb");
+
+ var output = new MemoryStream();
+ var pdb = new MemoryStream();
+
+ var emitOptions = new EmitOptions(
+ debugInformationFormat: DebugInformationFormat.PortablePdb,
+ pdbFilePath: symbolsName,
+ outputNameOverride: outputAssemblyName);
+
+ var embeddedTexts = new List();
+
+ foreach (var syntaxTree in compilation.SyntaxTrees)
+ {
+ var text = syntaxTree.GetText();
+ var encoding = text.Encoding ?? Encoding.UTF8;
+ var buffer = encoding.GetBytes(text.ToString());
+ var sourceText = SourceText.From(buffer, buffer.Length, encoding, canBeEmbedded: true);
+
+ var syntaxRootNode = (CSharpSyntaxNode)syntaxTree.GetRoot();
+ var newSyntaxTree = CSharpSyntaxTree.Create(syntaxRootNode, options: ParseOptions, encoding: encoding, path: syntaxTree.FilePath);
+
+ compilation = compilation.ReplaceSyntaxTree(syntaxTree, newSyntaxTree);
+
+ embeddedTexts.Add(EmbeddedText.FromSource(syntaxTree.FilePath, sourceText));
+ }
+
+ var result = compilation.Emit(output, pdb, options: emitOptions, embeddedTexts: embeddedTexts);
+
+ Assert.Empty(result.Diagnostics.Where(d => d.Severity > DiagnosticSeverity.Warning));
+ Assert.True(result.Success);
+
+ output.Position = 0;
+ pdb.Position = 0;
+
+ var assembly = AssemblyLoadContext.Default.LoadFromStream(output, pdb);
+
+ void ConfigureHostBuilder(object hostBuilder)
+ {
+ ((IHostBuilder)hostBuilder).ConfigureServices((context, services) =>
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ });
+ }
+
+ var waitForStartTcs = new TaskCompletionSource