// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
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.Metadata;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Constraints;
namespace Microsoft.AspNetCore.OpenApi;
/// <summary>
/// Provides a set of extension methods for modifying the opaque JSON Schema type
/// that is provided by the underlying schema generator in System.Text.Json.
/// </summary>
internal static class JsonNodeSchemaExtensions
{
private static readonly Dictionary<Type, string> _simpleTypeToFormat = new()
{
[typeof(byte)] = "uint8",
// Note: byte format is deprecated per https://spec.openapis.org/registry/format/
// We should follow the >= 3.1 approach stated in https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0
// In addition, we should ensure that Microsoft.OpenApi will be able to serialize the >= 3.1 representation correctly when
// it's asked to serialize as < 3.1 document.
[typeof(byte[])] = "byte",
[typeof(int)] = "int32",
[typeof(uint)] = "uint32",
[typeof(long)] = "int64",
[typeof(ulong)] = "uint64",
[typeof(short)] = "int16",
[typeof(ushort)] = "uint16",
[typeof(float)] = "float",
[typeof(double)] = "double",
[typeof(decimal)] = "double",
[typeof(DateTime)] = "date-time",
[typeof(DateTimeOffset)] = "date-time",
[typeof(Guid)] = "uuid",
[typeof(char)] = "char",
[typeof(Uri)] = "uri",
[typeof(TimeOnly)] = "time",
[typeof(DateOnly)] = "date",
};
/// <summary>
/// Maps the given validation attributes to the target schema.
/// </summary>
/// <remarks>
/// OpenApi schema v3 supports the validation vocabulary supported by JSON Schema. Because the underlying
/// schema generator does not handle validation attributes to the validation vocabulary, we apply that mapping here.
///
/// Note that this method targets <see cref="JsonNode"/> and not <see cref="OpenApiSchema"/> because it is
/// designed to be invoked via the `OnGenerated` callback provided by the underlying schema generator
/// so that attributes can be mapped to the properties associated with inputs and outputs to a given request.
///
/// This implementation only supports mapping validation attributes that have an associated keyword in the
/// validation vocabulary.
///
/// Validation attributes are applied in a last-wins-order. For example, the following set of attributes:
///
/// [Range(1, 10), Min(5)]
///
/// will result in the schema having a minimum value of 5 and a maximum value of 10. This rule applies even
/// though the model binding layer in MVC applies all validation attributes on an argument. The following
/// set of attributes:
///
/// [Base64String]
/// [Url]
/// public string Url { get; }
///
/// will result in the schema having a type of "string" and a format of "uri" even though the model binding
/// layer will validate the string against *both* constraints.
/// </remarks>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="validationAttributes">A list of the validation attributes to apply.</param>
internal static void ApplyValidationAttributes(this JsonNode schema, IEnumerable<Attribute> validationAttributes)
{
foreach (var attribute in validationAttributes)
{
if (attribute is Base64StringAttribute)
{
schema[OpenApiSchemaKeywords.FormatKeyword] = "byte";
}
else if (attribute is RangeAttribute rangeAttribute)
{
decimal? minDecimal = null;
decimal? maxDecimal = null;
if (rangeAttribute.Minimum is int minimumInteger)
{
// The range was set with the RangeAttribute(int, int) constructor.
minDecimal = minimumInteger;
maxDecimal = (int)rangeAttribute.Maximum;
}
else
{
// Use InvariantCulture if explicitly requested or if the range has been set via the RangeAttribute(double, double) constructor.
var targetCulture = rangeAttribute.ParseLimitsInInvariantCulture || rangeAttribute.Minimum is double
? CultureInfo.InvariantCulture
: CultureInfo.CurrentCulture;
var minString = Convert.ToString(rangeAttribute.Minimum, targetCulture);
var maxString = Convert.ToString(rangeAttribute.Maximum, targetCulture);
if (decimal.TryParse(minString, NumberStyles.Any, targetCulture, out var value))
{
minDecimal = value;
}
if (decimal.TryParse(maxString, NumberStyles.Any, targetCulture, out value))
{
maxDecimal = value;
}
}
if (minDecimal is { } minValue)
{
schema[rangeAttribute.MinimumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMinimum : OpenApiSchemaKeywords.MinimumKeyword] = minValue;
}
if (maxDecimal is { } maxValue)
{
schema[rangeAttribute.MaximumIsExclusive ? OpenApiSchemaKeywords.ExclusiveMaximum : OpenApiSchemaKeywords.MaximumKeyword] = maxValue;
}
}
else if (attribute is RegularExpressionAttribute regularExpressionAttribute)
{
schema[OpenApiSchemaKeywords.PatternKeyword] = regularExpressionAttribute.Pattern;
}
else if (attribute is MaxLengthAttribute maxLengthAttribute)
{
var isArray = MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes && schemaTypes.HasFlag(JsonSchemaType.Array);
var key = isArray ? OpenApiSchemaKeywords.MaxItemsKeyword : OpenApiSchemaKeywords.MaxLengthKeyword;
schema[key] = maxLengthAttribute.Length;
}
else if (attribute is MinLengthAttribute minLengthAttribute)
{
var isArray = MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes && schemaTypes.HasFlag(JsonSchemaType.Array);
var key = isArray ? OpenApiSchemaKeywords.MinItemsKeyword : OpenApiSchemaKeywords.MinLengthKeyword;
schema[key] = minLengthAttribute.Length;
}
else if (attribute is LengthAttribute lengthAttribute)
{
var isArray = MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes && schemaTypes.HasFlag(JsonSchemaType.Array);
var targetKeySuffix = isArray ? "Items" : "Length";
schema[$"min{targetKeySuffix}"] = lengthAttribute.MinimumLength;
schema[$"max{targetKeySuffix}"] = lengthAttribute.MaximumLength;
}
else if (attribute is UrlAttribute)
{
schema[OpenApiSchemaKeywords.FormatKeyword] = "uri";
}
else if (attribute is StringLengthAttribute stringLengthAttribute)
{
schema[OpenApiSchemaKeywords.MinLengthKeyword] = stringLengthAttribute.MinimumLength;
schema[OpenApiSchemaKeywords.MaxLengthKeyword] = stringLengthAttribute.MaximumLength;
}
}
}
/// <summary>
/// Populate the default value into the current schema.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="defaultValue">An object representing the <see cref="object"/> associated with the default value.</param>
/// <param name="jsonTypeInfo">The <see cref="JsonTypeInfo"/> associated with the target type.</param>
internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValue, JsonTypeInfo? jsonTypeInfo)
{
if (jsonTypeInfo is null)
{
return;
}
var schemaAttribute = schema.WillBeComponentized()
? OpenApiConstants.RefDefaultAnnotation
: OpenApiSchemaKeywords.DefaultKeyword;
if (defaultValue is null)
{
schema[schemaAttribute] = null;
}
else
{
schema[schemaAttribute] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo);
}
}
/// <summary>
/// Applies the format of known types to the schema.
/// </summary>
/// <remarks>
/// OpenAPI hosts a format registry in https://spec.openapis.org/registry/format/.
/// See also https://json-schema.org/draft/2020-12/json-schema-validation#name-vocabularies-for-semantic-c
///
/// Note that this method targets <see cref="JsonNode"/> and not <see cref="OpenApiSchema"/> because
/// it is is designed to be invoked via the `OnGenerated` callback in the underlying schema generator as
/// opposed to after the generated schemas have been mapped to OpenAPI schemas.
/// </remarks>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the <see paramref="schema"/>.</param>
internal static void ApplyPrimitiveFormats(this JsonNode schema, JsonSchemaExporterContext context)
{
var type = context.TypeInfo.Type;
var underlyingType = Nullable.GetUnderlyingType(type);
if (_simpleTypeToFormat.TryGetValue(underlyingType ?? type, out var format))
{
schema[OpenApiSchemaKeywords.FormatKeyword] = format;
}
}
/// <summary>
/// Applies route constraints to the target schema.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="constraints">The list of <see cref="IRouteConstraint"/>s associated with the route parameter.</param>
internal static void ApplyRouteConstraints(this JsonNode schema, IEnumerable<IRouteConstraint> constraints)
{
// Apply constraints in reverse order because when it comes to the routing
// layer the first constraint that is violated causes routing to short circuit.
foreach (var constraint in Enumerable.Reverse(constraints))
{
if (constraint is MinRouteConstraint minRouteConstraint)
{
schema[OpenApiSchemaKeywords.MinimumKeyword] = minRouteConstraint.Min;
}
else if (constraint is MaxRouteConstraint maxRouteConstraint)
{
schema[OpenApiSchemaKeywords.MaximumKeyword] = maxRouteConstraint.Max;
}
else if (constraint is MinLengthRouteConstraint minLengthRouteConstraint)
{
schema[OpenApiSchemaKeywords.MinLengthKeyword] = minLengthRouteConstraint.MinLength;
}
else if (constraint is MaxLengthRouteConstraint maxLengthRouteConstraint)
{
schema[OpenApiSchemaKeywords.MaxLengthKeyword] = maxLengthRouteConstraint.MaxLength;
}
else if (constraint is RangeRouteConstraint rangeRouteConstraint)
{
schema[OpenApiSchemaKeywords.MinimumKeyword] = rangeRouteConstraint.Min;
schema[OpenApiSchemaKeywords.MaximumKeyword] = rangeRouteConstraint.Max;
}
else if (constraint is RegexRouteConstraint regexRouteConstraint)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = JsonSchemaType.String.ToString();
schema[OpenApiSchemaKeywords.FormatKeyword] = null;
schema[OpenApiSchemaKeywords.PatternKeyword] = regexRouteConstraint.Constraint.ToString();
}
else if (constraint is LengthRouteConstraint lengthRouteConstraint)
{
schema[OpenApiSchemaKeywords.MinLengthKeyword] = lengthRouteConstraint.MinLength;
schema[OpenApiSchemaKeywords.MaxLengthKeyword] = lengthRouteConstraint.MaxLength;
}
else if (constraint is FloatRouteConstraint or DecimalRouteConstraint or DoubleRouteConstraint)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = JsonSchemaType.Number.ToString();
schema[OpenApiSchemaKeywords.FormatKeyword] = constraint is FloatRouteConstraint ? "float" : "double";
}
else if (constraint is LongRouteConstraint or IntRouteConstraint)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = JsonSchemaType.Integer.ToString();
schema[OpenApiSchemaKeywords.FormatKeyword] = constraint is LongRouteConstraint ? "int64" : "int32";
}
else if (constraint is GuidRouteConstraint or StringRouteConstraint)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = JsonSchemaType.String.ToString();
schema[OpenApiSchemaKeywords.FormatKeyword] = constraint is GuidRouteConstraint ? "uuid" : null;
}
else if (constraint is BoolRouteConstraint)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = JsonSchemaType.Boolean.ToString();
schema[OpenApiSchemaKeywords.FormatKeyword] = null;
}
else if (constraint is AlphaRouteConstraint)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = JsonSchemaType.String.ToString();
schema[OpenApiSchemaKeywords.FormatKeyword] = null;
}
else if (constraint is DateTimeRouteConstraint)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = JsonSchemaType.String.ToString();
schema[OpenApiSchemaKeywords.FormatKeyword] = "date-time";
}
}
}
/// <summary>
/// Applies parameter-specific customizations to the target schema.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="parameterDescription">The <see cref="ApiParameterDescription"/> associated with the <see paramref="schema"/>.</param>
/// <param name="jsonTypeInfo">The <see cref="JsonTypeInfo"/> associated with the <see paramref="schema"/>.</param>
internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescription parameterDescription, JsonTypeInfo? jsonTypeInfo)
{
// This is special handling for parameters that are not bound from the body but represented in a complex type.
// For example:
//
// public class MyArgs
// {
// [Required]
// [Range(1, 10)]
// [FromQuery]
// public string Name { get; set; }
// }
//
// public IActionResult(MyArgs myArgs) { }
//
// In this case, the `ApiParameterDescription` object that we received will represent the `Name` property
// based on our model binding heuristics. In that case, to access the validation attributes that the
// model binder will respect we will need to get the property from the container type and map the
// attributes on it to the schema.
if (parameterDescription.ModelMetadata is { PropertyName: { }, ContainerType: { }, HasValidators: true, ValidatorMetadata: { } validations })
{
var attributes = validations.OfType<ValidationAttribute>();
schema.ApplyValidationAttributes(attributes);
}
if (parameterDescription.ModelMetadata is Mvc.ModelBinding.Metadata.DefaultModelMetadata { Attributes.PropertyAttributes.Count: > 0 } metadata &&
metadata.Attributes.PropertyAttributes.OfType<DefaultValueAttribute>().LastOrDefault() is { } metadataDefaultValueAttribute)
{
schema.ApplyDefaultValue(metadataDefaultValueAttribute.Value, jsonTypeInfo);
}
if (parameterDescription.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo })
{
if (parameterInfo.HasDefaultValue)
{
schema.ApplyDefaultValue(parameterInfo.DefaultValue, jsonTypeInfo);
}
else if (parameterInfo.GetCustomAttributes<DefaultValueAttribute>().LastOrDefault() is { } defaultValueAttribute)
{
schema.ApplyDefaultValue(defaultValueAttribute.Value, jsonTypeInfo);
}
if (parameterInfo.GetCustomAttributes<ValidationAttribute>() is { } validationAttributes)
{
schema.ApplyValidationAttributes(validationAttributes);
}
}
// Route constraints are only defined on parameters that are sourced from the path. Since
// they are encoded in the route template, and not in the type information based to the underlying
// schema generator we have to handle them separately here.
if (parameterDescription.RouteInfo?.Constraints is { } constraints)
{
schema.ApplyRouteConstraints(constraints);
}
// Parameters sourced from query, path, header, and form are bound via Enum.TryParse,
// which only accepts the original C# member names — not names transformed by a JSON
// naming policy (e.g. KebabCaseLower). Replace the schema's enum values and default
// value with the original member names so the OpenAPI spec matches what the server
// actually accepts.
if (parameterDescription.Source is { } source && IsNonBodyBindingSource(source)
&& parameterDescription.Type is { } paramType)
{
var enumType = Nullable.GetUnderlyingType(paramType) ?? paramType;
if (enumType.IsEnum && schema[OpenApiSchemaKeywords.EnumKeyword] is JsonArray)
{
var memberNames = Enum.GetNames(enumType);
var enumArray = new JsonArray();
foreach (var name in memberNames)
{
enumArray.Add((JsonNode)name);
}
schema[OpenApiSchemaKeywords.EnumKeyword] = enumArray;
// Also fix the default value — it was serialized using the naming policy
// (e.g. "high-priority") but should use the original member name
// (e.g. "HighPriority") to match what Enum.TryParse accepts. The default
// may be stored in "default" or "x-ref-default" depending on whether the
// schema was tagged for componentization.
var defaultKey = schema[OpenApiConstants.RefDefaultAnnotation] is not null
? OpenApiConstants.RefDefaultAnnotation
: OpenApiSchemaKeywords.DefaultKeyword;
if (jsonTypeInfo is not null
&& schema[defaultKey] is JsonNode defaultNode
&& defaultNode.GetValueKind() == JsonValueKind.String)
{
var defaultValue = defaultNode.GetValue<string>();
foreach (var memberName in memberNames)
{
var enumValue = Enum.Parse(enumType, memberName);
var serialized = JsonSerializer.SerializeToNode(enumValue, jsonTypeInfo);
if (serialized?.GetValue<string>() == defaultValue)
{
schema[defaultKey] = (JsonNode)memberName;
break;
}
}
}
}
}
if (parameterDescription.Source is { } bindingSource
&& DoesNotSupportNullValue(bindingSource)
&& MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes &&
schemaTypes.HasFlag(JsonSchemaType.Null))
{
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes & ~JsonSchemaType.Null).ToString();
}
// Parameters sourced from the header, query, route, and/or form cannot be nullable based on our binding
// rules but can be optional.
static bool DoesNotSupportNullValue(BindingSource bindingSource) => bindingSource == BindingSource.Header
|| bindingSource == BindingSource.Query
|| bindingSource == BindingSource.Path
|| bindingSource == BindingSource.Form
|| bindingSource == BindingSource.FormFile;
static bool IsNonBodyBindingSource(BindingSource bindingSource) => bindingSource == BindingSource.Header
|| bindingSource == BindingSource.Query
|| bindingSource == BindingSource.Path
|| bindingSource == BindingSource.Form
|| bindingSource == BindingSource.FormFile;
}
/// <summary>
/// Applies the polymorphism options defined by System.Text.Json to the target schema following OpenAPI v3's
/// conventions for the discriminator property.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
/// <param name="createSchemaReferenceId">A delegate that generates the reference ID to create for a type.</param>
internal static void MapPolymorphismOptionsToDiscriminator(this JsonNode schema, JsonSchemaExporterContext context, Func<JsonTypeInfo, string?> createSchemaReferenceId)
{
// The `context.BaseTypeInfo == null` check is used to ensure that we only apply the polymorphism options
// to the top-level schema and not to any nested schemas that are generated.
if (context.TypeInfo.PolymorphismOptions is { } polymorphismOptions && context.BaseTypeInfo == null)
{
// System.Text.Json supports serializing to a non-abstract base class if no discriminator is provided.
// OpenAPI requires that all polymorphic sub-schemas have an associated discriminator. If the base type
// doesn't declare itself as its own derived type via [JsonDerived], then it can't have a discriminator,
// which OpenAPI requires. In that case, we exit early to avoid mapping the polymorphism options
// to the `discriminator` property and return an un-discriminated `anyOf` schema instead.
if (IsNonAbstractTypeWithoutDerivedTypeReference(context))
{
return;
}
var baseSchemaReferenceId = createSchemaReferenceId(context.TypeInfo);
var mappings = new JsonObject();
foreach (var derivedType in polymorphismOptions.DerivedTypes)
{
if (derivedType.TypeDiscriminator is { } discriminator)
{
var jsonDerivedType = context.TypeInfo.Options.GetTypeInfo(derivedType.DerivedType);
// Discriminator mappings are only supported in OpenAPI v3+ so we can safely assume that
// the generated reference mappings will support the OpenAPI v3 schema reference format
// that we hardcode here. We could use `OpenApiReference` to construct the reference and
// serialize it but we use a hardcoded string here to avoid allocating a new object and
// working around Microsoft.OpenApi's serialization libraries.
mappings[$"{discriminator}"] = $"{baseSchemaReferenceId}{createSchemaReferenceId(jsonDerivedType)}";
}
}
schema[OpenApiSchemaKeywords.DiscriminatorKeyword] = polymorphismOptions.TypeDiscriminatorPropertyName;
schema[OpenApiSchemaKeywords.DiscriminatorMappingKeyword] = mappings;
if (baseSchemaReferenceId is not null && IsNonAbstractTypeWithDerivedTypeReference(context))
{
schema[OpenApiSchemaKeywords.DiscriminatorDefaultMappingKeyword] = baseSchemaReferenceId;
}
}
}
/// <summary>
/// Set the x-schema-id property on the schema to the identifier associated with the type.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
/// <param name="createSchemaReferenceId">A delegate that generates the reference ID to create for a type.</param>
internal static void ApplySchemaReferenceId(this JsonNode schema, JsonSchemaExporterContext context, Func<JsonTypeInfo, string?> createSchemaReferenceId)
{
if (createSchemaReferenceId(context.TypeInfo) is { } schemaReferenceId)
{
schema[OpenApiConstants.SchemaId] = schemaReferenceId;
}
if (context.TypeInfo.Kind == JsonTypeInfoKind.Union)
{
schema[OpenApiConstants.SchemaIsUnion] = true;
}
// If the type is a non-abstract base class that is not one of the derived types then mark it as a base schema.
if (context.BaseTypeInfo == context.TypeInfo &&
IsNonAbstractTypeWithoutDerivedTypeReference(context))
{
schema[OpenApiConstants.SchemaId] = "Base";
}
}
/// <summary>
/// Determines whether the specified JSON schema will be moved into the components section.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <returns><see langword="true"/> if the schema will be componentized; otherwise, <see langword="false"/>.</returns>
internal static bool WillBeComponentized(this JsonNode schema)
{
return (schema[OpenApiConstants.SchemaId] is JsonNode schemaIdNode
&& schemaIdNode.GetValueKind() == JsonValueKind.String &&
!string.IsNullOrEmpty(schemaIdNode.GetValue<string>()));
}
/// <summary>
/// Returns <langword ref="true" /> if the current type is a non-abstract base class that is not defined as its
/// own derived type.
/// </summary>
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
private static bool IsNonAbstractTypeWithoutDerivedTypeReference(JsonSchemaExporterContext context)
{
return !context.TypeInfo.Type.IsAbstract
&& context.TypeInfo.PolymorphismOptions is { } polymorphismOptions
&& !polymorphismOptions.DerivedTypes.Any(type => type.DerivedType == context.TypeInfo.Type);
}
/// <summary>
/// Returns <see langword="true"/> if the current type is a non-abstract base class that is defined as its
/// own derived type with a discriminator.
/// </summary>
/// <param name="context">The <see cref="JsonSchemaExporterContext"/> associated with the current type.</param>
private static bool IsNonAbstractTypeWithDerivedTypeReference(JsonSchemaExporterContext context)
{
return !context.TypeInfo.Type.IsAbstract
&& context.TypeInfo.PolymorphismOptions is { } polymorphismOptions
&& polymorphismOptions.DerivedTypes.Any(type => type.DerivedType == context.TypeInfo.Type && type.TypeDiscriminator is not null);
}
/// <summary>
/// Support applying nullability status for reference types provided as a property or field.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="propertyInfo">The <see cref="JsonPropertyInfo" /> associated with the schema.</param>
internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPropertyInfo propertyInfo)
{
var shouldApplyNullableSchema = propertyInfo.PropertyType != typeof(object) && (propertyInfo.IsGetNullable || propertyInfo.IsSetNullable);
// Work around a System.Text.Json schema export issue where get-only properties can report
// IsGetNullable == false and IsSetNullable == true, which incorrectly marks them as nullable, documented in dotnet/runtime#131602
var shouldPruneNullFromReadOnlyProperty = propertyInfo.PropertyType != typeof(object) &&
propertyInfo.Set is null &&
!propertyInfo.IsGetNullable &&
propertyInfo.IsSetNullable;
if (shouldPruneNullFromReadOnlyProperty)
{
shouldApplyNullableSchema = false;
}
if (MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes)
{
if (shouldApplyNullableSchema && !schemaTypes.HasFlag(JsonSchemaType.Null))
{
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString();
}
else if (shouldPruneNullFromReadOnlyProperty && schemaTypes.HasFlag(JsonSchemaType.Null))
{
var nonNullableSchemaTypes = schemaTypes & ~JsonSchemaType.Null;
if (nonNullableSchemaTypes != 0)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = nonNullableSchemaTypes.ToString();
}
else if (schema is JsonObject schemaObject)
{
schemaObject.Remove(OpenApiSchemaKeywords.TypeKeyword);
}
}
}
if (schema.WillBeComponentized() &&
propertyInfo.PropertyType != typeof(object) && propertyInfo.ShouldApplyNullablePropertySchema())
{
schema[OpenApiConstants.NullableProperty] = true;
}
}
/// <summary>
/// Prunes the "null" type from the schema for types that are componentized. These
/// types should represent their nullability using oneOf with null instead.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema)
{
if (schema.WillBeComponentized() &&
schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
{
for (var i = typeArray.Count - 1; i >= 0; i--)
{
if (typeArray[i]?.GetValue<string>() == "null")
{
typeArray.RemoveAt(i);
}
}
if (typeArray.Count == 1)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
}
}
}
private static JsonSchemaType? MapJsonNodeToSchemaType(JsonNode? jsonNode)
{
if (jsonNode is not JsonArray jsonArray)
{
if (Enum.TryParse<JsonSchemaType>(jsonNode?.GetValue<string>(), true, out var openApiSchemaType))
{
return openApiSchemaType;
}
return jsonNode is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var identifier)
? ToSchemaType(identifier)
: null;
}
JsonSchemaType? schemaType = null;
foreach (var node in jsonArray)
{
if (node is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var identifier))
{
var type = ToSchemaType(identifier);
schemaType = schemaType.HasValue ? (schemaType | type) : type;
}
}
return schemaType;
static JsonSchemaType ToSchemaType(string identifier)
{
return identifier.ToLowerInvariant() switch
{
"null" => JsonSchemaType.Null,
"boolean" => JsonSchemaType.Boolean,
"integer" => JsonSchemaType.Integer,
"number" => JsonSchemaType.Number,
"string" => JsonSchemaType.String,
"array" => JsonSchemaType.Array,
"object" => JsonSchemaType.Object,
_ => throw new InvalidOperationException($"Unknown schema type: {identifier}"),
};
}
}
}