diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 2cfe7b73964..842951ab867 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -43,6 +43,10 @@ + + + + diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 2bde3b34e05..78772d87d09 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -20,6 +20,8 @@ + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index bb1a3b63708..30d5cd84425 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -19,6 +19,7 @@ + true true true true diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs similarity index 85% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index eb8f0d52a07..cd33a2557af 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -5,6 +5,9 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; +#if !NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -16,6 +19,7 @@ #pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable S107 // Methods should not have too many parameters #pragma warning disable S1075 // URIs should not be hardcoded +#pragma warning disable SA1118 // Parameter should not span multiple lines using FunctionParameterKey = ( System.Type? Type, @@ -174,6 +178,11 @@ private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, Fu #endif } +#if !NET9_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", + Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + + "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] +#endif private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, FunctionParameterKey key) { _ = Throw.IfNull(options); @@ -236,16 +245,9 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) const string DefaultPropertyName = "default"; const string RefPropertyName = "$ref"; - // Find the first DescriptionAttribute, starting first from the property, then the parameter, and finally the type itself. - Type descAttrType = typeof(DescriptionAttribute); - var descriptionAttribute = - GetAttrs(descAttrType, ctx.PropertyInfo?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.TypeInfo.Type)?.FirstOrDefault(); - - if (descriptionAttribute is DescriptionAttribute attr) + if (ctx.ResolveAttribute() is { } attr) { - ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)attr.Description); + ConvertSchemaToObject(ref schema).InsertAtStart(DescriptionPropertyName, (JsonNode)attr.Description); } if (schema is JsonObject objSchema) @@ -268,7 +270,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) // Include the type keyword in enum types if (key.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) { - objSchema.Insert(0, TypePropertyName, "string"); + objSchema.InsertAtStart(TypePropertyName, "string"); } // Disallow additional properties in object schemas @@ -303,7 +305,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) if (index < 0) { // If there's no description property, insert it at the beginning of the doc. - obj.Insert(0, DescriptionPropertyName, (JsonNode)key.Description!); + obj.InsertAtStart(DescriptionPropertyName, (JsonNode)key.Description!); } else { @@ -321,15 +323,12 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) if (key.IncludeSchemaUri) { // The $schema property must be the first keyword in the object - ConvertSchemaToObject(ref schema).Insert(0, SchemaPropertyName, (JsonNode)SchemaKeywordUri); + ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } } return schema; - static object[]? GetAttrs(Type attrType, ICustomAttributeProvider? provider) => - provider?.GetCustomAttributes(attrType, inherit: false); - static JsonObject ConvertSchemaToObject(ref JsonNode schema) { JsonObject obj; @@ -368,6 +367,62 @@ private static bool TypeIsArrayContainingInteger(JsonObject schema) return false; } + private static void InsertAtStart(this JsonObject jsonObject, string key, JsonNode value) + { +#if NET9_0_OR_GREATER + jsonObject.Insert(0, key, value); +#else + jsonObject.Remove(key); + var copiedEntries = jsonObject.ToArray(); + jsonObject.Clear(); + + jsonObject.Add(key, value); + foreach (var entry in copiedEntries) + { + jsonObject[entry.Key] = entry.Value; + } +#endif + } + +#if !NET9_0_OR_GREATER + private static int IndexOf(this JsonObject jsonObject, string key) + { + int i = 0; + foreach (var entry in jsonObject) + { + if (string.Equals(entry.Key, key, StringComparison.Ordinal)) + { + return i; + } + + i++; + } + + return -1; + } +#endif + + private static TAttribute? ResolveAttribute(this JsonSchemaExporterContext ctx) + where TAttribute : Attribute + { + // Resolve attributes from locations in the following order: + // 1. Property-level attributes + // 2. Parameter-level attributes and + // 3. Type-level attributes. + return +#if NET9_0_OR_GREATER + GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? + GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? +#else + GetAttrs(ctx.PropertyAttributeProvider) ?? + GetAttrs(ctx.ParameterInfo) ?? +#endif + GetAttrs(ctx.TypeInfo.Type); + + static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => + (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); + } + private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) { Utf8JsonReader reader = new(utf8Json); diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs new file mode 100644 index 00000000000..0f1044fc6eb --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs @@ -0,0 +1,545 @@ +// 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.Text.Json.Nodes; + +namespace System.Text.Json.Schema; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S1144 // Unused private types or members should be removed + +internal static partial class JsonSchemaExporter +{ + // Simple JSON schema representation taken from System.Text.Json + // https://github.com/dotnet/runtime/blob/50d6cad649aad2bfa4069268eddd16fd51ec5cf3/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchema.cs + private sealed class JsonSchema + { + public static JsonSchema False { get; } = new(false); + public static JsonSchema True { get; } = new(true); + + public JsonSchema() + { + } + + private JsonSchema(bool trueOrFalse) + { + _trueOrFalse = trueOrFalse; + } + + public bool IsTrue => _trueOrFalse is true; + public bool IsFalse => _trueOrFalse is false; + private readonly bool? _trueOrFalse; + + public string? Schema + { + get => _schema; + set + { + VerifyMutable(); + _schema = value; + } + } + + private string? _schema; + + public string? Title + { + get => _title; + set + { + VerifyMutable(); + _title = value; + } + } + + private string? _title; + + public string? Description + { + get => _description; + set + { + VerifyMutable(); + _description = value; + } + } + + private string? _description; + + public string? Ref + { + get => _ref; + set + { + VerifyMutable(); + _ref = value; + } + } + + private string? _ref; + + public string? Comment + { + get => _comment; + set + { + VerifyMutable(); + _comment = value; + } + } + + private string? _comment; + + public JsonSchemaType Type + { + get => _type; + set + { + VerifyMutable(); + _type = value; + } + } + + private JsonSchemaType _type = JsonSchemaType.Any; + + public string? Format + { + get => _format; + set + { + VerifyMutable(); + _format = value; + } + } + + private string? _format; + + public string? Pattern + { + get => _pattern; + set + { + VerifyMutable(); + _pattern = value; + } + } + + private string? _pattern; + + public JsonNode? Constant + { + get => _constant; + set + { + VerifyMutable(); + _constant = value; + } + } + + private JsonNode? _constant; + + public List>? Properties + { + get => _properties; + set + { + VerifyMutable(); + _properties = value; + } + } + + private List>? _properties; + + public List? Required + { + get => _required; + set + { + VerifyMutable(); + _required = value; + } + } + + private List? _required; + + public JsonSchema? Items + { + get => _items; + set + { + VerifyMutable(); + _items = value; + } + } + + private JsonSchema? _items; + + public JsonSchema? AdditionalProperties + { + get => _additionalProperties; + set + { + VerifyMutable(); + _additionalProperties = value; + } + } + + private JsonSchema? _additionalProperties; + + public JsonArray? Enum + { + get => _enum; + set + { + VerifyMutable(); + _enum = value; + } + } + + private JsonArray? _enum; + + public JsonSchema? Not + { + get => _not; + set + { + VerifyMutable(); + _not = value; + } + } + + private JsonSchema? _not; + + public List? AnyOf + { + get => _anyOf; + set + { + VerifyMutable(); + _anyOf = value; + } + } + + private List? _anyOf; + + public bool HasDefaultValue + { + get => _hasDefaultValue; + set + { + VerifyMutable(); + _hasDefaultValue = value; + } + } + + private bool _hasDefaultValue; + + public JsonNode? DefaultValue + { + get => _defaultValue; + set + { + VerifyMutable(); + _defaultValue = value; + } + } + + private JsonNode? _defaultValue; + + public int? MinLength + { + get => _minLength; + set + { + VerifyMutable(); + _minLength = value; + } + } + + private int? _minLength; + + public int? MaxLength + { + get => _maxLength; + set + { + VerifyMutable(); + _maxLength = value; + } + } + + private int? _maxLength; + + public JsonSchemaExporterContext? GenerationContext { get; set; } + + public int KeywordCount + { + get + { + if (_trueOrFalse != null) + { + return 0; + } + + int count = 0; + Count(Schema != null); + Count(Ref != null); + Count(Comment != null); + Count(Title != null); + Count(Description != null); + Count(Type != JsonSchemaType.Any); + Count(Format != null); + Count(Pattern != null); + Count(Constant != null); + Count(Properties != null); + Count(Required != null); + Count(Items != null); + Count(AdditionalProperties != null); + Count(Enum != null); + Count(Not != null); + Count(AnyOf != null); + Count(HasDefaultValue); + Count(MinLength != null); + Count(MaxLength != null); + + return count; + + void Count(bool isKeywordSpecified) => count += isKeywordSpecified ? 1 : 0; + } + } + + public void MakeNullable() + { + if (_trueOrFalse != null) + { + return; + } + + if (Type != JsonSchemaType.Any) + { + Type |= JsonSchemaType.Null; + } + } + + public JsonNode ToJsonNode(JsonSchemaExporterOptions options) + { + if (_trueOrFalse is { } boolSchema) + { + return CompleteSchema((JsonNode)boolSchema); + } + + var objSchema = new JsonObject(); + + if (Schema != null) + { + objSchema.Add(JsonSchemaConstants.SchemaPropertyName, Schema); + } + + if (Title != null) + { + objSchema.Add(JsonSchemaConstants.TitlePropertyName, Title); + } + + if (Description != null) + { + objSchema.Add(JsonSchemaConstants.DescriptionPropertyName, Description); + } + + if (Ref != null) + { + objSchema.Add(JsonSchemaConstants.RefPropertyName, Ref); + } + + if (Comment != null) + { + objSchema.Add(JsonSchemaConstants.CommentPropertyName, Comment); + } + + if (MapSchemaType(Type) is JsonNode type) + { + objSchema.Add(JsonSchemaConstants.TypePropertyName, type); + } + + if (Format != null) + { + objSchema.Add(JsonSchemaConstants.FormatPropertyName, Format); + } + + if (Pattern != null) + { + objSchema.Add(JsonSchemaConstants.PatternPropertyName, Pattern); + } + + if (Constant != null) + { + objSchema.Add(JsonSchemaConstants.ConstPropertyName, Constant); + } + + if (Properties != null) + { + var properties = new JsonObject(); + foreach (KeyValuePair property in Properties) + { + properties.Add(property.Key, property.Value.ToJsonNode(options)); + } + + objSchema.Add(JsonSchemaConstants.PropertiesPropertyName, properties); + } + + if (Required != null) + { + var requiredArray = new JsonArray(); + foreach (string requiredProperty in Required) + { + requiredArray.Add((JsonNode)requiredProperty); + } + + objSchema.Add(JsonSchemaConstants.RequiredPropertyName, requiredArray); + } + + if (Items != null) + { + objSchema.Add(JsonSchemaConstants.ItemsPropertyName, Items.ToJsonNode(options)); + } + + if (AdditionalProperties != null) + { + objSchema.Add(JsonSchemaConstants.AdditionalPropertiesPropertyName, AdditionalProperties.ToJsonNode(options)); + } + + if (Enum != null) + { + objSchema.Add(JsonSchemaConstants.EnumPropertyName, Enum); + } + + if (Not != null) + { + objSchema.Add(JsonSchemaConstants.NotPropertyName, Not.ToJsonNode(options)); + } + + if (AnyOf != null) + { + JsonArray anyOfArray = new(); + foreach (JsonSchema schema in AnyOf) + { + anyOfArray.Add(schema.ToJsonNode(options)); + } + + objSchema.Add(JsonSchemaConstants.AnyOfPropertyName, anyOfArray); + } + + if (HasDefaultValue) + { + objSchema.Add(JsonSchemaConstants.DefaultPropertyName, DefaultValue); + } + + if (MinLength is int minLength) + { + objSchema.Add(JsonSchemaConstants.MinLengthPropertyName, (JsonNode)minLength); + } + + if (MaxLength is int maxLength) + { + objSchema.Add(JsonSchemaConstants.MaxLengthPropertyName, (JsonNode)maxLength); + } + + return CompleteSchema(objSchema); + + JsonNode CompleteSchema(JsonNode schema) + { + if (GenerationContext is { } context) + { + Debug.Assert(options.TransformSchemaNode != null, "context should only be populated if a callback is present."); + + // Apply any user-defined transformations to the schema. + return options.TransformSchemaNode!(context, schema); + } + + return schema; + } + } + + public static void EnsureMutable(ref JsonSchema schema) + { + switch (schema._trueOrFalse) + { + case false: + schema = new JsonSchema { Not = JsonSchema.True }; + break; + case true: + schema = new JsonSchema(); + break; + } + } + + private static readonly JsonSchemaType[] _schemaValues = new JsonSchemaType[] + { + // NB the order of these values influences order of types in the rendered schema + JsonSchemaType.String, + JsonSchemaType.Integer, + JsonSchemaType.Number, + JsonSchemaType.Boolean, + JsonSchemaType.Array, + JsonSchemaType.Object, + JsonSchemaType.Null, + }; + + private void VerifyMutable() + { + Debug.Assert(_trueOrFalse is null, "Schema is not mutable"); + } + + private static JsonNode? MapSchemaType(JsonSchemaType schemaType) + { + if (schemaType is JsonSchemaType.Any) + { + return null; + } + + if (ToIdentifier(schemaType) is string identifier) + { + return identifier; + } + + var array = new JsonArray(); + foreach (JsonSchemaType type in _schemaValues) + { + if ((schemaType & type) != 0) + { + array.Add((JsonNode)ToIdentifier(type)!); + } + } + + return array; + + static string? ToIdentifier(JsonSchemaType schemaType) => schemaType switch + { + JsonSchemaType.Null => "null", + JsonSchemaType.Boolean => "boolean", + JsonSchemaType.Integer => "integer", + JsonSchemaType.Number => "number", + JsonSchemaType.String => "string", + JsonSchemaType.Array => "array", + JsonSchemaType.Object => "object", + _ => null, + }; + } + } + + [Flags] + private enum JsonSchemaType + { + Any = 0, // No type declared on the schema + Null = 1, + Boolean = 2, + Integer = 4, + Number = 8, + String = 16, + Array = 32, + Object = 64, + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs new file mode 100644 index 00000000000..9c4b83f8343 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -0,0 +1,1128 @@ +// 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; +using System.Globalization; +using System.Linq; +using System.Reflection; +#if NET +using System.Runtime.InteropServices; +#endif +using System.Text.Json.Nodes; +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 +#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; + +/// +/// Maps .NET types to JSON schema objects using contract metadata from instances. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal static partial class JsonSchemaExporter +{ + // Polyfill implementation of JsonSchemaExporter for System.Text.Json version 8.0.0. + // Uses private reflection to access metadata not available with the older APIs of STJ. + + private const string RequiresUnreferencedCodeMessage = + "Uses private reflection on System.Text.Json components to access converter metadata. " + + "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."; + + /// + /// Generates a JSON schema corresponding to the contract metadata of the specified type. + /// + /// The options instance from which to resolve the contract metadata. + /// The root type for which to generate the JSON schema. + /// The exporterOptions object controlling the schema generation. + /// A new instance defining the JSON schema for . + /// One of the specified parameters is . + /// The parameter contains unsupported exporterOptions. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOptions? exporterOptions = null) + { + _ = Throw.IfNull(options); + _ = Throw.IfNull(type); + ValidateOptions(options); + + exporterOptions ??= JsonSchemaExporterOptions.Default; + JsonTypeInfo typeInfo = options.GetTypeInfo(type); + return MapRootTypeJsonSchema(typeInfo, exporterOptions); + } + + /// + /// Generates a JSON schema corresponding to the specified contract metadata. + /// + /// The contract metadata for which to generate the schema. + /// The exporterOptions object controlling the schema generation. + /// A new instance defining the JSON schema for . + /// One of the specified parameters is . + /// The parameter contains unsupported exporterOptions. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOptions = null) + { + _ = Throw.IfNull(typeInfo); + ValidateOptions(typeInfo.Options); + + exporterOptions ??= JsonSchemaExporterOptions.Default; + return MapRootTypeJsonSchema(typeInfo, exporterOptions); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonNode MapRootTypeJsonSchema(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions) + { + GenerationState state = new(exporterOptions, typeInfo.Options); + JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo); + return schema.ToJsonNode(exporterOptions); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonSchema MapJsonSchemaCore( + ref GenerationState state, + JsonTypeInfo typeInfo, + Type? parentType = null, + JsonPropertyInfo? propertyInfo = null, + ICustomAttributeProvider? propertyAttributeProvider = null, + ParameterInfo? parameterInfo = null, + bool isNonNullableType = false, + JsonConverter? customConverter = null, + JsonNumberHandling? customNumberHandling = null, + JsonTypeInfo? parentPolymorphicTypeInfo = null, + bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false, + bool parentPolymorphicTypeIsNonNullable = false, + KeyValuePair? typeDiscriminator = null, + bool cacheResult = true) + { + Debug.Assert(typeInfo.IsReadOnly, "The specified contract must have been made read-only."); + + JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, parentPolymorphicTypeInfo, parentType, propertyInfo, parameterInfo, propertyAttributeProvider); + + if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None && + state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer)) + { + // The schema context has already been generated in the schema document, return a reference to it. + return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer }); + } + + JsonSchema schema; + JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; + JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; + + if (!IsBuiltInConverter(effectiveConverter)) + { + // Return a `true` schema for types with user-defined converters. + return CompleteSchema(ref state, JsonSchema.True); + } + + if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) + { + // This is the base type of a polymorphic type hierarchy. The schema for this type + // will include an "anyOf" property with the schemas for all derived types. + + string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName; + List derivedTypes = polyOptions.DerivedTypes.ToList(); + + if (!typeInfo.Type.IsAbstract && !derivedTypes.Any(derived => derived.DerivedType == typeInfo.Type)) + { + // For non-abstract base types that haven't been explicitly configured, + // add a trivial schema to the derived types since we should support it. + derivedTypes.Add(new JsonDerivedType(typeInfo.Type)); + } + + bool containsTypesWithoutDiscriminator = derivedTypes.Exists(static derivedTypes => derivedTypes.TypeDiscriminator is null); + JsonSchemaType schemaType = JsonSchemaType.Any; + List? anyOf = new(derivedTypes.Count); + + state.PushSchemaNode(JsonSchemaConstants.AnyOfPropertyName); + + foreach (JsonDerivedType derivedType in derivedTypes) + { + Debug.Assert(derivedType.TypeDiscriminator is null or int or string, "Type discriminator does not have the expected type."); + + KeyValuePair? derivedTypeDiscriminator = null; + if (derivedType.TypeDiscriminator is { } discriminatorValue) + { + JsonNode discriminatorNode = discriminatorValue switch + { + string stringId => (JsonNode)stringId, + _ => (JsonNode)(int)discriminatorValue, + }; + + JsonSchema discriminatorSchema = new() { Constant = discriminatorNode }; + derivedTypeDiscriminator = new(typeDiscriminatorKey, discriminatorSchema); + } + + JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfo(derivedType.DerivedType); + + state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture)); + JsonSchema derivedSchema = MapJsonSchemaCore( + ref state, + derivedTypeInfo, + parentPolymorphicTypeInfo: typeInfo, + typeDiscriminator: derivedTypeDiscriminator, + parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator, + parentPolymorphicTypeIsNonNullable: isNonNullableType, + cacheResult: false); + + state.PopSchemaNode(); + + // Determine if all derived schemas have the same type. + if (anyOf.Count == 0) + { + schemaType = derivedSchema.Type; + } + else if (schemaType != derivedSchema.Type) + { + schemaType = JsonSchemaType.Any; + } + + anyOf.Add(derivedSchema); + } + + state.PopSchemaNode(); + + if (schemaType is not JsonSchemaType.Any) + { + // If all derived types have the same schema type, we can simplify the schema + // by moving the type keyword to the base schema and removing it from the derived schemas. + foreach (JsonSchema derivedSchema in anyOf) + { + derivedSchema.Type = JsonSchemaType.Any; + + if (derivedSchema.KeywordCount == 0) + { + // if removing the type results in an empty schema, + // remove the anyOf array entirely since it's always true. + anyOf = null; + break; + } + } + } + + schema = new() + { + Type = schemaType, + AnyOf = anyOf, + + // If all derived types have a discriminator, we can require it in the base schema. + Required = containsTypesWithoutDiscriminator ? null : new() { typeDiscriminatorKey }, + }; + + return CompleteSchema(ref state, schema); + } + + if (Nullable.GetUnderlyingType(typeInfo.Type) is Type nullableElementType) + { + JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(nullableElementType); + customConverter = ExtractCustomNullableConverter(customConverter); + schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: customConverter, cacheResult: false); + + if (schema.Enum != null) + { + Debug.Assert(elementTypeInfo.Type.IsEnum, "The enum keyword should only be populated by schemas for enum types."); + schema.Enum.Add(null); // Append null to the enum array. + } + + return CompleteSchema(ref state, schema); + } + + switch (typeInfo.Kind) + { + case JsonTypeInfoKind.Object: + List>? properties = null; + List? required = null; + JsonSchema? additionalProperties = null; + + JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? typeInfo.Options.UnmappedMemberHandling; + if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + { + // Disallow unspecified properties. + additionalProperties = JsonSchema.False; + } + + if (typeDiscriminator is { } typeDiscriminatorPair) + { + (properties = new()).Add(typeDiscriminatorPair); + if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) + { + // Require the discriminator here since it's not common to all derived types. + (required = new()).Add(typeDiscriminatorPair.Key); + } + } + + Func? parameterInfoMapper = ResolveJsonConstructorParameterMapper(typeInfo); + + state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property is { Get: null, Set: null } or { IsExtensionData: true }) + { + continue; // Skip JsonIgnored properties and extension data + } + + JsonNumberHandling? propertyNumberHandling = property.NumberHandling ?? effectiveNumberHandling; + JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); + + // Resolve the attribute provider for the property. + ICustomAttributeProvider? attributeProvider = 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); + isNonNullableProperty = + (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && + (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); + } + + bool isRequired = property.IsRequired; + bool hasDefaultValue = false; + JsonNode? defaultValue = null; + + ParameterInfo? associatedParameter = parameterInfoMapper?.Invoke(property); + if (associatedParameter != null) + { + ResolveParameterInfo( + associatedParameter, + propertyTypeInfo, + state.NullabilityInfoContext, + out hasDefaultValue, + out defaultValue, + out bool isNonNullableParameter, + ref isRequired); + + isNonNullableProperty &= isNonNullableParameter; + } + + state.PushSchemaNode(property.Name); + JsonSchema propertySchema = MapJsonSchemaCore( + ref state, + propertyTypeInfo, + parentType: typeInfo.Type, + propertyInfo: property, + parameterInfo: associatedParameter, + propertyAttributeProvider: attributeProvider, + isNonNullableType: isNonNullableProperty, + customConverter: property.CustomConverter, + customNumberHandling: propertyNumberHandling); + + state.PopSchemaNode(); + + if (hasDefaultValue) + { + JsonSchema.EnsureMutable(ref propertySchema); + propertySchema.DefaultValue = defaultValue; + propertySchema.HasDefaultValue = true; + } + + (properties ??= new()).Add(new(property.Name, propertySchema)); + + if (isRequired) + { + (required ??= new()).Add(property.Name); + } + } + + state.PopSchemaNode(); + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = properties, + Required = required, + AdditionalProperties = additionalProperties, + }); + + case JsonTypeInfoKind.Enumerable: + Type elementType = GetElementType(typeInfo); + JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); + + if (typeDiscriminator is null) + { + state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); + JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Array, + Items = items.IsTrue ? null : items, + }); + } + else + { + // Polymorphic enumerable types are represented using a wrapping object: + // { "$type" : "discriminator", "$values" : [element1, element2, ...] } + // Which corresponds to the schema + // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "items" : { ... } } } } + const string ValuesKeyword = "$values"; + + state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); + state.PushSchemaNode(ValuesKeyword); + state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); + + JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); + + state.PopSchemaNode(); + state.PopSchemaNode(); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = new() + { + typeDiscriminator.Value, + new(ValuesKeyword, + new JsonSchema + { + Type = JsonSchemaType.Array, + Items = items.IsTrue ? null : items, + }), + }, + Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? new() { typeDiscriminator.Value.Key } : null, + }); + } + + case JsonTypeInfoKind.Dictionary: + Type valueType = GetElementType(typeInfo); + JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); + + List>? dictProps = null; + List? dictRequired = null; + + if (typeDiscriminator is { } dictDiscriminator) + { + dictProps = new() { dictDiscriminator }; + if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) + { + // Require the discriminator here since it's not common to all derived types. + dictRequired = new() { dictDiscriminator.Key }; + } + } + + state.PushSchemaNode(JsonSchemaConstants.AdditionalPropertiesPropertyName); + JsonSchema valueSchema = MapJsonSchemaCore(ref state, valueTypeInfo, customNumberHandling: effectiveNumberHandling); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = dictProps, + Required = dictRequired, + AdditionalProperties = valueSchema.IsTrue ? null : valueSchema, + }); + + default: + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.None, "The default case should handle unrecognize type kinds."); + + if (_simpleTypeSchemaFactories.TryGetValue(typeInfo.Type, out Func? simpleTypeSchemaFactory)) + { + schema = simpleTypeSchemaFactory(effectiveNumberHandling); + } + else if (typeInfo.Type.IsEnum) + { + schema = GetEnumConverterSchema(typeInfo, effectiveConverter); + } + else + { + schema = JsonSchema.True; + } + + return CompleteSchema(ref state, schema); + } + + 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) + { + schema.MakeNullable(); + } + } + + if (state.ExporterOptions.TransformSchemaNode != null) + { + // Prime the schema for invocation by the JsonNode transformer. + schema.GenerationContext = exporterContext; + } + + return schema; + } + } + + private readonly ref struct GenerationState + { + private const int DefaultMaxDepth = 64; + private readonly List _currentPath = new(); + private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new(); + private readonly int _maxDepth; + + public GenerationState(JsonSchemaExporterOptions exporterOptions, JsonSerializerOptions options, NullabilityInfoContext? nullabilityInfoContext = null) + { + ExporterOptions = exporterOptions; + NullabilityInfoContext = nullabilityInfoContext ?? new(); + _maxDepth = options.MaxDepth is 0 ? DefaultMaxDepth : options.MaxDepth; + } + + public JsonSchemaExporterOptions ExporterOptions { get; } + public NullabilityInfoContext NullabilityInfoContext { get; } + public int CurrentDepth => _currentPath.Count; + + public void PushSchemaNode(string nodeId) + { + if (CurrentDepth == _maxDepth) + { + ThrowHelpers.ThrowInvalidOperationException_MaxDepthReached(); + } + + _currentPath.Add(nodeId); + } + + public void PopSchemaNode() + { + _currentPath.RemoveAt(_currentPath.Count - 1); + } + + /// + /// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location. + /// + public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer) + { + (JsonTypeInfo, JsonPropertyInfo?) key = (context.TypeInfo, context.PropertyInfo); +#if NET + ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool exists); +#else + bool exists = _generated.TryGetValue(key, out string[]? pathToSchema); +#endif + if (exists) + { + existingJsonPointer = FormatJsonPointer(pathToSchema); + return true; + } +#if NET + pathToSchema = context._path; +#else + _generated[key] = context._path; +#endif + existingJsonPointer = null; + return false; + } + + public JsonSchemaExporterContext CreateContext( + JsonTypeInfo typeInfo, + JsonTypeInfo? baseTypeInfo, + Type? declaringType, + JsonPropertyInfo? propertyInfo, + ParameterInfo? parameterInfo, + ICustomAttributeProvider? propertyAttributeProvider) + { + return new JsonSchemaExporterContext(typeInfo, baseTypeInfo, declaringType, propertyInfo, parameterInfo, propertyAttributeProvider, _currentPath.ToArray()); + } + + private static string FormatJsonPointer(ReadOnlySpan path) + { + if (path.IsEmpty) + { + return "#"; + } + + StringBuilder sb = new(); + _ = sb.Append('#'); + + for (int i = 0; i < path.Length; i++) + { + string segment = path[i]; + if (segment.AsSpan().IndexOfAny('~', '/') != -1) + { +#pragma warning disable CA1307 // Specify StringComparison for clarity + segment = segment.Replace("~", "~0").Replace("/", "~1"); +#pragma warning restore CA1307 + } + + _ = sb.Append('/'); + _ = sb.Append(segment); + } + + return sb.ToString(); + } + } + + private static readonly Dictionary> _simpleTypeSchemaFactories = new() + { + [typeof(object)] = _ => JsonSchema.True, + [typeof(bool)] = _ => new JsonSchema { Type = JsonSchemaType.Boolean }, + [typeof(byte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(ushort)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(uint)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(ulong)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(sbyte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(short)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(int)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(long)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(float)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), + [typeof(double)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), + [typeof(decimal)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling), +#if NET6_0_OR_GREATER + [typeof(Half)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), +#endif +#if NET7_0_OR_GREATER + [typeof(UInt128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(Int128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), +#endif + [typeof(char)] = _ => new JsonSchema { Type = JsonSchemaType.String, MinLength = 1, MaxLength = 1 }, + [typeof(string)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(byte[])] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(Memory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(ReadOnlyMemory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(DateTime)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, + [typeof(DateTimeOffset)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, + [typeof(TimeSpan)] = _ => new JsonSchema + { + Comment = "Represents a System.TimeSpan value.", + Type = JsonSchemaType.String, + Pattern = @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$", + }, + +#if NET6_0_OR_GREATER + [typeof(DateOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date" }, + [typeof(TimeOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "time" }, +#endif + [typeof(Guid)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uuid" }, + [typeof(Uri)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uri" }, + [typeof(Version)] = _ => new JsonSchema + { + Comment = "Represents a version string.", + Type = JsonSchemaType.String, + Pattern = @"^\d+(\.\d+){1,3}$", + }, + + [typeof(JsonDocument)] = _ => JsonSchema.True, + [typeof(JsonElement)] = _ => JsonSchema.True, + [typeof(JsonNode)] = _ => JsonSchema.True, + [typeof(JsonValue)] = _ => JsonSchema.True, + [typeof(JsonObject)] = _ => new JsonSchema { Type = JsonSchemaType.Object }, + [typeof(JsonArray)] = _ => new JsonSchema { Type = JsonSchemaType.Array }, + }; + + // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/JsonPrimitiveConverter.cs#L36-L69 + private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, JsonNumberHandling numberHandling, bool isIeeeFloatingPoint = false) + { + Debug.Assert(schemaType is JsonSchemaType.Integer or JsonSchemaType.Number, "schema type must be number or integer"); + Debug.Assert(!isIeeeFloatingPoint || schemaType is JsonSchemaType.Number, "If specifying IEEE the schema type must be number"); + + string? pattern = null; + + 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+)?$"; + + schemaType |= JsonSchemaType.String; + } + + if (isIeeeFloatingPoint && (numberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0) + { + return new JsonSchema + { + AnyOf = new() + { + new JsonSchema { Type = schemaType, Pattern = pattern }, + new JsonSchema { Enum = new() { (JsonNode)"NaN", (JsonNode)"Infinity", (JsonNode)"-Infinity" } }, + }, + }; + } + + 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."); + + // 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") + { + FieldInfo elementConverterField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_elementConverter"); + return (JsonConverter)elementConverterField!.GetValue(converter)!; + } + + return null; + } + + private static void ValidateOptions(JsonSerializerOptions options) + { + if (options.ReferenceHandler == ReferenceHandler.Preserve) + { + ThrowHelpers.ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported(); + } + + options.MakeReadOnly(); + } + + private static void ResolveParameterInfo( + ParameterInfo parameter, + JsonTypeInfo parameterTypeInfo, + NullabilityInfoContext nullabilityInfoContext, + out bool hasDefaultValue, + out JsonNode? defaultValue, + out bool isNonNullable, + ref bool isRequired) + { + 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; + + if (parameter.HasDefaultValue) + { + // Append the default value to the description. + object? defaultVal = parameter.GetNormalizedDefaultValue(); + defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); + hasDefaultValue = true; + } + else + { + // Parameter is not optional, mark as required. + isRequired = true; + defaultValue = null; + hasDefaultValue = false; + } + } + + // 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."); + + 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"); + + const int EnumConverterOptionsAllowStrings = 1; + var converterOptions = (int)converterOptionsField!.GetValue(converter)!; + if ((converterOptions & EnumConverterOptionsAllowStrings) != 0) + { + // 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. + + if (typeInfo.Type.GetCustomAttribute() is not null) + { + // Do not report enum values in case of flags. + return new() { Type = JsonSchemaType.String }; + } + + var namingPolicy = (JsonNamingPolicy?)namingPolicyField!.GetValue(converter)!; + JsonArray enumValues = new(); + foreach (string name in Enum.GetNames(typeInfo.Type)) + { + // This does not account for custom names specified via the new + // JsonStringEnumMemberNameAttribute introduced in .NET 9. + string effectiveName = namingPolicy?.ConvertName(name) ?? name; + enumValues.Add((JsonNode)effectiveName); + } + + return new() { Enum = enumValues }; + } + + 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"; + public const string RefPropertyName = "$ref"; + public const string CommentPropertyName = "$comment"; + public const string TitlePropertyName = "title"; + public const string DescriptionPropertyName = "description"; + public const string TypePropertyName = "type"; + public const string FormatPropertyName = "format"; + public const string PatternPropertyName = "pattern"; + public const string PropertiesPropertyName = "properties"; + public const string RequiredPropertyName = "required"; + public const string ItemsPropertyName = "items"; + public const string AdditionalPropertiesPropertyName = "additionalProperties"; + public const string EnumPropertyName = "enum"; + public const string NotPropertyName = "not"; + public const string AnyOfPropertyName = "anyOf"; + public const string ConstPropertyName = "const"; + public const string DefaultPropertyName = "default"; + public const string MinLengthPropertyName = "minLength"; + public const string MaxLengthPropertyName = "maxLength"; + } + + private static class ThrowHelpers + { + [DoesNotReturn] + 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."); + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs new file mode 100644 index 00000000000..3602ee46df4 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs @@ -0,0 +1,77 @@ +// 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; +using System.Reflection; +using System.Text.Json.Serialization.Metadata; + +namespace System.Text.Json.Schema; + +/// +/// Defines the context in which a JSON schema within a type graph is being generated. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal readonly struct JsonSchemaExporterContext +{ +#pragma warning disable IDE1006 // Naming Styles + internal readonly string[] _path; +#pragma warning restore IDE1006 // Naming Styles + + internal JsonSchemaExporterContext( + JsonTypeInfo typeInfo, + JsonTypeInfo? baseTypeInfo, + Type? declaringType, + JsonPropertyInfo? propertyInfo, + ParameterInfo? parameterInfo, + ICustomAttributeProvider? propertyAttributeProvider, + string[] path) + { + TypeInfo = typeInfo; + DeclaringType = declaringType; + BaseTypeInfo = baseTypeInfo; + PropertyInfo = propertyInfo; + ParameterInfo = parameterInfo; + PropertyAttributeProvider = propertyAttributeProvider; + _path = path; + } + + /// + /// Gets the path to the schema document currently being generated. + /// + public ReadOnlySpan Path => _path; + + /// + /// Gets the for the type being processed. + /// + public JsonTypeInfo TypeInfo { get; } + + /// + /// Gets the declaring type of the property or parameter being processed. + /// + public Type? DeclaringType { get; } + + /// + /// Gets the type info for the polymorphic base type if generated as a derived type. + /// + public JsonTypeInfo? BaseTypeInfo { get; } + + /// + /// Gets the if the schema is being generated for a property. + /// + public JsonPropertyInfo? PropertyInfo { get; } + + /// + /// Gets the if a constructor parameter + /// has been associated with the accompanying . + /// + public ParameterInfo? ParameterInfo { get; } + + /// + /// Gets the corresponding to the property or field being processed. + /// + public ICustomAttributeProvider? PropertyAttributeProvider { get; } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs new file mode 100644 index 00000000000..53a269ea612 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.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. + +#if !NET9_0_OR_GREATER +using System; +using System.Text.Json.Nodes; + +namespace System.Text.Json.Schema; + +/// +/// Controls the behavior of the class. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal sealed class JsonSchemaExporterOptions +{ + /// + /// Gets the default configuration object used by . + /// + public static JsonSchemaExporterOptions Default { get; } = new(); + + /// + /// Gets a value indicating whether non-nullable schemas should be generated for null oblivious reference types. + /// + /// + /// Defaults to . Due to restrictions in the run-time representation of nullable reference types + /// most occurrences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule + /// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata. + /// + public bool TreatNullObliviousAsNonNullable { get; init; } + + /// + /// Gets a callback that is invoked for every schema that is generated within the type graph. + /// + public Func? TransformSchemaNode { get; init; } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs new file mode 100644 index 00000000000..bd9b132cd0f --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.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. + +#if !NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable SA1623 // Property summary documentation should match accessors + +namespace System.Reflection +{ + /// + /// A class that represents nullability info. + /// + [ExcludeFromCodeCoverage] + internal sealed class NullabilityInfo + { + internal NullabilityInfo(Type type, NullabilityState readState, NullabilityState writeState, + NullabilityInfo? elementType, NullabilityInfo[] typeArguments) + { + Type = type; + ReadState = readState; + WriteState = writeState; + ElementType = elementType; + GenericTypeArguments = typeArguments; + } + + /// + /// The of the member or generic parameter + /// to which this NullabilityInfo belongs. + /// + public Type Type { get; } + + /// + /// The nullability read state of the member. + /// + public NullabilityState ReadState { get; internal set; } + + /// + /// The nullability write state of the member. + /// + public NullabilityState WriteState { get; internal set; } + + /// + /// If the member type is an array, gives the of the elements of the array, null otherwise. + /// + public NullabilityInfo? ElementType { get; } + + /// + /// If the member type is a generic type, gives the array of for each type parameter. + /// + public NullabilityInfo[] GenericTypeArguments { get; } + } + + /// + /// An enum that represents nullability state. + /// + internal enum NullabilityState + { + /// + /// Nullability context not enabled (oblivious). + /// + Unknown, + + /// + /// Non nullable value or reference type. + /// + NotNull, + + /// + /// Nullable value or reference type. + /// + Nullable, + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs new file mode 100644 index 00000000000..3edee1b9cb8 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs @@ -0,0 +1,661 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET6_0_OR_GREATER +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S109 // Magic numbers should not be used +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S4136 // Method overloads should be grouped together +#pragma warning disable SA1202 // Elements should be ordered by access +#pragma warning disable IDE1006 // Naming Styles + +namespace System.Reflection +{ + /// + /// Provides APIs for populating nullability information/context from reflection members: + /// , , and . + /// + [ExcludeFromCodeCoverage] + internal sealed class NullabilityInfoContext + { + private const string CompilerServicesNameSpace = "System.Runtime.CompilerServices"; + private readonly Dictionary _publicOnlyModules = new(); + private readonly Dictionary _context = new(); + + [Flags] + private enum NotAnnotatedStatus + { + None = 0x0, // no restriction, all members annotated + Private = 0x1, // private members not annotated + Internal = 0x2, // internal members not annotated + } + + private NullabilityState? GetNullableContext(MemberInfo? memberInfo) + { + while (memberInfo != null) + { + if (_context.TryGetValue(memberInfo, out NullabilityState state)) + { + return state; + } + + foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) + { + if (attribute.AttributeType.Name == "NullableContextAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + state = TranslateByte(attribute.ConstructorArguments[0].Value); + _context.Add(memberInfo, state); + return state; + } + } + + memberInfo = memberInfo.DeclaringType; + } + + return null; + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the parameterInfo parameter is null. + /// . + public NullabilityInfo Create(ParameterInfo parameterInfo) + { + IList attributes = parameterInfo.GetCustomAttributesData(); + NullableAttributeStateParser parser = parameterInfo.Member is MethodBase method && IsPrivateOrInternalMethodAndAnnotationDisabled(method) + ? NullableAttributeStateParser.Unknown + : CreateParser(attributes); + NullabilityInfo nullability = GetNullabilityInfo(parameterInfo.Member, parameterInfo.ParameterType, parser); + + if (nullability.ReadState != NullabilityState.Unknown) + { + CheckParameterMetadataType(parameterInfo, nullability); + } + + CheckNullabilityAttributes(nullability, attributes); + return nullability; + } + + private void CheckParameterMetadataType(ParameterInfo parameter, NullabilityInfo nullability) + { + ParameterInfo? metaParameter; + MemberInfo metaMember; + + switch (parameter.Member) + { + case ConstructorInfo ctor: + var metaCtor = (ConstructorInfo)GetMemberMetadataDefinition(ctor); + metaMember = metaCtor; + metaParameter = GetMetaParameter(metaCtor, parameter); + break; + + case MethodInfo method: + MethodInfo metaMethod = GetMethodMetadataDefinition(method); + metaMember = metaMethod; + metaParameter = string.IsNullOrEmpty(parameter.Name) ? metaMethod.ReturnParameter : GetMetaParameter(metaMethod, parameter); + break; + + default: + return; + } + + if (metaParameter != null) + { + CheckGenericParameters(nullability, metaMember, metaParameter.ParameterType, parameter.Member.ReflectedType); + } + } + + private static ParameterInfo? GetMetaParameter(MethodBase metaMethod, ParameterInfo parameter) + { + var parameters = metaMethod.GetParameters(); + for (int i = 0; i < parameters.Length; i++) + { + if (parameter.Position == i && + parameter.Name == parameters[i].Name) + { + return parameters[i]; + } + } + + return null; + } + + private static MethodInfo GetMethodMetadataDefinition(MethodInfo method) + { + if (method.IsGenericMethod && !method.IsGenericMethodDefinition) + { + method = method.GetGenericMethodDefinition(); + } + + return (MethodInfo)GetMemberMetadataDefinition(method); + } + + private static void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) + { + var codeAnalysisReadState = NullabilityState.Unknown; + var codeAnalysisWriteState = NullabilityState.Unknown; + + foreach (CustomAttributeData attribute in attributes) + { + if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") + { + if (attribute.AttributeType.Name == "NotNullAttribute") + { + codeAnalysisReadState = NullabilityState.NotNull; + } + else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || + attribute.AttributeType.Name == "MaybeNullWhenAttribute") && + codeAnalysisReadState == NullabilityState.Unknown && + !IsValueTypeOrValueTypeByRef(nullability.Type)) + { + codeAnalysisReadState = NullabilityState.Nullable; + } + else if (attribute.AttributeType.Name == "DisallowNullAttribute") + { + codeAnalysisWriteState = NullabilityState.NotNull; + } + else if (attribute.AttributeType.Name == "AllowNullAttribute" && + codeAnalysisWriteState == NullabilityState.Unknown && + !IsValueTypeOrValueTypeByRef(nullability.Type)) + { + codeAnalysisWriteState = NullabilityState.Nullable; + } + } + } + + if (codeAnalysisReadState != NullabilityState.Unknown) + { + nullability.ReadState = codeAnalysisReadState; + } + + if (codeAnalysisWriteState != NullabilityState.Unknown) + { + nullability.WriteState = codeAnalysisWriteState; + } + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the propertyInfo parameter is null. + /// . + public NullabilityInfo Create(PropertyInfo propertyInfo) + { + MethodInfo? getter = propertyInfo.GetGetMethod(true); + MethodInfo? setter = propertyInfo.GetSetMethod(true); + bool annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) + && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); + NullableAttributeStateParser parser = annotationsDisabled ? NullableAttributeStateParser.Unknown : CreateParser(propertyInfo.GetCustomAttributesData()); + NullabilityInfo nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); + + if (getter != null) + { + CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); + } + else + { + nullability.ReadState = NullabilityState.Unknown; + } + + if (setter != null) + { + CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); + } + else + { + nullability.WriteState = NullabilityState.Unknown; + } + + return nullability; + } + + private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) + { + if ((method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && + IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module)) + { + return true; + } + + return false; + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the eventInfo parameter is null. + /// . + public NullabilityInfo Create(EventInfo eventInfo) + { + return GetNullabilityInfo(eventInfo, eventInfo.EventHandlerType!, CreateParser(eventInfo.GetCustomAttributesData())); + } + + /// + /// Populates for the given + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the fieldInfo parameter is null. + /// . + public NullabilityInfo Create(FieldInfo fieldInfo) + { + IList attributes = fieldInfo.GetCustomAttributesData(); + NullableAttributeStateParser parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) ? NullableAttributeStateParser.Unknown : CreateParser(attributes); + NullabilityInfo nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); + CheckNullabilityAttributes(nullability, attributes); + return nullability; + } + + private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) + { + if ((fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && + IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module)) + { + return true; + } + + return false; + } + + private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) + { + if (!_publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) + { + value = PopulateAnnotationInfo(module.GetCustomAttributesData()); + _publicOnlyModules.Add(module, value); + } + + if (value == NotAnnotatedStatus.None) + { + return false; + } + + if (((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || + (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal))) + { + return true; + } + + return false; + } + + private static NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) + { + foreach (CustomAttributeData attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) + { + return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; + } + else + { + return NotAnnotatedStatus.Private; + } + } + } + + return NotAnnotatedStatus.None; + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) + { + int index = 0; + NullabilityInfo nullability = GetNullabilityInfo(memberInfo, type, parser, ref index); + + if (nullability.ReadState != NullabilityState.Unknown) + { + TryLoadGenericMetaTypeNullability(memberInfo, nullability); + } + + return nullability; + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) + { + NullabilityState state = NullabilityState.Unknown; + NullabilityInfo? elementState = null; + NullabilityInfo[] genericArgumentsState = Array.Empty(); + Type underlyingType = type; + + if (underlyingType.IsByRef || underlyingType.IsPointer) + { + underlyingType = underlyingType.GetElementType()!; + } + + if (underlyingType.IsValueType) + { + if (Nullable.GetUnderlyingType(underlyingType) is { } nullableUnderlyingType) + { + underlyingType = nullableUnderlyingType; + state = NullabilityState.Nullable; + } + else + { + state = NullabilityState.NotNull; + } + + if (underlyingType.IsGenericType) + { + ++index; + } + } + else + { + if (!parser.ParseNullableState(index++, ref state) + && GetNullableContext(memberInfo) is { } contextState) + { + state = contextState; + } + + if (underlyingType.IsArray) + { + elementState = GetNullabilityInfo(memberInfo, underlyingType.GetElementType()!, parser, ref index); + } + } + + if (underlyingType.IsGenericType) + { + Type[] genericArguments = underlyingType.GetGenericArguments(); + genericArgumentsState = new NullabilityInfo[genericArguments.Length]; + + for (int i = 0; i < genericArguments.Length; i++) + { + genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); + } + } + + return new NullabilityInfo(type, state, state, elementState, genericArgumentsState); + } + + private static NullableAttributeStateParser CreateParser(IList customAttributes) + { + foreach (CustomAttributeData attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullableAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + return new NullableAttributeStateParser(attribute.ConstructorArguments[0].Value); + } + } + + return new NullableAttributeStateParser(null); + } + + private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) + { + MemberInfo? metaMember = GetMemberMetadataDefinition(memberInfo); + Type? metaType = null; + if (metaMember is FieldInfo field) + { + metaType = field.FieldType; + } + else if (metaMember is PropertyInfo property) + { + metaType = GetPropertyMetaType(property); + } + + if (metaType != null) + { + CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); + } + } + + private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) + { + Type? type = member.DeclaringType; + if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) + { + return NullabilityInfoHelpers.GetMemberWithSameMetadataDefinitionAs(type.GetGenericTypeDefinition(), member); + } + + return member; + } + + private static Type GetPropertyMetaType(PropertyInfo property) + { + if (property.GetGetMethod(true) is MethodInfo method) + { + return method.ReturnType; + } + + return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; + } + + private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) + { + if (metaType.IsGenericParameter) + { + if (nullability.ReadState == NullabilityState.NotNull) + { + _ = TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); + } + } + else if (metaType.ContainsGenericParameters) + { + if (nullability.GenericTypeArguments.Length > 0) + { + Type[] genericArguments = metaType.GetGenericArguments(); + + for (int i = 0; i < genericArguments.Length; i++) + { + CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); + } + } + else if (nullability.ElementType is { } elementNullability && metaType.IsArray) + { + CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); + } + + // We could also follow this branch for metaType.IsPointer, but since pointers must be unmanaged this + // will be a no-op regardless + else if (metaType.IsByRef) + { + CheckGenericParameters(nullability, metaMember, metaType.GetElementType()!, reflectedType); + } + } + } + + private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) + { + Debug.Assert(genericParameter.IsGenericParameter, "must be generic parameter"); + + if (reflectedType is not null + && !genericParameter.IsGenericMethodParameter() + && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) + { + return true; + } + + if (IsValueTypeOrValueTypeByRef(nullability.Type)) + { + return true; + } + + var state = NullabilityState.Unknown; + if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) + { + nullability.ReadState = state; + nullability.WriteState = state; + return true; + } + + if (GetNullableContext(genericParameter) is { } contextState) + { + nullability.ReadState = contextState; + nullability.WriteState = contextState; + return true; + } + + return false; + } + + private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) + { + Debug.Assert(genericParameter.IsGenericParameter && !genericParameter.IsGenericMethodParameter(), "must be generic parameter"); + + Type contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition ? context.GetGenericTypeDefinition() : context; + if (genericParameter.DeclaringType == contextTypeDefinition) + { + return false; + } + + Type? baseType = contextTypeDefinition.BaseType; + if (baseType is null) + { + return false; + } + + if (!baseType.IsGenericType + || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) + { + return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); + } + + Type[] genericArguments = baseType.GetGenericArguments(); + Type genericArgument = genericArguments[genericParameter.GenericParameterPosition]; + if (genericArgument.IsGenericParameter) + { + return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); + } + + NullableAttributeStateParser parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); + int nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself + for (int i = 0; i < genericParameter.GenericParameterPosition; i++) + { + nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); + } + + return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); + + static int CountNullabilityStates(Type type) + { + Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; + if (underlyingType.IsGenericType) + { + int count = 1; + foreach (Type genericArgument in underlyingType.GetGenericArguments()) + { + count += CountNullabilityStates(genericArgument); + } + + return count; + } + + if (underlyingType.HasElementType) + { + return (underlyingType.IsArray ? 1 : 0) + CountNullabilityStates(underlyingType.GetElementType()!); + } + + return type.IsValueType ? 0 : 1; + } + } + +#pragma warning disable SA1204 // Static elements should appear before instance elements + private static bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) +#pragma warning restore SA1204 // Static elements should appear before instance elements + { + bool isValueType = IsValueTypeOrValueTypeByRef(nullability.Type); + if (!isValueType) + { + var state = NullabilityState.Unknown; + if (!parser.ParseNullableState(index, ref state)) + { + return false; + } + + nullability.ReadState = state; + nullability.WriteState = state; + } + + if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) + { + index++; + } + + if (nullability.GenericTypeArguments.Length > 0) + { + foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) + { + _ = TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); + } + } + else if (nullability.ElementType is { } elementTypeNullability) + { + _ = TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); + } + + return true; + } + + private static NullabilityState TranslateByte(object? value) + { + return value is byte b ? TranslateByte(b) : NullabilityState.Unknown; + } + + private static NullabilityState TranslateByte(byte b) => + b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; + + private static bool IsValueTypeOrValueTypeByRef(Type type) => + type.IsValueType || ((type.IsByRef || type.IsPointer) && type.GetElementType()!.IsValueType); + + private readonly struct NullableAttributeStateParser + { + private static readonly object UnknownByte = (byte)0; + + private readonly object? _nullableAttributeArgument; + + public NullableAttributeStateParser(object? nullableAttributeArgument) + { + _nullableAttributeArgument = nullableAttributeArgument; + } + + public static NullableAttributeStateParser Unknown => new(UnknownByte); + + public bool ParseNullableState(int index, ref NullabilityState state) + { + switch (_nullableAttributeArgument) + { + case byte b: + state = TranslateByte(b); + return true; + case ReadOnlyCollection args + when index < args.Count && args[index].Value is byte elementB: + state = TranslateByte(elementB); + return true; + default: + return false; + } + } + } + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs new file mode 100644 index 00000000000..1ee573a0020 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.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. + +#if !NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace System.Reflection +{ + /// + /// Polyfills for System.Private.CoreLib internals. + /// + [ExcludeFromCodeCoverage] + internal static class NullabilityInfoHelpers + { + public static MemberInfo GetMemberWithSameMetadataDefinitionAs(Type type, MemberInfo member) + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + foreach (var info in type.GetMembers(all)) + { + if (info.HasSameMetadataDefinitionAs(member)) + { + return info; + } + } + + throw new MissingMemberException(type.FullName, member.Name); + } + + // https://github.com/dotnet/runtime/blob/main/src/coreclr/System.Private.CoreLib/src/System/Reflection/MemberInfo.Internal.cs + public static bool HasSameMetadataDefinitionAs(this MemberInfo target, MemberInfo other) + { + return target.MetadataToken == other.MetadataToken && + target.Module.Equals(other.Module); + } + + // https://github.com/dotnet/runtime/issues/23493 + public static bool IsGenericMethodParameter(this Type target) + { + return target.IsGenericParameter && + target.DeclaringMethod != null; + } + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/README.md b/src/Shared/JsonSchemaExporter/README.md new file mode 100644 index 00000000000..1a4d13c5841 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/README.md @@ -0,0 +1,11 @@ +# JsonSchemaExporter + +Provides a polyfill for the [.NET 9 `JsonSchemaExporter` component](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) that is compatible with all supported targets using System.Text.Json version 8. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index f6cbb03ea83..58ec4eda535 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -12,7 +12,7 @@ true true true - true + true true true true @@ -33,6 +33,10 @@ + + + + diff --git a/test/Shared/JsonSchemaExporter/Helpers.cs b/test/Shared/JsonSchemaExporter/Helpers.cs new file mode 100644 index 00000000000..a925c1721f0 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/Helpers.cs @@ -0,0 +1,91 @@ +// 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.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Json.Schema; +using Json.Schema.Generation; +using Xunit.Sdk; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +internal static partial class Helpers +{ + public static void AssertValidJsonSchema(Type type, string? expectedJsonSchema, JsonNode actualJsonSchema) + { + // If an expected schema is provided, use that. Otherwise, generate a schema from the type. + JsonNode? expectedJsonSchemaNode = expectedJsonSchema != null + ? JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip }) + : JsonSerializer.SerializeToNode(new JsonSchemaBuilder().FromType(type), Context.Default.JsonSchema); + + // Trim the $schema property from actual schema since it's not included by the generator. + (actualJsonSchema as JsonObject)?.Remove("$schema"); + + if (!JsonNode.DeepEquals(expectedJsonSchemaNode, actualJsonSchema)) + { + throw new XunitException($""" + Generated schema does not match the expected specification. + Expected: + {FormatJson(expectedJsonSchemaNode)} + Actual: + {FormatJson(actualJsonSchema)} + """); + } + } + + public static void AssertDocumentMatchesSchema(JsonNode schema, JsonNode? instance) + { + EvaluationResults results = EvaluateSchemaCore(schema, instance); + if (!results.IsValid) + { + IEnumerable errors = results.Details + .Where(d => d.HasErrors) + .SelectMany(d => d.Errors!.Select(error => $"Path:${d.InstanceLocation} {error.Key}:{error.Value}")); + + throw new XunitException($""" + Instance JSON document does not match the specified schema. + Schema: + {FormatJson(schema)} + Instance: + {FormatJson(instance)} + Errors: + {string.Join(Environment.NewLine, errors)} + """); + } + } + + public static void AssertDoesNotMatchSchema(JsonNode schema, JsonNode? instance) + { + EvaluationResults results = EvaluateSchemaCore(schema, instance); + if (results.IsValid) + { + throw new XunitException($""" + Instance JSON document matches the specified schema. + Schema: + {FormatJson(schema)} + Instance: + {FormatJson(instance)} + """); + } + } + + private static EvaluationResults EvaluateSchemaCore(JsonNode schema, JsonNode? instance) + { + JsonSchema jsonSchema = JsonSerializer.Deserialize(schema, Context.Default.JsonSchema)!; + EvaluationOptions options = new() { OutputFormat = OutputFormat.List }; + return jsonSchema.Evaluate(instance, options); + } + + private static string FormatJson(JsonNode? node) => + JsonSerializer.Serialize(node, Context.Default.JsonNode!); + + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonSchema))] + [JsonSourceGenerationOptions(WriteIndented = true)] + private partial class Context : JsonSerializerContext; +} diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs new file mode 100644 index 00000000000..1d2b6caa74e --- /dev/null +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.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.Text.Json.Schema; +using Xunit; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +public static class JsonSchemaExporterConfigurationTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void JsonSchemaExporterOptions_DefaultValues(bool useSingleton) + { + JsonSchemaExporterOptions configuration = useSingleton ? JsonSchemaExporterOptions.Default : new(); + Assert.False(configuration.TreatNullObliviousAsNonNullable); + Assert.Null(configuration.TransformSchemaNode); + } + + [Fact] + public static void JsonSchemaExporterOptions_Singleton_ReturnsSameInstance() + { + Assert.Same(JsonSchemaExporterOptions.Default, JsonSchemaExporterOptions.Default); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void JsonSchemaExporterOptions_TreatNullObliviousAsNonNullable(bool treatNullObliviousAsNonNullable) + { + JsonSchemaExporterOptions configuration = new() { TreatNullObliviousAsNonNullable = treatNullObliviousAsNonNullable }; + Assert.Equal(treatNullObliviousAsNonNullable, configuration.TreatNullObliviousAsNonNullable); + } +} diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs new file mode 100644 index 00000000000..d526025d5ba --- /dev/null +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -0,0 +1,148 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +#if !NET9_0_OR_GREATER +using System.Xml.Linq; +#endif +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; + +public abstract class JsonSchemaExporterTests +{ + protected abstract JsonSerializerOptions Options { get; } + + [Theory] + [MemberData(nameof(TestTypes.GetTestData), MemberType = typeof(TestTypes))] + public void TestTypes_GeneratesExpectedJsonSchema(ITestData testData) + { + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } + : Options; + + JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); + Helpers.AssertValidJsonSchema(testData.Type, testData.ExpectedJsonSchema, schema); + } + + [Theory] + [MemberData(nameof(TestTypes.GetTestDataUsingAllValues), MemberType = typeof(TestTypes))] + public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) + { + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } + : Options; + + JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); + JsonNode? instance = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); + Helpers.AssertDocumentMatchesSchema(schema, instance); + } + + [Theory] + [InlineData(typeof(string), "string")] + [InlineData(typeof(int[]), "array")] + [InlineData(typeof(Dictionary), "object")] + [InlineData(typeof(TestTypes.SimplePoco), "object")] + public void TreatNullObliviousAsNonNullable_True_MarksAllReferenceTypesAsNonNullable(Type referenceType, string expectedType) + { + Assert.True(!referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); + JsonValue type = Assert.IsAssignableFrom(schema["type"]); + Assert.Equal(expectedType, (string)type!); + } + + [Theory] + [InlineData(typeof(int), "integer")] + [InlineData(typeof(double), "number")] + [InlineData(typeof(bool), "boolean")] + [InlineData(typeof(ImmutableArray), "array")] + [InlineData(typeof(TestTypes.StructDictionary), "object")] + [InlineData(typeof(TestTypes.SimpleRecordStruct), "object")] + public void TreatNullObliviousAsNonNullable_True_DoesNotImpactNonReferenceTypes(Type referenceType, string expectedType) + { + Assert.True(referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); + JsonValue value = Assert.IsAssignableFrom(schema["type"]); + Assert.Equal(expectedType, (string)value!); + } + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/108764 gets backported + [Fact] + public void CanGenerateXElementSchema() + { + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(XElement)); + Assert.True(schema.ToJsonString().Length < 100_000); + } +#endif + + [Fact] + public void TreatNullObliviousAsNonNullable_True_DoesNotImpactObjectType() + { + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(object), config); + Assert.False(schema is JsonObject jObj && jObj.ContainsKey("type")); + } + + [Fact] + public void TypeWithDisallowUnmappedMembers_AdditionalPropertiesFailValidation() + { + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(TestTypes.PocoDisallowingUnmappedMembers)); + JsonNode? jsonWithUnmappedProperties = JsonNode.Parse("""{ "UnmappedProperty" : {} }"""); + Helpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); + } + + [Fact] + public void GetJsonSchema_NullInputs_ThrowsArgumentNullException() + { + Assert.Throws(() => ((JsonSerializerOptions)null!).GetJsonSchemaAsNode(typeof(int))); + Assert.Throws(() => Options.GetJsonSchemaAsNode(type: null!)); + Assert.Throws(() => ((JsonTypeInfo)null!).GetJsonSchemaAsNode()); + } + + [Fact] + public void GetJsonSchema_NoResolver_ThrowInvalidOperationException() + { + var options = new JsonSerializerOptions(); + Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(int))); + } + + [Fact] + public void MaxDepth_SetToZero_NonTrivialSchema_ThrowsInvalidOperationException() + { + JsonSerializerOptions options = new(Options) { MaxDepth = 1 }; + var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); + Assert.Contains("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting.", ex.Message); + } + + [Fact] + public void ReferenceHandlePreserve_Enabled_ThrowsNotSupportedException() + { + var options = new JsonSerializerOptions(Options) { ReferenceHandler = ReferenceHandler.Preserve }; + options.MakeReadOnly(); + + var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); + Assert.Contains("ReferenceHandler.Preserve", ex.Message); + } +} + +public sealed class ReflectionJsonSchemaExporterTests : JsonSchemaExporterTests +{ + protected override JsonSerializerOptions Options => JsonSerializerOptions.Default; +} + +public sealed class SourceGenJsonSchemaExporterTests : JsonSchemaExporterTests +{ + protected override JsonSerializerOptions Options => TestTypes.TestTypesContext.Default.Options; +} diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs new file mode 100644 index 00000000000..6b2c9d841a3 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -0,0 +1,55 @@ +// 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.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Schema; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +internal sealed record TestData( + T? Value, + IEnumerable? AdditionalValues = null, + [StringSyntax("Json")] string? ExpectedJsonSchema = null, + JsonSchemaExporterOptions? ExporterOptions = null, + JsonSerializerOptions? Options = null) + : ITestData +{ + public Type Type => typeof(T); + object? ITestData.Value => Value; + object? ITestData.ExporterOptions => ExporterOptions; + + IEnumerable ITestData.GetTestDataForAllValues() + { + yield return this; + + if (AdditionalValues != null) + { + foreach (T? value in AdditionalValues) + { + yield return this with { Value = value, AdditionalValues = null }; + } + } + } +} + +public interface ITestData +{ + Type Type { get; } + + object? Value { get; } + + /// + /// Gets the expected JSON schema for the value. + /// Fall back to JsonSchemaGenerator as the source of truth if null. + /// + string? ExpectedJsonSchema { get; } + + object? ExporterOptions { get; } + + JsonSerializerOptions? Options { get; } + + IEnumerable GetTestDataForAllValues(); +} diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs new file mode 100644 index 00000000000..4615143aa78 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -0,0 +1,1293 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Xml.Linq; + +#pragma warning disable SA1118 // Parameter should not span multiple lines +#pragma warning disable JSON001 // Comments not allowed +#pragma warning disable S2344 // Enumeration type names should not have "Flags" or "Enum" suffixes +#pragma warning disable SA1502 // Element should not be on a single line +#pragma warning disable SA1136 // Enum values should be on separate lines +#pragma warning disable SA1133 // Do not combine attributes +#pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1515 // Single-line comment should be preceded by blank line +#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; + +public static partial class TestTypes +{ + public static IEnumerable GetTestData() => GetTestDataCore().Select(t => new object[] { t }); + + public static IEnumerable GetTestDataUsingAllValues() => + GetTestDataCore() + .SelectMany(t => t.GetTestDataForAllValues()) + .Select(t => new object[] { t }); + + public static IEnumerable GetTestDataCore() + { + // Primitives and built-in types + yield return new TestData( + Value: new(), + AdditionalValues: [null, 42, false, 3.14, 3.14M, new int[] { 1, 2, 3 }, new SimpleRecord(1, "str", false, 3.14)], + ExpectedJsonSchema: "true"); + + yield return new TestData(true); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(1.2f); + yield return new TestData(3.14159d); + yield return new TestData(3.14159M); +#if NET7_0_OR_GREATER + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); +#endif +#if NET6_0_OR_GREATER + yield return new TestData((Half)3.141, ExpectedJsonSchema: """{"type":"number"}"""); +#endif + yield return new TestData("I am a string", ExpectedJsonSchema: """{"type":["string","null"]}"""); + yield return new TestData('c', ExpectedJsonSchema: """{"type":"string","minLength":1,"maxLength":1}"""); + yield return new TestData( + Value: [1, 2, 3], + AdditionalValues: [[]], + ExpectedJsonSchema: """{"type":["string","null"]}"""); + + yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData( + Value: new(2021, 1, 1), + AdditionalValues: [DateTime.MinValue, DateTime.MaxValue]); + + yield return new TestData( + Value: new(new DateTime(2021, 1, 1), TimeSpan.Zero), + AdditionalValues: [DateTimeOffset.MinValue, DateTimeOffset.MaxValue], + ExpectedJsonSchema: """{"type":"string","format": "date-time"}"""); + + yield return new TestData( + Value: new(hours: 5, minutes: 13, seconds: 3), + AdditionalValues: [TimeSpan.MinValue, TimeSpan.MaxValue], + ExpectedJsonSchema: """{"$comment": "Represents a System.TimeSpan value.", "type":"string", "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"}"""); + +#if NET6_0_OR_GREATER + yield return new TestData(new(2021, 1, 1), ExpectedJsonSchema: """{"type":"string","format": "date"}"""); + yield return new TestData(new(hour: 22, minute: 30, second: 33, millisecond: 100), ExpectedJsonSchema: """{"type":"string","format": "time"}"""); +#endif + yield return new TestData(Guid.Empty); + yield return new TestData(new("http://example.com"), ExpectedJsonSchema: """{"type":["string","null"], "format":"uri"}"""); + yield return new TestData(new(1, 2, 3, 4), ExpectedJsonSchema: """{"$comment":"Represents a version string.", "type":["string","null"],"pattern":"^\\d+(\\.\\d+){1,3}$"}"""); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]""").RootElement, ExpectedJsonSchema: "true"); + yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); + yield return new TestData((JsonValue)42, ExpectedJsonSchema: "true"); + yield return new TestData(new() { ["x"] = 42 }, ExpectedJsonSchema: """{"type":["object","null"]}"""); + yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"]}"""); + + // Enum types + yield return new TestData(IntEnum.A, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(StringEnum.A, ExpectedJsonSchema: """{"enum": ["A","B","C"]}"""); + yield return new TestData(FlagsStringEnum.A, ExpectedJsonSchema: """{"type":"string"}"""); + + // Nullable types + yield return new TestData(true, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["boolean","null"]}"""); + yield return new TestData(42, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); + yield return new TestData(3.14, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["number","null"]}"""); + yield return new TestData(Guid.Empty, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["string","null"],"format":"uuid"}"""); + yield return new TestData(JsonDocument.Parse("{}").RootElement, AdditionalValues: [null], ExpectedJsonSchema: "true"); + yield return new TestData(IntEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); + yield return new TestData(StringEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"enum":["A","B","C",null]}"""); + yield return new TestData( + new(1, "two", true, 3.14), + AdditionalValues: [null], + ExpectedJsonSchema: """ + { + "type":["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + """); + + // User-defined POCOs + yield return new TestData( + Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + AdditionalValues: [new() { String = "str", StringNullable = null }, null], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + } + } + """); + + // Same as above but with nullable types set to non-nullable + yield return new TestData( + Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + AdditionalValues: [new() { String = "str", StringNullable = null }], + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + } + } + """, + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + + yield return new TestData( + Value: new(1, "two", true, 3.14), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X","Y","Z","W"] + } + """); + + yield return new TestData( + Value: new(1, "two", true, 3.14), + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + } + } + """); + + yield return new TestData( + Value: new(1, "two", true, 3.14, StringEnum.A), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X1": { "type": "integer" }, + "X2": { "type": "string" }, + "X3": { "type": "boolean" }, + "X4": { "type": "number" }, + "X5": { "enum": ["A", "B", "C"] }, + "Y1": { "type": "integer", "default": 42 }, + "Y2": { "type": "string", "default": "str" }, + "Y3": { "type": "boolean", "default": true }, + "Y4": { "type": "number", "default": 0 }, + "Y5": { "enum": ["A", "B", "C"], "default": "A" } + }, + "required": ["X1", "X2", "X3", "X4", "X5"] + } + """); + + yield return new TestData( + new() { X = "str1", Y = "str2" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Y": { "type": "string" }, + "Z": { "type": "integer" }, + "X": { "type": "string" } + }, + "required": [ "Y", "Z", "X" ] + } + """); + + yield return new TestData( + new() { X = 1, Y = 2 }, + ExpectedJsonSchema: """ + { + "type": [ "object", "null" ], + "properties": { + "X": { "type": "integer" } + } + } + """); + yield return new TestData( + Value: new() { IntegerProperty = 1, StringProperty = "str" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "int": { "type": "integer" }, + "str": { "type": [ "string", "null"] } + } + } + """); + + yield return new TestData( + Value: new() { X = 1 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { "X": { "type": ["string","integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" } } + } + """); + + yield return new TestData( + Value: new() { X = 1, Y = 2, Z = 3 }, + AdditionalValues: [ + new() { X = 1, Y = double.NaN, Z = 3 }, + new() { X = 1, Y = double.PositiveInfinity, Z = 3 }, + new() { X = 1, Y = double.NegativeInfinity, Z = 3 }, + ], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { "type": ["string", "integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" }, + "Y": { + "anyOf": [ + { "type": "number" }, + { "enum": ["NaN", "Infinity", "-Infinity"]} + ] + }, + "Z": { "type": ["string", "integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" }, + "W" : { "type": "number" } + } + } + """); + + yield return new TestData( + Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, + AdditionalValues: [null, new() { Value = 1, Next = null }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { "$ref": "#/properties/Next" } + } + } + } + } + """); + + // Same as above but with non-nullable reference types by default. + yield return new TestData( + Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, + AdditionalValues: [new() { Value = 1, Next = null }], + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "Value": { "type": "integer" }, + "Next": { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { "$ref": "#/properties/Next" } + } + } + } + } + """, + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/108764 gets backported + SimpleRecord recordValue = new(42, "str", true, 3.14); + yield return new TestData( + Value: new() { Value1 = recordValue, Value2 = recordValue, ArrayValue = [recordValue], ListValue = [recordValue] }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value1": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + }, + /* The same type on a different property is repeated to + account for potential metadata resolved from attributes. */ + "Value2": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + }, + /* This collection element is the first occurrence + of the type without contextual metadata. */ + "ListValue": { + "type": ["array","null"], + "items": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + } + }, + /* This collection element is the second occurrence + of the type which points to the first occurrence. */ + "ArrayValue": { + "type": ["array","null"], + "items": { + "$ref": "#/properties/ListValue/items" + } + } + } + } + """); +#endif + + yield return new TestData( + Value: new() { X = 42 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { + "type": "integer" + } + } + } + """); + + yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: "true"); + yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Value":true}}"""); + yield return new TestData( + Value: new() + { + IntEnum = IntEnum.A, + StringEnum = StringEnum.B, + IntEnumUsingStringConverter = IntEnum.A, + NullableIntEnumUsingStringConverter = IntEnum.B, + StringEnumUsingIntConverter = StringEnum.A, + NullableStringEnumUsingIntConverter = StringEnum.B + }, + AdditionalValues: [ + new() + { + IntEnum = (IntEnum)int.MaxValue, + StringEnum = StringEnum.A, + IntEnumUsingStringConverter = IntEnum.A, + NullableIntEnumUsingStringConverter = null, + StringEnumUsingIntConverter = (StringEnum)int.MaxValue, + NullableStringEnumUsingIntConverter = null + }, + ], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "IntEnum": { "type": "integer" }, + "StringEnum": { "enum": [ "A", "B", "C" ] }, + "IntEnumUsingStringConverter": { "enum": [ "A", "B", "C" ] }, + "NullableIntEnumUsingStringConverter": { "enum": [ "A", "B", "C", null ] }, + "StringEnumUsingIntConverter": { "type": "integer" }, + "NullableStringEnumUsingIntConverter": { "type": [ "integer", "null" ] } + } + } + """); + + var recordStruct = new SimpleRecordStruct(42, "str", true, 3.14); + yield return new TestData( + Value: new() { Struct = recordStruct, NullableStruct = null }, + AdditionalValues: [new() { Struct = recordStruct, NullableStruct = recordStruct }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Struct": { + "type": "object", + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + }, + "NullableStruct": { + "type": ["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + } + } + """); + + yield return new TestData( + Value: new() { NullableStruct = null, Struct = recordStruct }, + AdditionalValues: [new() { NullableStruct = recordStruct, Struct = recordStruct }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "NullableStruct": { + "type": ["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + }, + "Struct": { + "type": "object", + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + } + } + """); + + yield return new TestData( + Value: new() { Name = "name", ExtensionData = new() { ["x"] = 42 } }, + ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Name":{"type":["string","null"]}}}"""); + + yield return new TestData( + Value: new() { Name = "name", Age = 42 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Name": {"type":["string","null"]}, + "Age": {"type":"integer"} + }, + "additionalProperties": false + } + """); + +#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 }, + AdditionalValues: [new() { String = "str", StringNullable = null }, null], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + }, + "additionalProperties": false + } + """, + Options: new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }); +#endif + + yield return new TestData( + Value: new() { MaybeNull = null!, AllowNull = null, NotNull = null, DisallowNull = null!, NotNullDisallowNull = "str" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "MaybeNull": {"type":["string","null"]}, + "AllowNull": {"type":["string","null"]}, + "NotNull": {"type":["string","null"]}, + "DisallowNull": {"type":["string","null"]}, + "NotNullDisallowNull": {"type":"string"} + } + } + """); + + yield return new TestData( + Value: new(allowNull: null, disallowNull: "str"), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "AllowNull": {"type":["string","null"]}, + "DisallowNull": {"type":"string"} + }, + "required": ["AllowNull", "DisallowNull"] + } + """); + + yield return new TestData( + Value: new(null), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": {"type":["string","null"]} + }, + "required": ["Value"] + } + """); + + yield return new TestData( + Value: new(), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X1": {"type":"string", "default": "str" }, + "X2": {"type":"integer", "default": 42 }, + "X3": {"type":"boolean", "default": true }, + "X4": {"type":"number", "default": 0 }, + "X5": {"enum":["A","B","C"], "default": "A" }, + "X6": {"type":["string","null"], "default": "str" }, + "X7": {"type":["integer","null"], "default": 42 }, + "X8": {"type":["boolean","null"], "default": true }, + "X9": {"type":["number","null"], "default": 0 }, + "X10": {"enum":["A","B","C", null], "default": "A" } + } + } + """); + + yield return new TestData>( + Value: new(null!), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": {"type":["string","null"]} + }, + "required": ["Value"] + } + """); + + yield return new TestData( + Value: new PocoWithPolymorphism.DerivedPocoStringDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + AdditionalValues: [ + new PocoWithPolymorphism.DerivedPocoNoDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + new PocoWithPolymorphism.DerivedPocoIntDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + new PocoWithPolymorphism.DerivedCollection { BaseValue = 42 }, + new PocoWithPolymorphism.DerivedDictionary { BaseValue = 42 }, + ], + + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "anyOf": [ + { + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + }, + { + "properties": { + "$type": {"const":"derivedPoco"}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":42}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedCollection"}, + "$values": { + "type": "array", + "items": {"type":"integer"} + } + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedDictionary"} + }, + "additionalProperties":{"type": "integer"}, + "required": ["$type"] + } + ] + } + """); + + yield return new TestData( + Value: new NonAbstractClassWithSingleDerivedType(), + AdditionalValues: [new NonAbstractClassWithSingleDerivedType.Derived()], + ExpectedJsonSchema: """ + { + "type": ["object","null"] + } + """); + +#if !NET9_0 // Disable until https://github.com/microsoft/semantic-kernel/issues/8983 gets backported to .NET 9 + yield return new TestData( + Value: new(value: null), + AdditionalValues: [new(true), new(42), new(""), new(new object()), new(Array.Empty())], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": { "default": null } + } + } + """); +#endif + + yield return new TestData( + Value: new(), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "PolymorphicValue": { + "type": "object", + "anyOf": [ + { + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + }, + { + "properties": { + "$type": {"const":"derivedPoco"}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":42}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedCollection"}, + "$values": { + "type": "array", + "items": {"type":"integer"} + } + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedDictionary"} + }, + "additionalProperties":{"type": "integer"}, + "required": ["$type"] + } + ] + }, + "DerivedValue1": { + "type": "object", + "properties": { + "BaseValue": { + "type": "integer" + }, + "DerivedValue": { + "type": [ + "string", + "null" + ] + } + } + }, + "DerivedValue2": { + "type": "object", + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + } + } + } + """); + + yield return new TestData( + Value: new("string", -1), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "StringValue": {"type":"string","pattern":"\\w+"}, + "IntValue": {"type":"integer","default":42} + }, + "required": ["StringValue","IntValue"] + } + """, + ExporterOptions: new() + { + TransformSchemaNode = static (ctx, schema) => + { + if (ctx.PropertyInfo is null || schema is not JsonObject jObj) + { + return schema; + } + + if (ctx.ResolveAttribute() is { } attr) + { + jObj["default"] = JsonSerializer.SerializeToNode(attr.Value); + } + + if (ctx.ResolveAttribute() is { } regexAttr) + { + jObj["pattern"] = regexAttr.Pattern; + } + + return jObj; + } + }); + + // Collection types + yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"integer"}}"""); + yield return new TestData>([false, true, false], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData>(["one", "two", "three"], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(new([1.1, 2.2, 3.3]), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"number"}}"""); + yield return new TestData>(new(['x', '2', '+']), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":1}}"""); + yield return new TestData>(ImmutableArray.Create(1, 2, 3), ExpectedJsonSchema: """{"type":"array","items":{"type":"integer"}}"""); + yield return new TestData>(ImmutableList.Create("one", "two", "three"), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(ImmutableQueue.Create(false, false, true), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + + // Dictionary types + yield return new TestData>( + Value: new() { ["one"] = 1, ["two"] = 2, ["three"] = 3 }, + ExpectedJsonSchema: """{"type":["object","null"],"additionalProperties":{"type": "integer"}}"""); + + yield return new TestData>( + Value: new([new("one", 1), new("two", 2), new("three", 3)]), + ExpectedJsonSchema: """{"type":"object","additionalProperties":{"type": "integer"}}"""); + + yield return new TestData>( + Value: new() { [1] = "one", [2] = "two", [3] = "three" }, + ExpectedJsonSchema: """{"type":["object","null"],"additionalProperties":{"type": ["string","null"]}}"""); + + yield return new TestData>( + Value: new() + { + ["one"] = new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + ["two"] = new() { String = "string", StringNullable = null, Int = 42, Double = 3.14, Boolean = true }, + ["three"] = new() { String = "string", StringNullable = null, Int = 42, Double = 3.14, Boolean = true }, + }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "additionalProperties": { + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + }, + "type": ["object","null"] + } + } + """); + + yield return new TestData>( + Value: new() { ["one"] = 1, ["two"] = "two", ["three"] = 3.14 }, + ExpectedJsonSchema: """{"type":["object","null"]}"""); + + yield return new TestData( + Value: new() { ["one"] = 1, ["two"] = "two", ["three"] = 3.14 }, + ExpectedJsonSchema: """{"type":["object","null"]}"""); + } + + public enum IntEnum { A, B, C } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum StringEnum { A, B, C } + + [Flags, JsonConverter(typeof(JsonStringEnumConverter))] + public enum FlagsStringEnum { A = 1, B = 2, C = 4 } + + public class SimplePoco + { + public string String { get; set; } = "default"; + public string? StringNullable { get; set; } + + public int Int { get; set; } + public double Double { get; set; } + public bool Boolean { get; set; } + } + + public record SimpleRecord(int X, string Y, bool Z, double W); + public record struct SimpleRecordStruct(int X, string Y, bool Z, double W); + + public record RecordWithOptionalParameters( + [property: Description("required integer")] int X1, string X2, bool X3, double X4, [Description("required string enum")] StringEnum X5, + [property: Description("optional integer")] int Y1 = 42, string Y2 = "str", bool Y3 = true, double Y4 = 0, [Description("optional string enum")] StringEnum Y5 = StringEnum.A); + + public class PocoWithRequiredMembers + { + [JsonInclude] + public required string X; + + public required string Y { get; set; } + + [JsonRequired] + public int Z { get; set; } + } + + public class PocoWithIgnoredMembers + { + public int X { get; set; } + + [JsonIgnore] + public int Y { get; set; } + } + + public class PocoWithCustomNaming + { + [JsonPropertyName("int")] + public int IntegerProperty { get; set; } + + [JsonPropertyName("str")] + public string? StringProperty { get; set; } + } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public class PocoWithCustomNumberHandling + { + public int X { get; set; } + } + + public class PocoWithCustomNumberHandlingOnProperties + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public int X { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] + public double Y { get; set; } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public int Z { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] + public decimal W { get; set; } + } + + public class PocoWithRecursiveMembers + { + public int Value { get; init; } + public PocoWithRecursiveMembers? Next { get; init; } + } + + public class PocoWithNonRecursiveDuplicateOccurrences + { + public SimpleRecord? Value1 { get; set; } + public SimpleRecord? Value2 { get; set; } + public List? ListValue { get; set; } + public SimpleRecord[]? ArrayValue { get; set; } + } + + [Description("The type description")] + public class PocoWithDescription + { + [Description("The property description")] + public int X { get; set; } + } + + [JsonConverter(typeof(CustomConverter))] + public class PocoWithCustomConverter + { + public int Value { get; set; } + + public class CustomConverter : JsonConverter + { + public override PocoWithCustomConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new PocoWithCustomConverter { Value = reader.GetInt32() }; + + public override void Write(Utf8JsonWriter writer, PocoWithCustomConverter value, JsonSerializerOptions options) => + writer.WriteNumberValue(value.Value); + } + } + + public class PocoWithCustomPropertyConverter + { + [JsonConverter(typeof(CustomConverter))] + public int Value { get; set; } + + public class CustomConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => int.Parse(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + } + } + + public class PocoWithEnums + { + public IntEnum IntEnum { get; init; } + public StringEnum StringEnum { get; init; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public IntEnum IntEnumUsingStringConverter { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public IntEnum? NullableIntEnumUsingStringConverter { get; set; } + + [JsonConverter(typeof(JsonNumberEnumConverter))] + public StringEnum StringEnumUsingIntConverter { get; set; } + + [JsonConverter(typeof(JsonNumberEnumConverter))] + public StringEnum? NullableStringEnumUsingIntConverter { get; set; } + } + + public class PocoWithStructFollowedByNullableStruct + { + public SimpleRecordStruct? NullableStruct { get; set; } + public SimpleRecordStruct Struct { get; set; } + } + + public class PocoWithNullableStructFollowedByStruct + { + public SimpleRecordStruct? NullableStruct { get; set; } + public SimpleRecordStruct Struct { get; set; } + } + + public class PocoWithExtensionDataProperty + { + public string? Name { get; set; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public class PocoDisallowingUnmappedMembers + { + public string? Name { get; set; } + public int Age { get; set; } + } + + public class PocoWithNullableAnnotationAttributes + { + [MaybeNull] + public string MaybeNull { get; set; } + + [AllowNull] + public string AllowNull { get; set; } + + [NotNull] + public string? NotNull { get; set; } + + [DisallowNull] + public string? DisallowNull { get; set; } + + [NotNull, DisallowNull] + public string? NotNullDisallowNull { get; set; } = ""; + } + + public class PocoWithNullableAnnotationAttributesOnConstructorParams([AllowNull] string allowNull, [DisallowNull] string? disallowNull) + { + public string AllowNull { get; } = allowNull!; + public string DisallowNull { get; } = disallowNull; + } + + public class PocoWithNullableConstructorParameter(string? value) + { + public string Value { get; } = value!; + } + + public class PocoWithOptionalConstructorParams( + string x1 = "str", int x2 = 42, bool x3 = true, double x4 = 0, StringEnum x5 = StringEnum.A, + string? x6 = "str", int? x7 = 42, bool? x8 = true, double? x9 = 0, StringEnum? x10 = StringEnum.A) + { + public string X1 { get; } = x1; + public int X2 { get; } = x2; + public bool X3 { get; } = x3; + public double X4 { get; } = x4; + public StringEnum X5 { get; } = x5; + + public string? X6 { get; } = x6; + public int? X7 { get; } = x7; + public bool? X8 { get; } = x8; + public double? X9 { get; } = x9; + public StringEnum? X10 { get; } = x10; + } + + // Regression test for https://github.com/dotnet/runtime/issues/92487 + public class GenericPocoWithNullableConstructorParameter(T value) + { + [NotNull] + public T Value { get; } = value!; + } + + [JsonDerivedType(typeof(DerivedPocoNoDiscriminator))] + [JsonDerivedType(typeof(DerivedPocoStringDiscriminator), "derivedPoco")] + [JsonDerivedType(typeof(DerivedPocoIntDiscriminator), 42)] + [JsonDerivedType(typeof(DerivedCollection), "derivedCollection")] + [JsonDerivedType(typeof(DerivedDictionary), "derivedDictionary")] + public abstract class PocoWithPolymorphism + { + public int BaseValue { get; set; } + + public class DerivedPocoNoDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedPocoStringDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedPocoIntDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedCollection : PocoWithPolymorphism, IEnumerable + { + public IEnumerator GetEnumerator() => Enumerable.Repeat(BaseValue, 1).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class DerivedDictionary : PocoWithPolymorphism, IReadOnlyDictionary + { + public int this[string key] => key == nameof(BaseValue) ? BaseValue : throw new KeyNotFoundException(); + public IEnumerable Keys => [nameof(BaseValue)]; + public IEnumerable Values => [BaseValue]; + public int Count => 1; + public bool ContainsKey(string key) => key == nameof(BaseValue); + public bool TryGetValue(string key, out int value) => key == nameof(BaseValue) ? (value = BaseValue) == BaseValue : (value = 0) == 0; + public IEnumerator> GetEnumerator() => Enumerable.Repeat(new KeyValuePair(nameof(BaseValue), BaseValue), 1).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + + [JsonDerivedType(typeof(NonAbstractClassWithSingleDerivedType.Derived))] + public class NonAbstractClassWithSingleDerivedType + { + public class Derived : NonAbstractClassWithSingleDerivedType; + } + + public class PocoCombiningPolymorphicTypeAndDerivedTypes + { + public PocoWithPolymorphism PolymorphicValue { get; set; } = new PocoWithPolymorphism.DerivedPocoNoDiscriminator { DerivedValue = "derived" }; + public PocoWithPolymorphism.DerivedPocoNoDiscriminator DerivedValue1 { get; set; } = new() { DerivedValue = "derived" }; + public PocoWithPolymorphism.DerivedPocoStringDiscriminator DerivedValue2 { get; set; } = new() { DerivedValue = "derived" }; + } + + public class ClassWithComponentModelAttributes + { + public ClassWithComponentModelAttributes(string stringValue, [DefaultValue(42)] int intValue) + { + StringValue = stringValue; + IntValue = intValue; + } + + [RegularExpression(@"\w+")] + public string StringValue { get; } + + public int IntValue { get; } + } + + public class ClassWithOptionalObjectParameter(object? value = null) + { + public object? Value { get; } = value; + } + + public readonly struct StructDictionary(IEnumerable> values) + : IReadOnlyDictionary + where TKey : notnull + { + private readonly IReadOnlyDictionary _dictionary = values.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + public TValue this[TKey key] => _dictionary[key]; + public IEnumerable Keys => _dictionary.Keys; + public IEnumerable Values => _dictionary.Values; + public int Count => _dictionary.Count; + public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); + public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); +#if NETCOREAPP + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetValue(key, out value); +#else + public bool TryGetValue(TKey key, out TValue value) => _dictionary.TryGetValue(key, out value); +#endif + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_dictionary).GetEnumerator(); + } + + [JsonSerializable(typeof(object))] + [JsonSerializable(typeof(bool))] + [JsonSerializable(typeof(byte))] + [JsonSerializable(typeof(ushort))] + [JsonSerializable(typeof(uint))] + [JsonSerializable(typeof(ulong))] + [JsonSerializable(typeof(sbyte))] + [JsonSerializable(typeof(short))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(long))] + [JsonSerializable(typeof(float))] + [JsonSerializable(typeof(double))] + [JsonSerializable(typeof(decimal))] +#if NET7_0_OR_GREATER + [JsonSerializable(typeof(UInt128))] + [JsonSerializable(typeof(Int128))] +#endif +#if NET6_0_OR_GREATER + [JsonSerializable(typeof(Half))] +#endif + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(char))] + [JsonSerializable(typeof(byte[]))] + [JsonSerializable(typeof(Memory))] + [JsonSerializable(typeof(ReadOnlyMemory))] + [JsonSerializable(typeof(DateTime))] + [JsonSerializable(typeof(DateTimeOffset))] + [JsonSerializable(typeof(TimeSpan))] +#if NET6_0_OR_GREATER + [JsonSerializable(typeof(DateOnly))] + [JsonSerializable(typeof(TimeOnly))] +#endif + [JsonSerializable(typeof(Guid))] + [JsonSerializable(typeof(Uri))] + [JsonSerializable(typeof(Version))] + [JsonSerializable(typeof(JsonDocument))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonValue))] + [JsonSerializable(typeof(JsonObject))] + [JsonSerializable(typeof(JsonArray))] + // Enum types + [JsonSerializable(typeof(IntEnum))] + [JsonSerializable(typeof(StringEnum))] + [JsonSerializable(typeof(FlagsStringEnum))] + // Nullable types + [JsonSerializable(typeof(bool?))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(double?))] + [JsonSerializable(typeof(Guid?))] + [JsonSerializable(typeof(JsonElement?))] + [JsonSerializable(typeof(IntEnum?))] + [JsonSerializable(typeof(StringEnum?))] + [JsonSerializable(typeof(SimpleRecordStruct?))] + // User-defined POCOs + [JsonSerializable(typeof(SimplePoco))] + [JsonSerializable(typeof(SimpleRecord))] + [JsonSerializable(typeof(SimpleRecordStruct))] + [JsonSerializable(typeof(RecordWithOptionalParameters))] + [JsonSerializable(typeof(PocoWithRequiredMembers))] + [JsonSerializable(typeof(PocoWithIgnoredMembers))] + [JsonSerializable(typeof(PocoWithCustomNaming))] + [JsonSerializable(typeof(PocoWithCustomNumberHandling))] + [JsonSerializable(typeof(PocoWithCustomNumberHandlingOnProperties))] + [JsonSerializable(typeof(PocoWithRecursiveMembers))] + [JsonSerializable(typeof(PocoWithNonRecursiveDuplicateOccurrences))] + [JsonSerializable(typeof(PocoWithDescription))] + [JsonSerializable(typeof(PocoWithCustomConverter))] + [JsonSerializable(typeof(PocoWithCustomPropertyConverter))] + [JsonSerializable(typeof(PocoWithEnums))] + [JsonSerializable(typeof(PocoWithStructFollowedByNullableStruct))] + [JsonSerializable(typeof(PocoWithNullableStructFollowedByStruct))] + [JsonSerializable(typeof(PocoWithExtensionDataProperty))] + [JsonSerializable(typeof(PocoDisallowingUnmappedMembers))] + [JsonSerializable(typeof(PocoWithNullableAnnotationAttributes))] + [JsonSerializable(typeof(PocoWithNullableAnnotationAttributesOnConstructorParams))] + [JsonSerializable(typeof(PocoWithNullableConstructorParameter))] + [JsonSerializable(typeof(PocoWithOptionalConstructorParams))] + [JsonSerializable(typeof(GenericPocoWithNullableConstructorParameter))] + [JsonSerializable(typeof(PocoWithPolymorphism))] + [JsonSerializable(typeof(NonAbstractClassWithSingleDerivedType))] + [JsonSerializable(typeof(PocoCombiningPolymorphicTypeAndDerivedTypes))] + [JsonSerializable(typeof(ClassWithComponentModelAttributes))] + [JsonSerializable(typeof(ClassWithOptionalObjectParameter))] + // Collection types + [JsonSerializable(typeof(int[]))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(HashSet))] + [JsonSerializable(typeof(Queue))] + [JsonSerializable(typeof(Stack))] + [JsonSerializable(typeof(ImmutableArray))] + [JsonSerializable(typeof(ImmutableList))] + [JsonSerializable(typeof(ImmutableQueue))] + [JsonSerializable(typeof(object[]))] + [JsonSerializable(typeof(System.Collections.ArrayList))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(SortedDictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Hashtable))] + [JsonSerializable(typeof(StructDictionary))] + [JsonSerializable(typeof(XElement))] + public partial class TestTypesContext : JsonSerializerContext; + + private static TAttribute? ResolveAttribute(this JsonSchemaExporterContext ctx) + where TAttribute : Attribute + { + // Resolve attributes from locations in the following order: + // 1. Property-level attributes + // 2. Parameter-level attributes and + // 3. Type-level attributes. + return +#if NET9_0_OR_GREATER + GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? + GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? +#else + GetAttrs(ctx.PropertyAttributeProvider) ?? + GetAttrs(ctx.ParameterInfo) ?? +#endif + GetAttrs(ctx.TypeInfo.Type); + + static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => + (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); + } +} diff --git a/test/Shared/Shared.Tests.csproj b/test/Shared/Shared.Tests.csproj index d7bfa1801e2..dc2a46d60d9 100644 --- a/test/Shared/Shared.Tests.csproj +++ b/test/Shared/Shared.Tests.csproj @@ -5,16 +5,23 @@ - $(NoWarn);CA1716 + $(NoWarn);CA1716;S104 $(TestNetCoreTargetFrameworks) $(TestNetCoreTargetFrameworks)$(ConditionalNet462) + + true + true + + + +