< Summary

Information
Line coverage
0%
Covered lines: 0
Uncovered lines: 313
Coverable lines: 313
Total lines: 512
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 160
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

C:\h\w\B31A098C\w\BB5A0A33\e\runtime-utils\Runner\runtime\src\libraries\System.Text.Json\src\System\Text\Json\Schema\JsonSchemaExporter.cs

#LineLine coverage
 1// Licensed to the .NET Foundation under one or more agreements.
 2// The .NET Foundation licenses this file to you under the MIT license.
 3
 4using System.Collections.Generic;
 5using System.Diagnostics;
 6using System.Diagnostics.CodeAnalysis;
 7using System.Globalization;
 8using System.Runtime.InteropServices;
 9using System.Text.Json.Nodes;
 10using System.Text.Json.Serialization;
 11using System.Text.Json.Serialization.Metadata;
 12
 13namespace System.Text.Json.Schema
 14{
 15    /// <summary>
 16    /// Functionality for exporting JSON schema from serialization contracts defined in <see cref="JsonTypeInfo"/>.
 17    /// </summary>
 18    public static class JsonSchemaExporter
 19    {
 20        /// <summary>
 21        /// Gets the JSON schema for <paramref name="type"/> as a <see cref="JsonNode"/> document.
 22        /// </summary>
 23        /// <param name="options">The options declaring the contract for the type.</param>
 24        /// <param name="type">The type for which to resolve a schema.</param>
 25        /// <param name="exporterOptions">The options object governing the export operation.</param>
 26        /// <returns>A JSON object containing the schema for <paramref name="type"/>.</returns>
 27        public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOpti
 028        {
 029            ArgumentNullException.ThrowIfNull(options);
 030            ArgumentNullException.ThrowIfNull(type);
 31
 032            ValidateOptions(options);
 033            JsonTypeInfo typeInfo = options.GetTypeInfoInternal(type);
 034            return typeInfo.GetJsonSchemaAsNode(exporterOptions);
 035        }
 36
 37        /// <summary>
 38        /// Gets the JSON schema for <paramref name="typeInfo"/> as a <see cref="JsonNode"/> document.
 39        /// </summary>
 40        /// <param name="typeInfo">The contract from which to resolve the JSON schema.</param>
 41        /// <param name="exporterOptions">The options object governing the export operation.</param>
 42        /// <returns>A JSON object containing the schema for <paramref name="typeInfo"/>.</returns>
 43        public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOption
 044        {
 045            ArgumentNullException.ThrowIfNull(typeInfo);
 46
 047            ValidateOptions(typeInfo.Options);
 048            exporterOptions ??= JsonSchemaExporterOptions.Default;
 49
 050            typeInfo.EnsureConfigured();
 051            GenerationState state = new(typeInfo.Options, exporterOptions);
 052            JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo);
 053            return schema.ToJsonNode(exporterOptions);
 054        }
 55
 56        private static JsonSchema MapJsonSchemaCore(
 57            ref GenerationState state,
 58            JsonTypeInfo typeInfo,
 59            JsonPropertyInfo? propertyInfo = null,
 60            JsonConverter? customConverter = null,
 61            JsonNumberHandling? customNumberHandling = null,
 62            JsonTypeInfo? parentPolymorphicTypeInfo = null,
 63            bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false,
 64            bool parentPolymorphicTypeIsNonNullable = false,
 65            KeyValuePair<string, JsonSchema>? typeDiscriminator = null,
 66            bool cacheResult = true)
 067        {
 068            Debug.Assert(typeInfo.IsConfigured);
 69
 070            JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, propertyInfo, parentPolymorphicTyp
 71
 072            if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None &&
 073                state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer))
 074            {
 75                // The schema context has already been generated in the schema document, return a reference to it.
 076                return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer });
 77            }
 78
 079            JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter;
 080            JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Opt
 081            if (effectiveConverter.GetSchema(effectiveNumberHandling) is { } schema)
 082            {
 83                // A schema has been provided by the converter.
 084                return CompleteSchema(ref state, schema);
 85            }
 86
 087            if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOpt
 088            {
 89                // This is the base type of a polymorphic type hierarchy. The schema for this type
 90                // will include an "anyOf" property with the schemas for all derived types.
 091                string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName;
 092                List<JsonDerivedType> derivedTypes = new(polyOptions.DerivedTypes);
 93
 094                if (!typeInfo.Type.IsAbstract && !IsPolymorphicTypeThatSpecifiesItselfAsDerivedType(typeInfo))
 095                {
 96                    // For non-abstract base types that haven't been explicitly configured,
 97                    // add a trivial schema to the derived types since we should support it.
 098                    derivedTypes.Add(new JsonDerivedType(typeInfo.Type));
 099                }
 100
 0101                bool containsTypesWithoutDiscriminator = derivedTypes.Exists(static derivedTypes => derivedTypes.TypeDis
 0102                JsonSchemaType schemaType = JsonSchemaType.Any;
 0103                List<JsonSchema>? anyOf = new(derivedTypes.Count);
 104
 0105                state.PushSchemaNode(JsonSchema.AnyOfPropertyName);
 106
 0107                foreach (JsonDerivedType derivedType in derivedTypes)
 0108                {
 0109                    Debug.Assert(derivedType.TypeDiscriminator is null or int or string);
 110
 0111                    KeyValuePair<string, JsonSchema>? derivedTypeDiscriminator = null;
 0112                    if (derivedType.TypeDiscriminator is { } discriminatorValue)
 0113                    {
 0114                        JsonNode discriminatorNode = discriminatorValue switch
 0115                        {
 0116                            string stringId => (JsonNode)stringId,
 0117                            _ => (JsonNode)(int)discriminatorValue,
 0118                        };
 119
 0120                        JsonSchema discriminatorSchema = new() { Constant = discriminatorNode };
 0121                        derivedTypeDiscriminator = new(typeDiscriminatorKey, discriminatorSchema);
 0122                    }
 123
 0124                    JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfoInternal(derivedType.DerivedType);
 125
 0126                    state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture));
 0127                    JsonSchema derivedSchema = MapJsonSchemaCore(
 0128                        ref state,
 0129                        derivedTypeInfo,
 0130                        parentPolymorphicTypeInfo: typeInfo,
 0131                        typeDiscriminator: derivedTypeDiscriminator,
 0132                        parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator,
 0133                        parentPolymorphicTypeIsNonNullable: propertyInfo is { IsGetNullable: false, IsSetNullable: false
 0134                        cacheResult: false);
 135
 0136                    state.PopSchemaNode();
 137
 138                    // Determine if all derived schemas have the same type.
 0139                    if (anyOf.Count == 0)
 0140                    {
 0141                        schemaType = derivedSchema.Type;
 0142                    }
 0143                    else if (schemaType != derivedSchema.Type)
 0144                    {
 0145                        schemaType = JsonSchemaType.Any;
 0146                    }
 147
 0148                    anyOf.Add(derivedSchema);
 0149                }
 150
 0151                state.PopSchemaNode();
 152
 0153                if (schemaType is not JsonSchemaType.Any)
 0154                {
 155                    // If all derived types have the same schema type, we can simplify the schema
 156                    // by moving the type keyword to the base schema and removing it from the derived schemas.
 0157                    foreach (JsonSchema derivedSchema in anyOf)
 0158                    {
 0159                        derivedSchema.Type = JsonSchemaType.Any;
 160
 0161                        if (derivedSchema.KeywordCount == 0)
 0162                        {
 163                            // if removing the type results in an empty schema,
 164                            // remove the anyOf array entirely since it's always true.
 0165                            anyOf = null;
 0166                            break;
 167                        }
 0168                    }
 0169                }
 170
 0171                return CompleteSchema(ref state, new()
 0172                {
 0173                    Type = schemaType,
 0174                    AnyOf = anyOf,
 0175                    // If all derived types have a discriminator, we can require it in the base schema.
 0176                    Required = containsTypesWithoutDiscriminator ? null : [typeDiscriminatorKey]
 0177                });
 178            }
 179
 0180            if (effectiveConverter.NullableElementConverter is { } elementConverter)
 0181            {
 0182                JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementConverter.Type!);
 0183                schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: elementConverter, cacheResult: f
 184
 0185                if (schema.Enum != null)
 0186                {
 0187                    Debug.Assert(elementTypeInfo.Type.IsEnum, "The enum keyword should only be populated by schemas for 
 0188                    schema.Enum.Add(null); // Append null to the enum array.
 0189                }
 190
 0191                return CompleteSchema(ref state, schema);
 192            }
 193
 0194            switch (typeInfo.Kind)
 195            {
 196                case JsonTypeInfoKind.Object:
 0197                    List<KeyValuePair<string, JsonSchema>>? properties = null;
 0198                    List<string>? required = null;
 0199                    JsonSchema? additionalProperties = null;
 200
 0201                    JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? type
 0202                    if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow)
 0203                    {
 0204                        additionalProperties = JsonSchema.CreateFalseSchema();
 0205                    }
 206
 0207                    if (typeDiscriminator is { } typeDiscriminatorPair)
 0208                    {
 0209                        (properties ??= []).Add(typeDiscriminatorPair);
 0210                        if (parentPolymorphicTypeContainsTypesWithoutDiscriminator)
 0211                        {
 212                            // Require the discriminator here since it's not common to all derived types.
 0213                            (required ??= []).Add(typeDiscriminatorPair.Key);
 0214                        }
 0215                    }
 216
 0217                    state.PushSchemaNode(JsonSchema.PropertiesPropertyName);
 0218                    foreach (JsonPropertyInfo property in typeInfo.Properties)
 0219                    {
 0220                        if (property is { Get: null, Set: null } or { IsExtensionData: true })
 0221                        {
 0222                            continue; // Skip JsonIgnored properties and extension data
 223                        }
 224
 0225                        state.PushSchemaNode(property.Name);
 0226                        JsonSchema propertySchema = MapJsonSchemaCore(
 0227                            ref state,
 0228                            property.JsonTypeInfo,
 0229                            propertyInfo: property,
 0230                            customConverter: property.EffectiveConverter,
 0231                            customNumberHandling: property.EffectiveNumberHandling);
 232
 0233                        state.PopSchemaNode();
 234
 0235                        if (property.AssociatedParameter is { HasDefaultValue: true } parameterInfo)
 0236                        {
 0237                            JsonSchema.EnsureMutable(ref propertySchema);
 0238                            propertySchema.DefaultValue = JsonSerializer.SerializeToNode(parameterInfo.DefaultValue, pro
 0239                            propertySchema.HasDefaultValue = true;
 0240                        }
 241
 0242                        (properties ??= []).Add(new(property.Name, propertySchema));
 243
 244                        // Mark as required if either the property is required or the associated constructor parameter i
 245                        // While the latter implies the former in cases where the JsonSerializerOptions.RespectRequiredC
 246                        // setting has been enabled, for the case of the schema exporter we always mark non-optional con
 0247                        if (property is { IsRequired: true } or { AssociatedParameter.IsRequiredParameter: true })
 0248                        {
 0249                            (required ??= []).Add(property.Name);
 0250                        }
 0251                    }
 252
 0253                    state.PopSchemaNode();
 0254                    return CompleteSchema(ref state, new()
 0255                    {
 0256                        Type = JsonSchemaType.Object,
 0257                        Properties = properties,
 0258                        Required = required,
 0259                        AdditionalProperties = additionalProperties,
 0260                    });
 261
 262                case JsonTypeInfoKind.Enumerable:
 0263                    Debug.Assert(typeInfo.ElementTypeInfo != null);
 264
 0265                    if (typeDiscriminator is null)
 0266                    {
 0267                        state.PushSchemaNode(JsonSchema.ItemsPropertyName);
 0268                        JsonSchema items = MapJsonSchemaCore(ref state, typeInfo.ElementTypeInfo, customNumberHandling: 
 0269                        state.PopSchemaNode();
 270
 0271                        return CompleteSchema(ref state, new()
 0272                        {
 0273                            Type = JsonSchemaType.Array,
 0274                            Items = items.IsTrue ? null : items,
 0275                        });
 276                    }
 277                    else
 0278                    {
 279                        // Polymorphic enumerable types are represented using a wrapping object:
 280                        // { "$type" : "discriminator", "$values" : [element1, element2, ...] }
 281                        // Which corresponds to the schema
 282                        // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "
 283                        const string ValuesKeyword = JsonSerializer.ValuesPropertyName;
 284
 0285                        state.PushSchemaNode(JsonSchema.PropertiesPropertyName);
 0286                        state.PushSchemaNode(ValuesKeyword);
 0287                        state.PushSchemaNode(JsonSchema.ItemsPropertyName);
 288
 0289                        JsonSchema items = MapJsonSchemaCore(ref state, typeInfo.ElementTypeInfo, customNumberHandling: 
 290
 0291                        state.PopSchemaNode();
 0292                        state.PopSchemaNode();
 0293                        state.PopSchemaNode();
 294
 0295                        return CompleteSchema(ref state, new()
 0296                        {
 0297                            Type = JsonSchemaType.Object,
 0298                            Properties =
 0299                            [
 0300                                typeDiscriminator.Value,
 0301                                new(ValuesKeyword,
 0302                                    new JsonSchema()
 0303                                    {
 0304                                        Type = JsonSchemaType.Array,
 0305                                        Items = items.IsTrue ? null : items,
 0306                                    }),
 0307                            ],
 0308                            Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? [typeDiscriminator.Value
 0309                        });
 310                    }
 311
 312                case JsonTypeInfoKind.Dictionary:
 0313                    Debug.Assert(typeInfo.ElementTypeInfo != null);
 314
 0315                    List<KeyValuePair<string, JsonSchema>>? dictProps = null;
 0316                    List<string>? dictRequired = null;
 317
 0318                    if (typeDiscriminator is { } dictDiscriminator)
 0319                    {
 0320                        dictProps = [dictDiscriminator];
 0321                        if (parentPolymorphicTypeContainsTypesWithoutDiscriminator)
 0322                        {
 323                            // Require the discriminator here since it's not common to all derived types.
 0324                            dictRequired = [dictDiscriminator.Key];
 0325                        }
 0326                    }
 327
 0328                    state.PushSchemaNode(JsonSchema.AdditionalPropertiesPropertyName);
 0329                    JsonSchema valueSchema = MapJsonSchemaCore(ref state, typeInfo.ElementTypeInfo, customNumberHandling
 0330                    state.PopSchemaNode();
 331
 0332                    return CompleteSchema(ref state, new()
 0333                    {
 0334                        Type = JsonSchemaType.Object,
 0335                        Properties = dictProps,
 0336                        Required = dictRequired,
 0337                        AdditionalProperties = valueSchema.IsTrue ? null : valueSchema,
 0338                    });
 339
 340                default:
 0341                    Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.None);
 342                    // Return a `true` schema for types with user-defined converters.
 0343                    return CompleteSchema(ref state, JsonSchema.CreateTrueSchema());
 344            }
 345
 346            JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema)
 0347            {
 0348                if (schema.Ref is null)
 0349                {
 0350                    if (IsNullableSchema(state.ExporterOptions))
 0351                    {
 0352                        schema.MakeNullable();
 0353                    }
 354
 355                    bool IsNullableSchema(JsonSchemaExporterOptions options)
 0356                    {
 357                        // A schema is marked as nullable if either:
 358                        // 1. We have a schema for a property where either the getter or setter are marked as nullable.
 359                        // 2. We have a schema for a Nullable<T> type.
 360                        // 3. We have a schema for a reference type, unless we're explicitly treating null-oblivious typ
 361
 0362                        if (propertyInfo is not null)
 0363                        {
 0364                            return propertyInfo.IsGetNullable || propertyInfo.IsSetNullable;
 365                        }
 366
 0367                        if (typeInfo.IsNullable)
 0368                        {
 0369                            return true;
 370                        }
 371
 0372                        return !typeInfo.Type.IsValueType && !parentPolymorphicTypeIsNonNullable && !options.TreatNullOb
 0373                    }
 0374                }
 375
 0376                if (state.ExporterOptions.TransformSchemaNode != null)
 0377                {
 378                    // Prime the schema for invocation by the JsonNode transformer.
 0379                    schema.ExporterContext = exporterContext;
 0380                }
 381
 0382                return schema;
 0383            }
 0384        }
 385
 386        private static void ValidateOptions(JsonSerializerOptions options)
 0387        {
 0388            if (options.ReferenceHandler == ReferenceHandler.Preserve)
 0389            {
 0390                ThrowHelper.ThrowNotSupportedException_JsonSchemaExporterDoesNotSupportReferenceHandlerPreserve();
 391            }
 392
 0393            options.MakeReadOnly();
 0394        }
 395
 396        private static bool IsPolymorphicTypeThatSpecifiesItselfAsDerivedType(JsonTypeInfo typeInfo)
 0397        {
 0398            Debug.Assert(typeInfo.PolymorphismOptions is not null);
 399
 0400            foreach (JsonDerivedType derivedType in typeInfo.PolymorphismOptions.DerivedTypes)
 0401            {
 0402                if (derivedType.DerivedType == typeInfo.Type)
 0403                {
 0404                    return true;
 405                }
 0406            }
 407
 0408            return false;
 0409        }
 410
 411        private readonly ref struct GenerationState(JsonSerializerOptions options, JsonSchemaExporterOptions exporterOpt
 412        {
 0413            private readonly List<string> _currentPath = [];
 0414            private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new();
 415
 0416            public int CurrentDepth => _currentPath.Count;
 0417            public JsonSerializerOptions Options { get; } = options;
 0418            public JsonSchemaExporterOptions ExporterOptions { get; } = exporterOptions;
 419
 420            public void PushSchemaNode(string nodeId)
 0421            {
 0422                if (CurrentDepth == Options.EffectiveMaxDepth)
 0423                {
 0424                    ThrowHelper.ThrowInvalidOperationException_JsonSchemaExporterDepthTooLarge();
 0425                }
 426
 0427                _currentPath.Add(nodeId);
 0428            }
 429
 430            public void PopSchemaNode()
 0431            {
 0432                Debug.Assert(CurrentDepth > 0);
 0433                _currentPath.RemoveAt(_currentPath.Count - 1);
 0434            }
 435
 436            /// <summary>
 437            /// Registers the current schema node generation context; if it has already been generated return a JSON poi
 438            /// </summary>
 439            public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? 
 0440            {
 0441                (JsonTypeInfo TypeInfo, JsonPropertyInfo? PropertyInfo) key = (context.TypeInfo, context.PropertyInfo);
 442#if NET
 0443                ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool ex
 444#else
 445                bool exists = _generated.TryGetValue(key, out string[]? pathToSchema);
 446#endif
 0447                if (exists)
 0448                {
 0449                    existingJsonPointer = FormatJsonPointer(pathToSchema);
 0450                    return true;
 451                }
 452#if NET
 0453                pathToSchema = context._path;
 454#else
 455                _generated[key] = context._path;
 456#endif
 0457                existingJsonPointer = null;
 0458                return false;
 0459            }
 460
 461            public JsonSchemaExporterContext CreateContext(JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo, JsonTy
 0462            {
 0463                return new JsonSchemaExporterContext(typeInfo, propertyInfo, baseTypeInfo, [.. _currentPath]);
 0464            }
 465
 466            private static string FormatJsonPointer(ReadOnlySpan<string> path)
 0467            {
 0468                if (path.IsEmpty)
 0469                {
 0470                    return "#";
 471                }
 472
 0473                using ValueStringBuilder sb = new(initialCapacity: path.Length * 10);
 0474                sb.Append('#');
 475
 0476                foreach (string segment in path)
 0477                {
 0478                    ReadOnlySpan<char> span = segment.AsSpan();
 0479                    sb.Append('/');
 480
 481                    do
 0482                    {
 483                        // Per RFC 6901 the characters '~' and '/' must be escaped.
 0484                        int pos = span.IndexOfAny('~', '/');
 0485                        if (pos < 0)
 0486                        {
 0487                            sb.Append(span);
 0488                            break;
 489                        }
 490
 0491                        sb.Append(span.Slice(0, pos));
 492
 0493                        if (span[pos] == '~')
 0494                        {
 0495                            sb.Append("~0");
 0496                        }
 497                        else
 0498                        {
 0499                            Debug.Assert(span[pos] == '/');
 0500                            sb.Append("~1");
 0501                        }
 502
 0503                        span = span.Slice(pos + 1);
 0504                    }
 0505                    while (!span.IsEmpty);
 0506                }
 507
 0508                return sb.ToString();
 0509            }
 510        }
 511    }
 512}