diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs new file mode 100644 index 00000000000..481e5f75753 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs @@ -0,0 +1,427 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +#if !NET +using System.Linq; +#endif +using System.Reflection; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace System.Text.Json.Schema; + +internal static partial class JsonSchemaExporter +{ + private static class ReflectionHelpers + { + private const BindingFlags AllInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private static PropertyInfo? _jsonTypeInfo_ElementType; + private static PropertyInfo? _jsonPropertyInfo_MemberName; + private static FieldInfo? _nullableConverter_ElementConverter_Generic; + private static FieldInfo? _enumConverter_Options_Generic; + private static FieldInfo? _enumConverter_NamingPolicy_Generic; + + public static bool IsBuiltInConverter(JsonConverter converter) => + converter.GetType().Assembly == typeof(JsonConverter).Assembly; + + public static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; + + public static Type GetElementType(JsonTypeInfo typeInfo) + { + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); + + // Uses reflection to access the element type encapsulated by a JsonTypeInfo. + if (_jsonTypeInfo_ElementType is null) + { + PropertyInfo? elementTypeProperty = typeof(JsonTypeInfo).GetProperty("ElementType", AllInstance); + _jsonTypeInfo_ElementType = Throw.IfNull(elementTypeProperty); + } + + return (Type)_jsonTypeInfo_ElementType.GetValue(typeInfo)!; + } + + public static string? GetMemberName(JsonPropertyInfo propertyInfo) + { + // Uses reflection to the member name encapsulated by a JsonPropertyInfo. + if (_jsonPropertyInfo_MemberName is null) + { + PropertyInfo? memberName = typeof(JsonPropertyInfo).GetProperty("MemberName", AllInstance); + _jsonPropertyInfo_MemberName = Throw.IfNull(memberName); + } + + return (string?)_jsonPropertyInfo_MemberName.GetValue(propertyInfo); + } + + public static JsonConverter GetElementConverter(JsonConverter nullableConverter) + { + // Uses reflection to access the element converter encapsulated by a nullable converter. + if (_nullableConverter_ElementConverter_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.NullableConverter`1, System.Text.Json")! + .GetField("_elementConverter", AllInstance); + + _nullableConverter_ElementConverter_Generic = Throw.IfNull(genericFieldInfo); + } + + Type converterType = nullableConverter.GetType(); + var thisFieldInfo = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_nullableConverter_ElementConverter_Generic); + return (JsonConverter)thisFieldInfo.GetValue(nullableConverter)!; + } + + public static void GetEnumConverterConfig(JsonConverter enumConverter, out JsonNamingPolicy? namingPolicy, out bool allowString) + { + // Uses reflection to access configuration encapsulated by an enum converter. + if (_enumConverter_Options_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! + .GetField("_converterOptions", AllInstance); + + _enumConverter_Options_Generic = Throw.IfNull(genericFieldInfo); + } + + if (_enumConverter_NamingPolicy_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! + .GetField("_namingPolicy", AllInstance); + + _enumConverter_NamingPolicy_Generic = Throw.IfNull(genericFieldInfo); + } + + const int EnumConverterOptionsAllowStrings = 1; + Type converterType = enumConverter.GetType(); + var converterOptionsField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_Options_Generic); + var namingPolicyField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_NamingPolicy_Generic); + + namingPolicy = (JsonNamingPolicy?)namingPolicyField.GetValue(enumConverter); + int converterOptions = (int)converterOptionsField.GetValue(enumConverter)!; + allowString = (converterOptions & EnumConverterOptionsAllowStrings) != 0; + } + + // The .NET 8 source generator doesn't populate attribute providers for properties + // cf. https://github.com/dotnet/runtime/issues/100095 + // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property + // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 + public static ICustomAttributeProvider? ResolveAttributeProvider( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | + DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] + Type? declaringType, + JsonPropertyInfo? propertyInfo) + { + if (declaringType is null || propertyInfo is null) + { + return null; + } + + if (propertyInfo.AttributeProvider is { } provider) + { + return provider; + } + + string? memberName = ReflectionHelpers.GetMemberName(propertyInfo); + if (memberName is not null) + { + return (MemberInfo?)declaringType.GetProperty(memberName, AllInstance) ?? + declaringType.GetField(memberName, AllInstance); + } + + return null; + } + + // Resolves the parameters of the deserialization constructor for a type, if they exist. + public static Func? ResolveJsonConstructorParameterMapper( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] + Type type, + JsonTypeInfo typeInfo) + { + Debug.Assert(type == typeInfo.Type, "The declaring type must match the typeInfo type."); + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); + + if (typeInfo.Properties.Count > 0 && + typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used + TryGetDeserializationConstructor(type, useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) + { + ParameterInfo[]? parameters = ctor?.GetParameters(); + if (parameters?.Length > 0) + { + Dictionary dict = new(parameters.Length); + foreach (ParameterInfo parameter in parameters) + { + if (parameter.Name is not null) + { + // We don't care about null parameter names or conflicts since they + // would have already been rejected by JsonTypeInfo exporterOptions. + dict[new(parameter.Name, parameter.ParameterType)] = parameter; + } + } + + return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; + } + } + + return null; + } + + // Resolves the nullable reference type annotations for a property or field, + // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. + public static NullabilityInfo GetMemberNullability(NullabilityInfoContext context, MemberInfo memberInfo) + { + Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); + return memberInfo is PropertyInfo prop + ? context.Create(prop) + : context.Create((FieldInfo)memberInfo); + } + + public static NullabilityState GetParameterNullability(NullabilityInfoContext context, ParameterInfo parameterInfo) + { +#if NET8_0 + // Workaround for https://github.com/dotnet/runtime/issues/92487 + // The fix has been incorporated into .NET 9 (and the polyfilled implementations in netfx). + // Should be removed once .NET 8 support is dropped. + if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) + { + // Step 1. Look for nullable annotations on the type parameter. + if (GetNullableFlags(typeParam) is byte[] flags) + { + return TranslateByte(flags[0]); + } + + // Step 2. Look for nullable annotations on the generic method declaration. + if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) + { + return TranslateByte(flag); + } + + // Step 3. Look for nullable annotations on the generic method declaration. + if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) + { + return TranslateByte(flag2); + } + + // Default to nullable. + return NullabilityState.Nullable; + + static byte[]? GetNullableFlags(MemberInfo member) + { + foreach (CustomAttributeData attr in member.GetCustomAttributesData()) + { + Type attrType = attr.AttributeType; + if (attrType.Name == "NullableAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") + { + foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) + { + switch (ctorArg.Value) + { + case byte flag: + return [flag]; + case byte[] flags: + return flags; + } + } + } + } + + return null; + } + + static byte? GetNullableContextFlag(MemberInfo member) + { + foreach (CustomAttributeData attr in member.GetCustomAttributesData()) + { + Type attrType = attr.AttributeType; + if (attrType.Name == "NullableContextAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") + { + foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) + { + if (ctorArg.Value is byte flag) + { + return flag; + } + } + } + } + + return null; + } + +#pragma warning disable S109 // Magic numbers should not be used + static NullabilityState TranslateByte(byte b) => b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; +#pragma warning restore S109 // Magic numbers should not be used + } + + static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) + { + if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } + or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) + { + var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); + return genericMethod.GetParameters()[parameter.Position]; + } + + return parameter; + } + + static MemberInfo GetGenericMemberDefinition(MemberInfo member) + { + if (member is Type type) + { + return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; + } + + if (member.DeclaringType?.IsConstructedGenericType is true) + { + return member.DeclaringType.GetGenericTypeDefinition().GetMemberWithSameMetadataDefinitionAs(member); + } + + if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) + { + return method.GetGenericMethodDefinition(); + } + + return member; + } +#endif + return context.Create(parameterInfo).WriteState; + } + + // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 + public static object? GetNormalizedDefaultValue(ParameterInfo parameterInfo) + { + Type parameterType = parameterInfo.ParameterType; + object? defaultValue = parameterInfo.DefaultValue; + + if (defaultValue is null) + { + return null; + } + + // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. + if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) + { + return null; + } + + // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly + // cf. https://github.com/dotnet/runtime/issues/68647 + if (parameterType.IsEnum) + { + return Enum.ToObject(parameterType, defaultValue); + } + + if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) + { + return Enum.ToObject(underlyingType, defaultValue); + } + + return defaultValue; + } + + // Resolves the deserialization constructor for a type using logic copied from + // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 + private static bool TryGetDeserializationConstructor( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] + Type type, + bool useDefaultCtorInAnnotatedStructs, + out ConstructorInfo? deserializationCtor) + { + ConstructorInfo? ctorWithAttribute = null; + ConstructorInfo? publicParameterlessCtor = null; + ConstructorInfo? lonePublicCtor = null; + + ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (constructors.Length == 1) + { + lonePublicCtor = constructors[0]; + } + + foreach (ConstructorInfo constructor in constructors) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + else if (constructor.GetParameters().Length == 0) + { + publicParameterlessCtor = constructor; + } + } + + // Search for non-public ctors with [JsonConstructor]. + foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + } + + // Structs will use default constructor if attribute isn't used. + if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) + { + deserializationCtor = null; + return true; + } + + deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; + return true; + + static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => + constructorInfo.GetCustomAttribute() != null; + } + + // Parameter to property matching semantics as declared in + // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 + private readonly struct ParameterLookupKey : IEquatable + { + public ParameterLookupKey(string name, Type type) + { + Name = name; + Type = type; + } + + public string Name { get; } + public Type Type { get; } + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); + public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); + public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); + } + } + +#if !NET + private static MemberInfo GetMemberWithSameMetadataDefinitionAs(this Type specializedType, MemberInfo member) + { + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + return specializedType.GetMember(member.Name, member.MemberType, All).First(m => m.MetadataToken == member.MetadataToken); + } +#endif +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs index 9c4b83f8343..5c6ce6d9ab7 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -16,14 +16,9 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.Shared.Diagnostics; -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance #pragma warning disable S107 // Methods should not have too many parameters -#pragma warning disable S103 // Lines should not be too long #pragma warning disable S1121 // Assignments should not be made from within sub-expressions -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S3358 // Ternary operators should not be nested -#pragma warning disable EA0004 // Make type internal since project is executable namespace System.Text.Json.Schema; @@ -121,7 +116,7 @@ private static JsonSchema MapJsonSchemaCore( JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; - if (!IsBuiltInConverter(effectiveConverter)) + if (!ReflectionHelpers.IsBuiltInConverter(effectiveConverter)) { // Return a `true` schema for types with user-defined converters. return CompleteSchema(ref state, JsonSchema.True); @@ -263,7 +258,8 @@ private static JsonSchema MapJsonSchemaCore( } } - Func? parameterInfoMapper = ResolveJsonConstructorParameterMapper(typeInfo); + Func? parameterInfoMapper = + ReflectionHelpers.ResolveJsonConstructorParameterMapper(typeInfo.Type, typeInfo); state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); foreach (JsonPropertyInfo property in typeInfo.Properties) @@ -277,13 +273,13 @@ private static JsonSchema MapJsonSchemaCore( JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); // Resolve the attribute provider for the property. - ICustomAttributeProvider? attributeProvider = ResolveAttributeProvider(typeInfo.Type, property); + ICustomAttributeProvider? attributeProvider = ReflectionHelpers.ResolveAttributeProvider(typeInfo.Type, property); // Declare the property as nullable if either getter or setter are nullable. bool isNonNullableProperty = false; if (attributeProvider is MemberInfo memberInfo) { - NullabilityInfo nullabilityInfo = state.NullabilityInfoContext.GetMemberNullability(memberInfo); + NullabilityInfo nullabilityInfo = ReflectionHelpers.GetMemberNullability(state.NullabilityInfoContext, memberInfo); isNonNullableProperty = (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); @@ -347,7 +343,7 @@ private static JsonSchema MapJsonSchemaCore( }); case JsonTypeInfoKind.Enumerable: - Type elementType = GetElementType(typeInfo); + Type elementType = ReflectionHelpers.GetElementType(typeInfo); JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); if (typeDiscriminator is null) @@ -398,7 +394,7 @@ private static JsonSchema MapJsonSchemaCore( } case JsonTypeInfoKind.Dictionary: - Type valueType = GetElementType(typeInfo); + Type valueType = ReflectionHelpers.GetElementType(typeInfo); JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); List>? dictProps = null; @@ -449,17 +445,28 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) { if (schema.Ref is null) { - // A schema is marked as nullable if either - // 1. We have a schema for a property where either the getter or setter are marked as nullable. - // 2. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable. - bool isNullableSchema = (propertyInfo != null || parameterInfo != null) - ? !isNonNullableType - : CanBeNull(typeInfo.Type) && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable; - - if (isNullableSchema) + if (IsNullableSchema(ref state)) { schema.MakeNullable(); } + + bool IsNullableSchema(ref GenerationState state) + { + // A schema is marked as nullable if either + // 1. We have a schema for a property where either the getter or setter are marked as nullable. + // 2. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable + + if (propertyInfo != null || parameterInfo != null) + { + return !isNonNullableType; + } + else + { + return ReflectionHelpers.CanBeNull(typeInfo.Type) && + !parentPolymorphicTypeIsNonNullable && + !state.ExporterOptions.TreatNullObliviousAsNonNullable; + } + } } if (state.ExporterOptions.TransformSchemaNode != null) @@ -636,11 +643,18 @@ private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, Jso if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) { - pattern = schemaType is JsonSchemaType.Integer - ? @"^-?(?:0|[1-9]\d*)$" - : isIeeeFloatingPoint - ? @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$" - : @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + if (schemaType is JsonSchemaType.Integer) + { + pattern = @"^-?(?:0|[1-9]\d*)$"; + } + else if (isIeeeFloatingPoint) + { + pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$"; + } + else + { + pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + } schemaType |= JsonSchemaType.String; } @@ -660,62 +674,16 @@ private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, Jso return new JsonSchema { Type = schemaType, Pattern = pattern }; } - // Uses reflection to determine the element type of an enumerable or dictionary type - // Workaround for https://github.com/dotnet/runtime/issues/77306#issuecomment-2007887560 - private static Type GetElementType(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); - _elementTypeProperty ??= typeof(JsonTypeInfo).GetProperty("ElementType", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - return (Type)_elementTypeProperty?.GetValue(typeInfo)!; - } - - private static PropertyInfo? _elementTypeProperty; - - // The .NET 8 source generator doesn't populate attribute providers for properties - // cf. https://github.com/dotnet/runtime/issues/100095 - // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property - // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static ICustomAttributeProvider? ResolveAttributeProvider(Type? declaringType, JsonPropertyInfo? propertyInfo) - { - if (declaringType is null || propertyInfo is null) - { - return null; - } - - if (propertyInfo.AttributeProvider is { } provider) - { - return provider; - } - - _memberNameProperty ??= typeof(JsonPropertyInfo).GetProperty("MemberName", BindingFlags.Instance | BindingFlags.NonPublic)!; - var memberName = (string?)_memberNameProperty.GetValue(propertyInfo); - if (memberName is not null) - { - return declaringType.GetMember(memberName, MemberTypes.Property | MemberTypes.Field, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).FirstOrDefault(); - } - - return null; - } - - private static PropertyInfo? _memberNameProperty; - - // Uses reflection to determine any custom converters specified for the element of a nullable type. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) { - Debug.Assert(converter is null || IsBuiltInConverter(converter), "If specified the converter must be built-in."); + Debug.Assert(converter is null || ReflectionHelpers.IsBuiltInConverter(converter), "If specified the converter must be built-in."); - // There is unfortunately no way in which we can obtain the element converter from a nullable converter without resorting to private reflection - // https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/NullableConverter.cs#L15-L17 - Type? converterType = converter?.GetType(); - if (converterType?.Name == "NullableConverter`1") + if (converter is null) { - FieldInfo elementConverterField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_elementConverter"); - return (JsonConverter)elementConverterField!.GetValue(converter)!; + return null; } - return null; + return ReflectionHelpers.GetElementConverter(converter); } private static void ValidateOptions(JsonSerializerOptions options) @@ -740,12 +708,12 @@ private static void ResolveParameterInfo( Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); // Incorporate the nullability information from the parameter. - isNonNullable = nullabilityInfoContext.GetParameterNullability(parameter) is NullabilityState.NotNull; + isNonNullable = ReflectionHelpers.GetParameterNullability(nullabilityInfoContext, parameter) is NullabilityState.NotNull; if (parameter.HasDefaultValue) { // Append the default value to the description. - object? defaultVal = parameter.GetNormalizedDefaultValue(); + object? defaultVal = ReflectionHelpers.GetNormalizedDefaultValue(parameter); defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); hasDefaultValue = true; } @@ -758,25 +726,19 @@ private static void ResolveParameterInfo( } } - // Uses reflection to determine schema for enum types // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) { - Debug.Assert(typeInfo.Type.IsEnum && IsBuiltInConverter(converter), "must be using a built-in enum converter."); + Debug.Assert(typeInfo.Type.IsEnum && ReflectionHelpers.IsBuiltInConverter(converter), "must be using a built-in enum converter."); if (converter is JsonConverterFactory factory) { converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; } - Type converterType = converter.GetType(); - FieldInfo converterOptionsField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_converterOptions"); - FieldInfo namingPolicyField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_namingPolicy"); + ReflectionHelpers.GetEnumConverterConfig(converter, out JsonNamingPolicy? namingPolicy, out bool allowString); - const int EnumConverterOptionsAllowStrings = 1; - var converterOptions = (int)converterOptionsField!.GetValue(converter)!; - if ((converterOptions & EnumConverterOptionsAllowStrings) != 0) + if (allowString) { // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. @@ -787,7 +749,6 @@ private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConv return new() { Type = JsonSchemaType.String }; } - var namingPolicy = (JsonNamingPolicy?)namingPolicyField!.GetValue(converter)!; JsonArray enumValues = new(); foreach (string name in Enum.GetNames(typeInfo.Type)) { @@ -803,290 +764,6 @@ private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConv return new() { Type = JsonSchemaType.Integer }; } - private static NullabilityState GetParameterNullability(this NullabilityInfoContext context, ParameterInfo parameterInfo) - { -#if !NET9_0_OR_GREATER - // Workaround for https://github.com/dotnet/runtime/issues/92487 - if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) - { - // Step 1. Look for nullable annotations on the type parameter. - if (GetNullableFlags(typeParam) is byte[] flags) - { - return TranslateByte(flags[0]); - } - - // Step 2. Look for nullable annotations on the generic method declaration. - if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) - { - return TranslateByte(flag); - } - - // Step 3. Look for nullable annotations on the generic method declaration. - if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) - { - return TranslateByte(flag2); - } - - // Default to nullable. - return NullabilityState.Nullable; - -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] -#endif - static byte[]? GetNullableFlags(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableAttribute"; - }); - - return (byte[])attr?.GetType().GetField("NullableFlags")?.GetValue(attr)!; - } - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] - static byte? GetNullableContextFlag(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableContextAttribute"; - }); - - return (byte?)attr?.GetType().GetField("Flag")?.GetValue(attr)!; - } - -#pragma warning disable S109 // Magic numbers should not be used - static NullabilityState TranslateByte(byte b) => b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; -#pragma warning restore S109 // Magic numbers should not be used - } - - static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) - { - if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } - or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) - { - var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); - return genericMethod.GetParameters()[parameter.Position]; - } - - return parameter; - } - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "Looking up the generic member definition of the provided member.")] - static MemberInfo GetGenericMemberDefinition(MemberInfo member) - { - if (member is Type type) - { - return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; - } - - if (member.DeclaringType!.IsConstructedGenericType) - { - const BindingFlags AllMemberFlags = - BindingFlags.Static | BindingFlags.Instance | - BindingFlags.Public | BindingFlags.NonPublic; - - return member.DeclaringType.GetGenericTypeDefinition() - .GetMember(member.Name, AllMemberFlags) - .First(m => m.MetadataToken == member.MetadataToken); - } - - if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) - { - return method.GetGenericMethodDefinition(); - } - - return member; - } -#endif - return context.Create(parameterInfo).WriteState; - } - - // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 - private static object? GetNormalizedDefaultValue(this ParameterInfo parameterInfo) - { - Type parameterType = parameterInfo.ParameterType; - object? defaultValue = parameterInfo.DefaultValue; - - if (defaultValue is null) - { - return null; - } - - // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. - if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) - { - return null; - } - - // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly - // cf. https://github.com/dotnet/runtime/issues/68647 - if (parameterType.IsEnum) - { - return Enum.ToObject(parameterType, defaultValue); - } - - if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) - { - return Enum.ToObject(underlyingType, defaultValue); - } - - return defaultValue; - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static FieldInfo GetPrivateFieldWithPotentiallyTrimmedMetadata(this Type type, string fieldName) - { - FieldInfo? field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); - if (field is null) - { - throw new InvalidOperationException( - $"Could not resolve metadata for field '{fieldName}' in type '{type}'. " + - "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."); - } - - return field; - } - - // Resolves the parameters of the deserialization constructor for a type, if they exist. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static Func? ResolveJsonConstructorParameterMapper(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); - - if (typeInfo.Properties.Count > 0 && - typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used - typeInfo.Type.TryGetDeserializationConstructor(useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) - { - ParameterInfo[]? parameters = ctor?.GetParameters(); - if (parameters?.Length > 0) - { - Dictionary dict = new(parameters.Length); - foreach (ParameterInfo parameter in parameters) - { - if (parameter.Name is not null) - { - // We don't care about null parameter names or conflicts since they - // would have already been rejected by JsonTypeInfo exporterOptions. - dict[new(parameter.Name, parameter.ParameterType)] = parameter; - } - } - - return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; - } - } - - return null; - } - - // Parameter to property matching semantics as declared in - // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 - private readonly struct ParameterLookupKey : IEquatable - { - public ParameterLookupKey(string name, Type type) - { - Name = name; - Type = type; - } - - public string Name { get; } - public Type Type { get; } - - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); - public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); - } - - // Resolves the deserialization constructor for a type using logic copied from - // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static bool TryGetDeserializationConstructor( - this Type type, - bool useDefaultCtorInAnnotatedStructs, - out ConstructorInfo? deserializationCtor) - { - ConstructorInfo? ctorWithAttribute = null; - ConstructorInfo? publicParameterlessCtor = null; - ConstructorInfo? lonePublicCtor = null; - - ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); - - if (constructors.Length == 1) - { - lonePublicCtor = constructors[0]; - } - - foreach (ConstructorInfo constructor in constructors) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - else if (constructor.GetParameters().Length == 0) - { - publicParameterlessCtor = constructor; - } - } - - // Search for non-public ctors with [JsonConstructor]. - foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - } - - // Structs will use default constructor if attribute isn't used. - if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) - { - deserializationCtor = null; - return true; - } - - deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; - return true; - - static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => - constructorInfo.GetCustomAttribute() != null; - } - - private static bool IsBuiltInConverter(JsonConverter converter) => - converter.GetType().Assembly == typeof(JsonConverter).Assembly; - - // Resolves the nullable reference type annotations for a property or field, - // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. - private static NullabilityInfo GetMemberNullability(this NullabilityInfoContext context, MemberInfo memberInfo) - { - Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); - return memberInfo is PropertyInfo prop - ? context.Create(prop) - : context.Create((FieldInfo)memberInfo); - } - - private static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; - private static class JsonSchemaConstants { public const string SchemaPropertyName = "$schema"; @@ -1116,10 +793,6 @@ private static class ThrowHelpers public static void ThrowInvalidOperationException_MaxDepthReached() => throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); - [DoesNotReturn] - public static void ThrowInvalidOperationException_TrimmedMethodParameters(MethodBase method) => - throw new InvalidOperationException($"The parameters for method '{method}' have been trimmed away."); - [DoesNotReturn] public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index 58ec4eda535..439c3788557 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -17,6 +17,7 @@ true true true + true diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs index 93207a7167f..2ec81987dc2 100644 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -15,7 +15,6 @@ using Xunit; #pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable xUnit1000 // Test classes must be public namespace Microsoft.Extensions.AI.JsonSchemaExporter; diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index f8c54fdb178..d21a40640dd 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -27,7 +27,6 @@ #pragma warning disable CA1052 // Static holder types should be Static or NotInheritable #pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable IDE0073 // The file header is missing or not located at the top of the file -#pragma warning disable SA1402 // File may only contain a single type namespace Microsoft.Extensions.AI.JsonSchemaExporter; @@ -511,7 +510,6 @@ of the type which points to the first occurrence. */ } """); -#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/107545 gets backported // Global JsonUnmappedMemberHandling.Disallow setting yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -530,7 +528,6 @@ of the type which points to the first occurrence. */ } """, Options: new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }); -#endif yield return new TestData( Value: new() { MaybeNull = null!, AllowNull = null, NotNull = null, DisallowNull = null!, NotNullDisallowNull = "str" },