diff --git a/src/Shared/RoslynUtils/WellKnownTypeData.cs b/src/Shared/RoslynUtils/WellKnownTypeData.cs
index 1afb045fc713..1549ad178eac 100644
--- a/src/Shared/RoslynUtils/WellKnownTypeData.cs
+++ b/src/Shared/RoslynUtils/WellKnownTypeData.cs
@@ -119,6 +119,7 @@ public enum WellKnownType
Microsoft_AspNetCore_Authorization_IAuthorizeData,
System_AttributeUsageAttribute,
System_Text_Json_Serialization_JsonDerivedTypeAttribute,
+ System_Text_Json_Serialization_JsonIgnoreAttribute,
System_ComponentModel_DataAnnotations_DisplayAttribute,
System_ComponentModel_DataAnnotations_ValidationAttribute,
System_ComponentModel_DataAnnotations_RequiredAttribute,
@@ -240,6 +241,7 @@ public enum WellKnownType
"Microsoft.AspNetCore.Authorization.IAuthorizeData",
"System.AttributeUsageAttribute",
"System.Text.Json.Serialization.JsonDerivedTypeAttribute",
+ "System.Text.Json.Serialization.JsonIgnoreAttribute",
"System.ComponentModel.DataAnnotations.DisplayAttribute",
"System.ComponentModel.DataAnnotations.ValidationAttribute",
"System.ComponentModel.DataAnnotations.RequiredAttribute",
diff --git a/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs
index 408ec7defb89..364ee29b0b40 100644
--- a/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs
+++ b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs
@@ -152,4 +152,16 @@ attr.AttributeClass is not null &&
(attr.AttributeClass.ImplementsInterface(fromServiceMetadataSymbol) ||
SymbolEqualityComparer.Default.Equals(attr.AttributeClass, fromKeyedServiceAttributeSymbol)));
}
+
+ ///
+ /// Checks if the property is marked with [JsonIgnore] attribute.
+ ///
+ /// The property to check.
+ /// The symbol representing the [JsonIgnore] attribute.
+ internal static bool IsJsonIgnoredProperty(this IPropertySymbol property, INamedTypeSymbol jsonIgnoreAttributeSymbol)
+ {
+ return property.GetAttributes().Any(attr =>
+ attr.AttributeClass is not null &&
+ SymbolEqualityComparer.Default.Equals(attr.AttributeClass, jsonIgnoreAttributeSymbol));
+ }
}
diff --git a/src/Validation/gen/Models/RequiredSymbols.cs b/src/Validation/gen/Models/RequiredSymbols.cs
index 51f8c92ccf9e..f6d48f40eb7a 100644
--- a/src/Validation/gen/Models/RequiredSymbols.cs
+++ b/src/Validation/gen/Models/RequiredSymbols.cs
@@ -11,6 +11,7 @@ internal sealed record class RequiredSymbols(
INamedTypeSymbol IEnumerable,
INamedTypeSymbol IValidatableObject,
INamedTypeSymbol JsonDerivedTypeAttribute,
+ INamedTypeSymbol JsonIgnoreAttribute,
INamedTypeSymbol RequiredAttribute,
INamedTypeSymbol CustomValidationAttribute,
INamedTypeSymbol HttpContext,
diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
index b0617d79b3a6..20e761deb12d 100644
--- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
+++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
@@ -114,6 +114,8 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb
WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromServiceMetadata);
var fromKeyedServiceAttributeSymbol = wellKnownTypes.Get(
WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_FromKeyedServicesAttribute);
+ var jsonIgnoreAttributeSymbol = wellKnownTypes.Get(
+ WellKnownTypeData.WellKnownType.System_Text_Json_Serialization_JsonIgnoreAttribute);
// Special handling for record types to extract properties from
// the primary constructor.
@@ -148,6 +150,12 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb
continue;
}
+ // Skip properties that have JsonIgnore attribute
+ if (correspondingProperty.IsJsonIgnoredProperty(jsonIgnoreAttributeSymbol))
+ {
+ continue;
+ }
+
// Check if the property's type is validatable, this resolves
// validatable types in the inheritance hierarchy
var hasValidatableType = TryExtractValidatableType(
@@ -186,6 +194,12 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb
continue;
}
+ // Skip properties that have JsonIgnore attribute
+ if (member.IsJsonIgnoredProperty(jsonIgnoreAttributeSymbol))
+ {
+ continue;
+ }
+
var hasValidatableType = TryExtractValidatableType(member.Type, wellKnownTypes, ref validatableTypes, ref visitedTypes);
var attributes = ExtractValidationAttributes(member, wellKnownTypes, out var isRequired);
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs
index 46158b90632d..0a5f023ba00b 100644
--- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs
+++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs
@@ -7,6 +7,153 @@ namespace Microsoft.Extensions.Validation.GeneratorTests;
public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase
{
+ [Fact]
+ public async Task CanValidateComplexTypesWithJsonIgnore()
+ {
+ // 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;
+using Microsoft.AspNetCore.Mvc;
+using System.Text.Json.Serialization;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services.AddValidation();
+
+var app = builder.Build();
+
+app.MapPost("/complex-type-with-json-ignore", (ComplexTypeWithJsonIgnore complexType) => Results.Ok("Passed"!));
+app.MapPost("/record-type-with-json-ignore", (RecordTypeWithJsonIgnore recordType) => Results.Ok("Passed"!));
+
+app.Run();
+
+public class ComplexTypeWithJsonIgnore
+{
+ [Range(10, 100)]
+ public int ValidatedProperty { get; set; } = 10;
+
+ [JsonIgnore]
+ [Required] // This should be ignored because of [JsonIgnore]
+ public string IgnoredProperty { get; set; } = null!;
+
+ [JsonIgnore]
+ public CircularReferenceType? CircularReference { get; set; }
+}
+
+public class CircularReferenceType
+{
+ [JsonIgnore]
+ public ComplexTypeWithJsonIgnore? Parent { get; set; }
+
+ public string Name { get; set; } = "test";
+}
+
+public record RecordTypeWithJsonIgnore
+{
+ [Range(10, 100)]
+ public int ValidatedProperty { get; set; } = 10;
+
+ [JsonIgnore]
+ [Required] // This should be ignored because of [JsonIgnore]
+ public string IgnoredProperty { get; set; } = null!;
+
+ [JsonIgnore]
+ public CircularReferenceRecord? CircularReference { get; set; }
+}
+
+public record CircularReferenceRecord
+{
+ [JsonIgnore]
+ public RecordTypeWithJsonIgnore? Parent { get; set; }
+
+ public string Name { get; set; } = "test";
+}
+""";
+ await Verify(source, out var compilation);
+ await VerifyEndpoint(compilation, "/complex-type-with-json-ignore", async (endpoint, serviceProvider) =>
+ {
+ await ValidInputWithJsonIgnoreProducesNoWarnings(endpoint);
+ await InvalidValidatedPropertyProducesError(endpoint);
+
+ async Task ValidInputWithJsonIgnoreProducesNoWarnings(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "ValidatedProperty": 50
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(200, context.Response.StatusCode);
+ }
+
+ async Task InvalidValidatedPropertyProducesError(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "ValidatedProperty": 5
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ var problemDetails = await AssertBadRequest(context);
+ Assert.Collection(problemDetails.Errors, kvp =>
+ {
+ Assert.Equal("ValidatedProperty", kvp.Key);
+ Assert.Equal("The field ValidatedProperty must be between 10 and 100.", kvp.Value.Single());
+ });
+ }
+ });
+
+ await VerifyEndpoint(compilation, "/record-type-with-json-ignore", async (endpoint, serviceProvider) =>
+ {
+ await ValidInputWithJsonIgnoreProducesNoWarningsForRecord(endpoint);
+ await InvalidValidatedPropertyProducesErrorForRecord(endpoint);
+
+ async Task ValidInputWithJsonIgnoreProducesNoWarningsForRecord(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "ValidatedProperty": 50
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+ await endpoint.RequestDelegate(context);
+
+ Assert.Equal(200, context.Response.StatusCode);
+ }
+
+ async Task InvalidValidatedPropertyProducesErrorForRecord(Endpoint endpoint)
+ {
+ var payload = """
+ {
+ "ValidatedProperty": 5
+ }
+ """;
+ var context = CreateHttpContextWithPayload(payload, serviceProvider);
+
+ await endpoint.RequestDelegate(context);
+
+ var problemDetails = await AssertBadRequest(context);
+ Assert.Collection(problemDetails.Errors, kvp =>
+ {
+ Assert.Equal("ValidatedProperty", kvp.Key);
+ Assert.Equal("The field ValidatedProperty must be between 10 and 100.", kvp.Value.Single());
+ });
+ }
+ });
+ }
[Fact]
public async Task CanValidateComplexTypes()
{
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs
new file mode 100644
index 000000000000..496844084845
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs
@@ -0,0 +1,179 @@
+//HintName: ValidatableInfoResolver.g.cs
+#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
+{
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : System.Attribute
+ {
+ public InterceptsLocationAttribute(int version, string data)
+ {
+ }
+ }
+}
+
+namespace Microsoft.Extensions.Validation.Generated
+{
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ 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.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+ 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 | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+ internal global::System.Type ContainingType { get; }
+ internal string Name { get; }
+
+ protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
+ => ValidationAttributeCache.GetValidationAttributes(ContainingType, Name);
+ }
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ 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) { }
+ }
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ 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;
+ if (type == typeof(global::ComplexTypeWithJsonIgnore))
+ {
+ validatableInfo = new GeneratedValidatableTypeInfo(
+ type: typeof(global::ComplexTypeWithJsonIgnore),
+ members: [
+ new GeneratedValidatablePropertyInfo(
+ containingType: typeof(global::ComplexTypeWithJsonIgnore),
+ propertyType: typeof(int),
+ name: "ValidatedProperty",
+ displayName: "ValidatedProperty"
+ ),
+ ]
+ );
+ return true;
+ }
+ if (type == typeof(global::RecordTypeWithJsonIgnore))
+ {
+ validatableInfo = new GeneratedValidatableTypeInfo(
+ type: typeof(global::RecordTypeWithJsonIgnore),
+ members: [
+ new GeneratedValidatablePropertyInfo(
+ containingType: typeof(global::RecordTypeWithJsonIgnore),
+ propertyType: typeof(int),
+ name: "ValidatedProperty",
+ displayName: "ValidatedProperty"
+ ),
+ ]
+ );
+ return true;
+ }
+
+ return false;
+ }
+
+ // No-ops, rely on runtime code for ParameterInfo-based resolution
+ public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo)
+ {
+ validatableInfo = null;
+ return false;
+ }
+ }
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class GeneratedServiceCollectionExtensions
+ {
+ [InterceptsLocation]
+ public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? configureOptions = null)
+ {
+ // Use non-extension method to avoid infinite recursion.
+ return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
+ {
+ options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver());
+ if (configureOptions is not null)
+ {
+ configureOptions(options);
+ }
+ });
+ }
+ }
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class ValidationAttributeCache
+ {
+ private sealed record CacheKey(
+ [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+ [property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+ 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.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+ 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();
+ });
+ }
+ }
+}
\ No newline at end of file