diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 4d41a6e0e..02a47a68e 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -21,6 +21,8 @@ namespace Microsoft.OpenApi /// public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer { + private static readonly IEnumerable s_singleNullElementList = [ JsonNullSentinel.JsonNull ]; + /// public string? Title { get; set; } @@ -109,13 +111,8 @@ public string? ExclusiveMinimum /// 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); /// public string? Const { get; set; } @@ -517,57 +514,26 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version : Enum; writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); - // Handle oneOf/anyOf with null type for v3.0 downcast - IList? effectiveOneOf = OneOf; - IList? effectiveAnyOf = AnyOf; - bool hasNullInComposition = false; - bool hasOneOfNullAndSingleEnumWith3_0 = false; - JsonSchemaType? inferredType = null; - if (version == OpenApiSpecVersion.OpenApi3_0) { - (effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); - hasNullInComposition |= nullInOneOf; - inferredType = inferredOneOf ?? inferredType; - (effectiveAnyOf, 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 } && Enum 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); // 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); @@ -603,9 +569,14 @@ 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) { - SerializeNullable(writer, version, hasNullInComposition); + // 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); } // discriminator @@ -832,7 +803,7 @@ private void SerializeAsV2( writer.WriteStartObject(); // type - SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0); + TrySerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0); // description writer.WriteProperty(OpenApiConstants.Description, Description); @@ -995,31 +966,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) @@ -1054,9 +1026,9 @@ where type.HasFlag(flag) } } - private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false) + private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version) { - if (IsNullable || hasNullInComposition) + if (HasNullType) { switch (version) { @@ -1070,63 +1042,6 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version } } - /// - /// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type. - /// - /// The list of schemas in the composition. - /// A tuple with the effective list, inferred type, and whether null is present in composition. - private static (IList? effective, JsonSchemaType? inferredType, bool hasNullInComposition) - ProcessCompositionForNull(IList? composition) - { - if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null)) - { - // Nothing to patch - return (composition, null, false); - } - - var nonNullSchemas = composition - .Where(static s => s.Type is null or not JsonSchemaType.Null) - .ToList(); - - if (nonNullSchemas.Count > 0) - { - JsonSchemaType commonType = 0; - - foreach (var schema in nonNullSchemas) - { - if (schema.Type.HasValue) - { - commonType |= schema.Type.Value & ~JsonSchemaType.Null; - } - else if (schema.Enum is { Count: > 0 }) - { - foreach (var enumValue in schema.Enum.Where(x => x is not null)) - { - var currentType = enumValue.GetValueKind() switch - { - JsonValueKind.Array => JsonSchemaType.Array, - JsonValueKind.String => JsonSchemaType.String, - JsonValueKind.Number => JsonSchemaType.Number, - JsonValueKind.True or JsonValueKind.False => JsonSchemaType.Boolean, - JsonValueKind.Null => (JsonSchemaType)0, - _ => JsonSchemaType.Object, - }; - - commonType |= currentType; - } - - commonType |= JsonSchemaType.String; - } - } - - return (nonNullSchemas, commonType == 0 ? null : commonType, true); - } - else - { - return (null, null, true); - } - } - #if NET5_0_OR_GREATER private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues(); #else diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index 421468750..005aa51ea 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -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 { @@ -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)) + 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; diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 6a933fe5f..a83d19da8 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -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 { @@ -222,10 +223,20 @@ 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; + { + // "nullable" is a type modifier. + // It only has effect if there is a "Type" set. + // Given that we don't have a Type set yet, we want to keep track of it until the end of deserialization. + // If, at a later point during deserialization, Type was set, we apply nullable to it. + // Otherwise, we throw it away. + o.Metadata ??= new Dictionary(); + o.Metadata["encounteredNullable"] = true; + } } } }, @@ -385,14 +396,13 @@ 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.Metadata?.TryGetValue("encounteredNullable", out var value) == true) { - if (schema.Type.HasValue) + schema.Metadata.Remove("encounteredNullable"); + if (schema.Type is not null && schema.Type != 0 && value is bool isNullable && isNullable) + { schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - - schema.Extensions.Remove(OpenApiConstants.NullableExtension); + } } return schema; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index e060537b2..c14237f83 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -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; @@ -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) @@ -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 diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index 71a422516..892822a45 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -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.V32; @@ -325,24 +326,6 @@ internal static partial class OpenApiV32Deserializer "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) @@ -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 diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs index 291e3978a..5822d2239 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; @@ -173,8 +173,10 @@ public async Task SerializeSchemaWithNullableShouldSucceed() [Fact] public async Task SerializeSchemaWithOnlyNullableShouldSucceed() { + // NOTE: x-nullable extension has no effect on the schema if type is not specified, so it is omitted in the serialized output. + // Arrange - var expected = @"x-nullable: true"; + var expected = @"{ }"; var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullableExtension.yaml"); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index 5cf558f7c..cc994c0e6 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -141,13 +141,15 @@ public void ParseSchemaWithTypeArrayWorks() [Theory] [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")] [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")] - public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson) + public void ParseSchemaWithNullableBeforeOrAfterTypeDoesNotPreserveNullFlag(string schemaJson) { + // "nullable" is only for 3.0. + // Act var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_1, new(), out _, "json", SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -517,7 +519,7 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() - ""int"" nullable: true"; - var expected = @"x-nullable: true"; + var expected = @"{ }"; var schema = OpenApiModelFactory.Parse(input, OpenApiSpecVersion.OpenApi3_1, new(), out _, "yaml", SettingsFixture.ReaderSettings); @@ -531,8 +533,10 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() [Theory] [InlineData("schemaWithNullable.yaml")] [InlineData("schemaWithNullableExtension.yaml")] - public async Task LoadSchemaWithNullableExtensionAsV31Works(string filePath) + public async Task LoadSchemaWithNullableExtensionAsV31ShouldNotWork(string filePath) { + // "nullable" is only for 3.0. + // and "x-nullable" is only for 2.0. // Arrange var path = Path.Combine(SampleFolderPath, filePath); @@ -540,7 +544,7 @@ public async Task LoadSchemaWithNullableExtensionAsV31Works(string filePath) var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs index a56850cde..5b294837d 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs @@ -140,13 +140,15 @@ public void ParseSchemaWithTypeArrayWorks() [Theory] [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")] [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")] - public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson) + public void ParseSchemaWithNullableBeforeOrAfterTypeDoesNotPreserveNullFlag(string schemaJson) { + // "nullable" is only for 3.0. + // Act var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_2, new(), out _, "json", SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -419,7 +421,7 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() - ""int"" nullable: true"; - var expected = @"x-nullable: true"; + var expected = @"{ }"; var schema = OpenApiModelFactory.Parse(input, OpenApiSpecVersion.OpenApi3_2, new(), out _, "yaml", SettingsFixture.ReaderSettings); @@ -433,8 +435,11 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() [Theory] [InlineData("schemaWithNullable.yaml")] [InlineData("schemaWithNullableExtension.yaml")] - public async Task LoadSchemaWithNullableExtensionAsV32Works(string filePath) + public async Task LoadSchemaWithNullableExtensionAsV32ShouldNotWork(string filePath) { + // "nullable" is only for 3.0. + // and "x-nullable" is only for 2.0. + // Arrange var path = Path.Combine(SampleFolderPath, filePath); @@ -442,7 +447,7 @@ public async Task LoadSchemaWithNullableExtensionAsV32Works(string filePath) var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 4e8b80286..cda5c4c72 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; @@ -532,8 +532,9 @@ public async Task SerializeSchemaWithNullableShouldSucceed() [Fact] public async Task SerializeSchemaWithOnlyNullableShouldSucceed() { + // NOTE: nullable property has no effect on the schema if type is not specified, so it is omitted in the serialized output. // Arrange - var expected = @"nullable: true"; + var expected = @"{ }"; var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullable.yaml"); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index b3048db00..b4b188617 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -959,14 +959,17 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "string", "oneOf": [ + { + "enum": [ + null + ] + }, { "maxLength": 10, "type": "string" } - ], - "nullable": true + ] } """; @@ -1001,14 +1004,18 @@ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNull """ { "oneOf": [ + { + "enum": [ + null + ] + }, { "type": "string" }, { "type": "number" } - ], - "nullable": true + ] } """; @@ -1048,8 +1055,12 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "object", "anyOf": [ + { + "enum": [ + null + ] + }, { "type": "object", "properties": { @@ -1058,8 +1069,7 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() } } } - ], - "nullable": true + ] } """; // Assert @@ -1093,6 +1103,11 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl """ { "anyOf": [ + { + "enum": [ + null + ] + }, { "minLength": 1, "type": "string" @@ -1100,8 +1115,7 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl { "type": "integer" } - ], - "nullable": true + ] } """; @@ -1133,7 +1147,13 @@ public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync() var expectedV3Schema = """ { - "nullable": true + "oneOf": [ + { + "enum": [ + null + ] + } + ] } """; @@ -1230,13 +1250,16 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "object", "oneOf": [ + { + "enum": [ + null + ] + }, { "$ref": "#/components/schemas/Pet" } - ], - "nullable": true + ] } """; @@ -1870,17 +1893,19 @@ public async Task SerializeNullableEnumWith3_0() var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); var expected = """ { - "type": "string", "oneOf": [ { "enum": [ - "A", - "B", null ] + }, + { + "enum": [ + "A", + "B" + ] } - ], - "nullable": true + ] } """; @@ -1912,6 +1937,37 @@ public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion vers Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); } + [Fact] + public async Task SerializeNullableTypeWith3_0() + { + var schema = CreateTypeNullSchema(); + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "enum": [ + null + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeNullableTypeWith3_1_And_Later(OpenApiSpecVersion version) + { + var schema = CreateTypeNullSchema(); + var result = await schema.SerializeAsJsonAsync(version); + var expected = """ + { + "type": "null" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + private OpenApiSchema CreateNullableEnumSchema() { var schema = new OpenApiSchema(); @@ -1928,6 +1984,13 @@ private OpenApiSchema CreateNullableEnumSchema() return schema; } + private OpenApiSchema CreateTypeNullSchema() + { + var schema = new OpenApiSchema(); + schema.Type = JsonSchemaType.Null; + return schema; + } + internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new();