Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 34 additions & 53 deletions src/Microsoft.OpenApi/Models/OpenApiSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ namespace Microsoft.OpenApi
/// </summary>
public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer
{
private static readonly IEnumerable<JsonNode> s_singleNullElementList = [ JsonNullSentinel.JsonNull ];

/// <inheritdoc />
public string? Title { get; set; }

Expand Down Expand Up @@ -109,13 +111,8 @@ public string? ExclusiveMinimum
/// <inheritdoc />
public JsonSchemaType? Type { get; set; }

// x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code.
private bool IsNullable =>
(Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)) ||
Extensions is not null &&
Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) &&
nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode } &&
jsonNode.GetValueKind() is JsonValueKind.True;
private bool HasNullType
=> Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null);

/// <inheritdoc />
public string? Const { get; set; }
Expand Down Expand Up @@ -518,56 +515,36 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s));

// Handle oneOf/anyOf with null type for v3.0 downcast
IList<IOpenApiSchema>? effectiveOneOf = OneOf;
IList<IOpenApiSchema>? effectiveAnyOf = AnyOf;
Comment on lines -521 to -522

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For correctness, we are no longer altering/modifying the oneOf/anyOf.
If we find oneOf/anyOf that has a child schema with null type, we will serialize it as enum with a single null element, which is valid for OpenAPI 3.0.

bool hasNullInComposition = false;
bool hasOneOfNullAndSingleEnumWith3_0 = false;
JsonSchemaType? inferredType = null;

if (version == OpenApiSpecVersion.OpenApi3_0)
{
(effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf);
(var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf);
hasNullInComposition |= nullInOneOf;
inferredType = inferredOneOf ?? inferredType;
(effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf);
(var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf);
hasNullInComposition |= nullInAnyOf;
inferredType = inferredAnyOf ?? inferredType;

hasOneOfNullAndSingleEnumWith3_0 = nullInOneOf && effectiveOneOf is { Count: 1 } &&
effectiveOneOf[0].Enum is { Count: > 0 };
// If we have a schema that's only just { "type": "null" }, we serialize it as enum with null value.
if (Type == JsonSchemaType.Null && OneOf is not { Count: > 0 } && AnyOf is not { Count: > 0 })
{
writer.WriteOptionalCollection(OpenApiConstants.Enum, s_singleNullElementList, (nodeWriter, s) => nodeWriter.WriteAny(s));
}
}

// type
SerializeTypeProperty(writer, version, inferredType);
var serializedTypeProperty = TrySerializeTypeProperty(writer, version, inferredType);

// allOf
writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback);

// anyOf
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback);
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, callback);

// oneOf
if (hasOneOfNullAndSingleEnumWith3_0)
{
writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf!, (writer, element) =>
{
var clonedToMutateEnum = element.CreateShallowCopy();
if (clonedToMutateEnum is OpenApiSchema { Enum: { } existingEnum } concreteCloned)
{
concreteCloned.Enum = [.. existingEnum, JsonNullSentinel.JsonNull];
callback(writer, clonedToMutateEnum);
}
else
{
callback(writer, element);
}
});
}
else
{
writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback);
}

writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, callback);

// not
writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback);
Expand Down Expand Up @@ -603,8 +580,13 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d));

// nullable
if (version == OpenApiSpecVersion.OpenApi3_0)
if (version == OpenApiSpecVersion.OpenApi3_0 && serializedTypeProperty)
{
// https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20
// This keyword only takes effect if type is explicitly defined within the same Schema Object.
//
// If the user explicitly set IsNullable to true, we serialize it even if redundant.
// But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant.
SerializeNullable(writer, version, hasNullInComposition);
}

Expand Down Expand Up @@ -832,7 +814,7 @@ private void SerializeAsV2(
writer.WriteStartObject();

// type
SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0);
TrySerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0);

// description
writer.WriteProperty(OpenApiConstants.Description, Description);
Expand Down Expand Up @@ -995,31 +977,32 @@ private void SerializeAsV2(
writer.WriteEndObject();
}

private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null)
private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null)
{
// Use original type or inferred type when the explicit type is not set
var typeToUse = Type ?? inferredType;

if (typeToUse is null)
{
return;
return false;
}

var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value;
var typeWithoutNull = unifiedType & ~JsonSchemaType.Null;

switch (version)
{
case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0:
var typeWithoutNull = typeToUse.Value & ~JsonSchemaType.Null;
if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull))
{
writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier());
return true;
}
break;
default:
WriteUnifiedSchemaType(unifiedType, writer);
break;
WriteUnifiedSchemaType(typeToUse.Value, writer);
return true;
}

return false;
}

private static bool IsPowerOfTwo(int x)
Expand Down Expand Up @@ -1056,7 +1039,7 @@ where type.HasFlag(flag)

private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false)
{
if (IsNullable || hasNullInComposition)
if (HasNullType || hasNullInComposition)
{
switch (version)
{
Expand All @@ -1075,13 +1058,13 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version
/// </summary>
/// <param name="composition">The list of schemas in the composition.</param>
/// <returns>A tuple with the effective list, inferred type, and whether null is present in composition.</returns>
private static (IList<IOpenApiSchema>? effective, JsonSchemaType? inferredType, bool hasNullInComposition)
private static (JsonSchemaType? inferredType, bool hasNullInComposition)
ProcessCompositionForNull(IList<IOpenApiSchema>? composition)
{
if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null))
{
// Nothing to patch
return (composition, null, false);
return (null, false);
}

var nonNullSchemas = composition
Expand Down Expand Up @@ -1114,16 +1097,14 @@ private static (IList<IOpenApiSchema>? effective, JsonSchemaType? inferredType,

commonType |= currentType;
}

commonType |= JsonSchemaType.String;
}
}

return (nonNullSchemas, commonType == 0 ? null : commonType, true);
return (commonType == 0 ? null : commonType, true);
}
else
{
return (null, null, true);
return (null, true);
}
}

Expand Down
21 changes: 11 additions & 10 deletions src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using System.Text.Json.Nodes;

using System;
using System.Collections.Generic;
using System.Globalization;
using System;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Microsoft.OpenApi.Reader.V2
{
Expand Down Expand Up @@ -268,14 +268,15 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum

ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context);

if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension))
Comment thread
baywet marked this conversation as resolved.
var extensions = schema.Extensions;
if (extensions?.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) == true &&
nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode })
{
if (schema.Type.HasValue)
extensions.Remove(OpenApiConstants.NullableExtension);
if (schema.Type is not null && schema.Type != 0 && jsonNode.GetValueKind() is JsonValueKind.True)
{
schema.Type |= JsonSchemaType.Null;
else
schema.Type = JsonSchemaType.Null;

schema.Extensions.Remove(OpenApiConstants.NullableExtension);
}
}

return schema;
Expand Down
41 changes: 30 additions & 11 deletions src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using System.Text.Json.Nodes;

using System;
using System.Collections.Generic;
using System.Data.SqlTypes;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Microsoft.OpenApi.Reader.V3
{
Expand All @@ -16,6 +17,8 @@ namespace Microsoft.OpenApi.Reader.V3
/// </summary>
internal static partial class OpenApiV3Deserializer
{
private static readonly IOpenApiExtension _nullableTrueExtension = new JsonNodeExtension(JsonValue.Create(true));

private static readonly FixedFieldMap<OpenApiSchema> _openApiSchemaFixedFields = new()
{
{
Expand Down Expand Up @@ -222,10 +225,22 @@ internal static partial class OpenApiV3Deserializer
{
if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed)
{
if (o.Type.HasValue)
if (o.Type is not null && o.Type != 0)
{
o.Type |= JsonSchemaType.Null;
}
else
o.Type = JsonSchemaType.Null;
{
// While we are dealing with v3 document, we are still using x-nullable extension here.
// This is used only as a marker during deserialization to indicate that the schema is nullable.
// When we complete the deserialization, we will modify the OpenApiSchema.Type to
// include JsonSchemaType.Null (**only if** needed), and always remove the extension.
// The reason is that "nullable" property should only take effect if "Type" is set.
// If we knew Type is set, we add "Null" to it.
// Otherwise, it might be that it will be set later.
// In this case, we add the marker extension, and check at the end of deserialization, and remove the extension.
o.AddExtension(OpenApiConstants.NullableExtension, _nullableTrueExtension);
}
}
}
},
Expand Down Expand Up @@ -385,14 +400,18 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum

ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context);

if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension))
if (schema.Extensions?.TryGetValue(OpenApiConstants.NullableExtension, out var value) == true &&
value == _nullableTrueExtension)
{
if (schema.Type.HasValue)
schema.Type |= JsonSchemaType.Null;
else
schema.Type = JsonSchemaType.Null;

// If this is "our own" internal _nullableTrueExtension instance, then we know we encountered "nullable": true in the schema
// at a point where Type wasn't set yet.
// We check now if schema.Type is set.
// If it is, we add JsonSchemaType.Null to it.
schema.Extensions.Remove(OpenApiConstants.NullableExtension);
if (schema.Type is not null && schema.Type != 0)
{
schema.Type |= JsonSchemaType.Null;
}
}

return schema;
Expand Down
29 changes: 1 addition & 28 deletions src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Microsoft.OpenApi.Reader.V31;
Expand Down Expand Up @@ -325,24 +326,6 @@ internal static partial class OpenApiV31Deserializer
"default",
(o, n, _, _) => o.Default = n
},
{
"nullable",
(o, n, _, _) =>
{
var value = n.GetScalarValue();
if (value is not null)
{
var nullable = bool.Parse(value);
if (nullable) // if nullable, convert type into an array of type(s) and null
{
if (o.Type.HasValue)
o.Type |= JsonSchemaType.Null;
else
o.Type = JsonSchemaType.Null;
}
}
}
},
{
"discriminator",
(o, n, doc, c) => o.Discriminator = LoadDiscriminator(n, doc, c)
Expand Down Expand Up @@ -476,16 +459,6 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
schema.UnrecognizedKeywords[name] = value;
});

if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension))
{
if (schema.Type.HasValue)
schema.Type |= JsonSchemaType.Null;
else
schema.Type = JsonSchemaType.Null;

schema.Extensions.Remove(OpenApiConstants.NullableExtension);
}

if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null)
{
// register the schema in our registry using the identifier's URL
Expand Down
Loading