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