|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace System.Text.Json.Schema
{
/// <summary>
/// Functionality for exporting JSON schema from serialization contracts defined in <see cref="JsonTypeInfo"/>.
/// </summary>
public static class JsonSchemaExporter
{
/// <summary>
/// Gets the JSON schema for <paramref name="type"/> as a <see cref="JsonNode"/> document.
/// </summary>
/// <param name="options">The options declaring the contract for the type.</param>
/// <param name="type">The type for which to resolve a schema.</param>
/// <param name="exporterOptions">The options object governing the export operation.</param>
/// <returns>A JSON object containing the schema for <paramref name="type"/>.</returns>
public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOptions? exporterOptions = null)
{
if (options is null)
{
ThrowHelper.ThrowArgumentNullException(nameof(options));
}
if (type is null)
{
ThrowHelper.ThrowArgumentNullException(nameof(type));
}
ValidateOptions(options);
JsonTypeInfo typeInfo = options.GetTypeInfoInternal(type);
return typeInfo.GetJsonSchemaAsNode(exporterOptions);
}
/// <summary>
/// Gets the JSON schema for <paramref name="typeInfo"/> as a <see cref="JsonNode"/> document.
/// </summary>
/// <param name="typeInfo">The contract from which to resolve the JSON schema.</param>
/// <param name="exporterOptions">The options object governing the export operation.</param>
/// <returns>A JSON object containing the schema for <paramref name="typeInfo"/>.</returns>
public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOptions = null)
{
if (typeInfo is null)
{
ThrowHelper.ThrowArgumentNullException(nameof(typeInfo));
}
ValidateOptions(typeInfo.Options);
exporterOptions ??= JsonSchemaExporterOptions.Default;
typeInfo.EnsureConfigured();
GenerationState state = new(typeInfo.Options, exporterOptions);
JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo);
return schema.ToJsonNode(exporterOptions);
}
private static JsonSchema MapJsonSchemaCore(
ref GenerationState state,
JsonTypeInfo typeInfo,
JsonPropertyInfo? propertyInfo = null,
JsonConverter? customConverter = null,
JsonNumberHandling? customNumberHandling = null,
JsonTypeInfo? parentPolymorphicTypeInfo = null,
bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false,
bool parentPolymorphicTypeIsNonNullable = false,
KeyValuePair<string, JsonSchema>? typeDiscriminator = null,
bool cacheResult = true)
{
Debug.Assert(typeInfo.IsConfigured);
JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, propertyInfo, parentPolymorphicTypeInfo);
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 });
}
JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter;
JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling;
if (effectiveConverter.GetSchema(effectiveNumberHandling) is { } schema)
{
// A schema has been provided by the converter.
return CompleteSchema(ref state, schema);
}
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<JsonDerivedType> derivedTypes = new(polyOptions.DerivedTypes);
if (!typeInfo.Type.IsAbstract && !IsPolymorphicTypeThatSpecifiesItselfAsDerivedType(typeInfo))
{
// 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<JsonSchema>? anyOf = new(derivedTypes.Count);
state.PushSchemaNode(JsonSchema.AnyOfPropertyName);
foreach (JsonDerivedType derivedType in derivedTypes)
{
Debug.Assert(derivedType.TypeDiscriminator is null or int or string);
KeyValuePair<string, JsonSchema>? 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.GetTypeInfoInternal(derivedType.DerivedType);
state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture));
JsonSchema derivedSchema = MapJsonSchemaCore(
ref state,
derivedTypeInfo,
parentPolymorphicTypeInfo: typeInfo,
typeDiscriminator: derivedTypeDiscriminator,
parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator,
parentPolymorphicTypeIsNonNullable: propertyInfo is { IsGetNullable: false, IsSetNullable: false },
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;
}
}
}
return CompleteSchema(ref state, new()
{
Type = schemaType,
AnyOf = anyOf,
// If all derived types have a discriminator, we can require it in the base schema.
Required = containsTypesWithoutDiscriminator ? null : [typeDiscriminatorKey]
});
}
if (effectiveConverter.NullableElementConverter is { } elementConverter)
{
JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementConverter.Type!);
schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: elementConverter, 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<KeyValuePair<string, JsonSchema>>? properties = null;
List<string>? required = null;
JsonSchema? additionalProperties = null;
JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? typeInfo.Options.UnmappedMemberHandling;
if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow)
{
additionalProperties = JsonSchema.CreateFalseSchema();
}
if (typeDiscriminator is { } typeDiscriminatorPair)
{
(properties ??= []).Add(typeDiscriminatorPair);
if (parentPolymorphicTypeContainsTypesWithoutDiscriminator)
{
// Require the discriminator here since it's not common to all derived types.
(required ??= []).Add(typeDiscriminatorPair.Key);
}
}
state.PushSchemaNode(JsonSchema.PropertiesPropertyName);
foreach (JsonPropertyInfo property in typeInfo.Properties)
{
if (property is { Get: null, Set: null } or { IsExtensionData: true })
{
continue; // Skip JsonIgnored properties and extension data
}
state.PushSchemaNode(property.Name);
JsonSchema propertySchema = MapJsonSchemaCore(
ref state,
property.JsonTypeInfo,
propertyInfo: property,
customConverter: property.EffectiveConverter,
customNumberHandling: property.EffectiveNumberHandling);
state.PopSchemaNode();
if (property.AssociatedParameter is { HasDefaultValue: true } parameterInfo)
{
JsonSchema.EnsureMutable(ref propertySchema);
propertySchema.DefaultValue = JsonSerializer.SerializeToNode(parameterInfo.DefaultValue, property.JsonTypeInfo);
propertySchema.HasDefaultValue = true;
}
(properties ??= []).Add(new(property.Name, propertySchema));
// Mark as required if either the property is required or the associated constructor parameter is non-optional.
// While the latter implies the former in cases where the JsonSerializerOptions.RespectRequiredConstructorParameters
// setting has been enabled, for the case of the schema exporter we always mark non-optional constructor parameters as required.
if (property is { IsRequired: true } or { AssociatedParameter.IsRequiredParameter: true })
{
(required ??= []).Add(property.Name);
}
}
state.PopSchemaNode();
return CompleteSchema(ref state, new()
{
Type = JsonSchemaType.Object,
Properties = properties,
Required = required,
AdditionalProperties = additionalProperties,
});
case JsonTypeInfoKind.Enumerable:
Debug.Assert(typeInfo.ElementTypeInfo != null);
if (typeDiscriminator is null)
{
state.PushSchemaNode(JsonSchema.ItemsPropertyName);
JsonSchema items = MapJsonSchemaCore(ref state, typeInfo.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 = JsonSerializer.ValuesPropertyName;
state.PushSchemaNode(JsonSchema.PropertiesPropertyName);
state.PushSchemaNode(ValuesKeyword);
state.PushSchemaNode(JsonSchema.ItemsPropertyName);
JsonSchema items = MapJsonSchemaCore(ref state, typeInfo.ElementTypeInfo, customNumberHandling: effectiveNumberHandling);
state.PopSchemaNode();
state.PopSchemaNode();
state.PopSchemaNode();
return CompleteSchema(ref state, new()
{
Type = JsonSchemaType.Object,
Properties =
[
typeDiscriminator.Value,
new(ValuesKeyword,
new JsonSchema()
{
Type = JsonSchemaType.Array,
Items = items.IsTrue ? null : items,
}),
],
Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? [typeDiscriminator.Value.Key] : null,
});
}
case JsonTypeInfoKind.Dictionary:
Debug.Assert(typeInfo.ElementTypeInfo != null);
List<KeyValuePair<string, JsonSchema>>? dictProps = null;
List<string>? dictRequired = null;
if (typeDiscriminator is { } dictDiscriminator)
{
dictProps = [dictDiscriminator];
if (parentPolymorphicTypeContainsTypesWithoutDiscriminator)
{
// Require the discriminator here since it's not common to all derived types.
dictRequired = [dictDiscriminator.Key];
}
}
state.PushSchemaNode(JsonSchema.AdditionalPropertiesPropertyName);
JsonSchema valueSchema = MapJsonSchemaCore(ref state, typeInfo.ElementTypeInfo, 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);
// Return a `true` schema for types with user-defined converters.
return CompleteSchema(ref state, JsonSchema.CreateTrueSchema());
}
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
? propertyInfo.IsGetNullable || propertyInfo.IsSetNullable
: typeInfo.CanBeNull && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable;
if (isNullableSchema)
{
schema.MakeNullable();
}
}
if (state.ExporterOptions.TransformSchemaNode != null)
{
// Prime the schema for invocation by the JsonNode transformer.
schema.ExporterContext = exporterContext;
}
return schema;
}
}
private static void ValidateOptions(JsonSerializerOptions options)
{
if (options.ReferenceHandler == ReferenceHandler.Preserve)
{
ThrowHelper.ThrowNotSupportedException_JsonSchemaExporterDoesNotSupportReferenceHandlerPreserve();
}
options.MakeReadOnly();
}
private static bool IsPolymorphicTypeThatSpecifiesItselfAsDerivedType(JsonTypeInfo typeInfo)
{
Debug.Assert(typeInfo.PolymorphismOptions is not null);
foreach (JsonDerivedType derivedType in typeInfo.PolymorphismOptions.DerivedTypes)
{
if (derivedType.DerivedType == typeInfo.Type)
{
return true;
}
}
return false;
}
private readonly ref struct GenerationState(JsonSerializerOptions options, JsonSchemaExporterOptions exporterOptions)
{
private readonly List<string> _currentPath = [];
private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new();
public int CurrentDepth => _currentPath.Count;
public JsonSerializerOptions Options { get; } = options;
public JsonSchemaExporterOptions ExporterOptions { get; } = exporterOptions;
public void PushSchemaNode(string nodeId)
{
if (CurrentDepth == Options.EffectiveMaxDepth)
{
ThrowHelper.ThrowInvalidOperationException_JsonSchemaExporterDepthTooLarge();
}
_currentPath.Add(nodeId);
}
public void PopSchemaNode()
{
Debug.Assert(CurrentDepth > 0);
_currentPath.RemoveAt(_currentPath.Count - 1);
}
/// <summary>
/// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location.
/// </summary>
public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer)
{
(JsonTypeInfo TypeInfo, JsonPropertyInfo? PropertyInfo) 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, JsonPropertyInfo? propertyInfo, JsonTypeInfo? baseTypeInfo)
{
return new JsonSchemaExporterContext(typeInfo, propertyInfo, baseTypeInfo, [.. _currentPath]);
}
private static string FormatJsonPointer(ReadOnlySpan<string> path)
{
if (path.IsEmpty)
{
return "#";
}
using ValueStringBuilder sb = new(initialCapacity: path.Length * 10);
sb.Append('#');
foreach (string segment in path)
{
ReadOnlySpan<char> span = segment.AsSpan();
sb.Append('/');
do
{
// Per RFC 6901 the characters '~' and '/' must be escaped.
int pos = span.IndexOfAny('~', '/');
if (pos < 0)
{
sb.Append(span);
break;
}
sb.Append(span.Slice(0, pos));
if (span[pos] == '~')
{
sb.Append("~0");
}
else
{
Debug.Assert(span[pos] == '/');
sb.Append("~1");
}
span = span.Slice(pos + 1);
}
while (!span.IsEmpty);
}
return sb.ToString();
}
}
}
}
|