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