diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 2aceda191..2a6c8cb2f 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -550,7 +550,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version } // type - var serializedTypeProperty = TrySerializeTypeProperty(writer, version); + SerializeTypeProperty(writer, version); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); @@ -595,13 +595,13 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d)); // nullable - if (version == OpenApiSpecVersion.OpenApi3_0 && serializedTypeProperty) + if (version == OpenApiSpecVersion.OpenApi3_0) { // 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. + // We don't care to avoid an unnecessary serialization. + // So, we attempt to serialize it regardless of whether or not a type property was serialized. SerializeNullable(writer, version); } @@ -838,7 +838,7 @@ private void SerializeAsV2( writer.WriteStartObject(); // type - TrySerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0); + SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0); // description writer.WriteProperty(OpenApiConstants.Description, Description); @@ -1006,14 +1006,13 @@ private void SerializeAsV2( writer.WriteEndObject(); } - private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) + private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version) { - // Use original type or inferred type when the explicit type is not set - var typeToUse = Type ?? inferredType; + var typeToUse = Type; if (typeToUse is null) { - return false; + return; } switch (version) @@ -1023,15 +1022,15 @@ private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull)) { writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); - return true; + return; } break; default: WriteUnifiedSchemaType(typeToUse.Value, writer); - return true; + return; } - return false; + return; } private JsonNode? GetCompatibilityExample() diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs index 684e72c3b..db285f078 100644 --- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs +++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs @@ -42,7 +42,13 @@ public static List CreateListOfAny(this JsonNode? node, ParsingContext throw new OpenApiReaderException("Cannot create a list from this type of node.", context); } - return jsonArray.OfType().ToList(); + var list = new List(jsonArray.Count); + foreach (var element in jsonArray) + { + list.Add(element ?? JsonNullSentinel.JsonNull); + } + + return list; } public static List CreateSimpleList(this JsonNode? node, Func map, OpenApiDocument? openApiDocument, ParsingContext context) diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 55293c7c5..bf0b7bca4 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -413,6 +413,13 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum } } + if (schema.Type is null && schema.Enum is { Count: 1 } && + schema.Enum[0].IsJsonNullSentinel()) + { + schema.Enum = null; + schema.Type = JsonSchemaType.Null; + } + return schema; } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index db4de6355..424a2c22d 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -965,7 +965,8 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() { "enum": [ null - ] + ], + "nullable": true }, { "maxLength": 10, @@ -1009,7 +1010,8 @@ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNull { "enum": [ null - ] + ], + "nullable": true }, { "type": "string" @@ -1061,7 +1063,8 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() { "enum": [ null - ] + ], + "nullable": true }, { "type": "object", @@ -1108,7 +1111,8 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl { "enum": [ null - ] + ], + "nullable": true }, { "minLength": 1, @@ -1153,7 +1157,8 @@ public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync() { "enum": [ null - ] + ], + "nullable": true } ] } @@ -1256,7 +1261,8 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() { "enum": [ null - ] + ], + "nullable": true }, { "$ref": "#/components/schemas/Pet" @@ -2050,7 +2056,8 @@ public async Task SerializeNullableEnumWith3_0() { "enum": [ null - ] + ], + "nullable": true }, { "enum": [ @@ -2099,7 +2106,8 @@ public async Task SerializeNullableTypeWith3_0() { "enum": [ null - ] + ], + "nullable": true } """; diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs new file mode 100644 index 000000000..4dc0e025b --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Models +{ + /// + /// Tests for the transformations applied when an that uses + /// JSON Schema DRAFT 2020-12 / OpenAPI 3.2 features is serialized down to OpenAPI 3.0 (which lacks those features), + /// and the corresponding behavior when such documents are deserialized back. + /// + [Collection("DefaultSettings")] + public class OpenApiSchemaV30CompatibilityTests + { + private static IOpenApiSchema ParseSchemaFromV30Document(string schemaJson) + { + var jsonContent = $$""" + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "TestSchema": {{schemaJson}} + } + } + } + """; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + Assert.Empty(readResult.Diagnostic.Errors); + return readResult.Document.Components.Schemas["TestSchema"]; + } + + [Fact] + public async Task NullableEnumShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema + { + Enum = + [ + JsonValue.Create(1), + JsonValue.Create(2), + JsonValue.Create(3), + JsonNullSentinel.JsonNull, + ] + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "enum": [ + 1, + 2, + 3, + null + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + + Assert.Equal(4, deserializedSchema.Enum.Count); + Assert.Equal(1, deserializedSchema.Enum[0].GetValue()); + Assert.Equal(2, deserializedSchema.Enum[1].GetValue()); + Assert.Equal(3, deserializedSchema.Enum[2].GetValue()); + Assert.True(deserializedSchema.Enum[3].IsJsonNullSentinel()); + } + + [Fact] + public async Task TypeNullAloneAsV3ShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Null }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "enum": [ + null + ], + "nullable": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + + Assert.Equal(JsonSchemaType.Null, deserializedSchema.Type); + Assert.Null(deserializedSchema.Enum); + } + + [Fact] + public async Task NullableTypeAsV3ShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "string", + "nullable": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, deserializedSchema.Type); + } + + [Fact] + public async Task SerializeMultipleNonNullTypesAsV3OmitsType() + { + // Current behavior isn't good. It loses the information about multiple types. + var schema = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Integer }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = """ + { + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Null(deserializedSchema.Type); + } + + [Fact] + public async Task SerializeMultipleNonNullTypesWithNullAsV3OmitsTypeButKeepsNullable() + { + // Current behavior isn't good. It loses the information about multiple types. + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = """ + { + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Null(deserializedSchema.Type); + } + + [Fact] + public async Task SerializeConstAsV3EmitsSingleValueEnum() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String, Const = "foo" }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var node = JsonNode.Parse(actual)!.AsObject(); + Assert.False(node.ContainsKey("const")); + Assert.True(node["enum"] is JsonArray enumArray + && enumArray.Count == 1 + && enumArray[0]!.ToString() == "foo"); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Null(deserializedSchema.Const); + Assert.NotNull(deserializedSchema.Enum); + Assert.Single(deserializedSchema.Enum); + Assert.Equal("foo", deserializedSchema.Enum[0]!.ToString()); + } + + [Fact] + public async Task SerializeExclusiveMaximumAsV3EmitsMaximumWithBooleanFlag() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Integer, + ExclusiveMaximum = "5" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "integer", + "maximum": 5, + "exclusiveMaximum": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = (OpenApiSchema)ParseSchemaFromV30Document(actual); + Assert.True(deserializedSchema.IsExclusiveMaximum); + Assert.Null(deserializedSchema.Maximum); + Assert.Equal("5", deserializedSchema.ExclusiveMaximum); + } + + [Fact] + public async Task SerializeExclusiveMinimumAsV3EmitsMinimumWithBooleanFlag() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Integer, + ExclusiveMinimum = "1" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "integer", + "minimum": 1, + "exclusiveMinimum": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = (OpenApiSchema)ParseSchemaFromV30Document(actual); + Assert.True(deserializedSchema.IsExclusiveMinimum); + Assert.Null(deserializedSchema.Minimum); + Assert.Equal("1", deserializedSchema.ExclusiveMinimum); + } + } +}