diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 8ed048427..c629f78be 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -700,6 +700,16 @@ public static class OpenApiConstants /// public const string ComponentsSegment = "/components/"; + /// + /// Field: Null + /// + public const string Null = "null"; + + /// + /// Field: Nullable extension + /// + public const string NullableExtension = "x-nullable"; + #region V2.0 /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 25352086f..eda8249dc 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -483,14 +483,7 @@ public void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, writer.WriteOptionalCollection(OpenApiConstants.Enum, Enum, (nodeWriter, s) => nodeWriter.WriteAny(s)); // type - if (Type?.GetType() == typeof(string)) - { - writer.WriteProperty(OpenApiConstants.Type, (string)Type); - } - else - { - writer.WriteOptionalCollection(OpenApiConstants.Type, (string[])Type, (w, s) => w.WriteRaw(s)); - } + SerializeTypeProperty(Type, writer, version); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, (w, s) => s.SerializeAsV3(w)); @@ -533,7 +526,10 @@ public void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d)); // nullable - writer.WriteProperty(OpenApiConstants.Nullable, Nullable, false); + if (version is OpenApiSpecVersion.OpenApi3_0) + { + writer.WriteProperty(OpenApiConstants.Nullable, Nullable, false); + } // discriminator writer.WriteOptionalObject(OpenApiConstants.Discriminator, Discriminator, (w, s) => s.SerializeAsV3(w)); @@ -670,7 +666,14 @@ internal void SerializeAsV2( writer.WriteStartObject(); // type - writer.WriteProperty(OpenApiConstants.Type, (string)Type); + if (Type is string[] array) + { + DowncastTypeArrayToV2OrV3(array, writer, OpenApiSpecVersion.OpenApi2_0); + } + else + { + writer.WriteProperty(OpenApiConstants.Type, (string)Type); + } // description writer.WriteProperty(OpenApiConstants.Description, Description); @@ -799,6 +802,35 @@ internal void SerializeAsV2( writer.WriteEndObject(); } + private void SerializeTypeProperty(object type, IOpenApiWriter writer, OpenApiSpecVersion version) + { + if (type?.GetType() == typeof(string)) + { + // check whether nullable is true for upcasting purposes + if (Nullable || Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + { + // create a new array and insert the type and "null" as values + Type = new[] { (string)Type, OpenApiConstants.Null }; + } + else + { + writer.WriteProperty(OpenApiConstants.Type, (string)Type); + } + } + if (Type is string[] array) + { + // type + if (version is OpenApiSpecVersion.OpenApi3_0) + { + DowncastTypeArrayToV2OrV3(array, writer, OpenApiSpecVersion.OpenApi3_0); + } + else + { + writer.WriteOptionalCollection(OpenApiConstants.Type, (string[])Type, (w, s) => w.WriteRaw(s)); + } + } + } + private object DeepCloneType(object type) { if (type == null) @@ -822,5 +854,38 @@ private object DeepCloneType(object type) return null; } + + private void DowncastTypeArrayToV2OrV3(string[] array, IOpenApiWriter writer, OpenApiSpecVersion version) + { + /* If the array has one non-null value, emit Type as string + * If the array has one null value, emit x-nullable as true + * If the array has two values, one null and one non-null, emit Type as string and x-nullable as true + * If the array has more than two values or two non-null values, do not emit type + * */ + + var nullableProp = version.Equals(OpenApiSpecVersion.OpenApi2_0) + ? OpenApiConstants.NullableExtension + : OpenApiConstants.Nullable; + + if (array.Length is 1) + { + var value = array[0]; + if (value is OpenApiConstants.Null) + { + writer.WriteProperty(nullableProp, true); + } + else + { + writer.WriteProperty(OpenApiConstants.Type, value); + } + } + else if (array.Length is 2 && array.Contains(OpenApiConstants.Null)) + { + // Find the non-null value and write it out + var nonNullValue = array.First(v => v != OpenApiConstants.Null); + writer.WriteProperty(OpenApiConstants.Type, nonNullValue); + writer.WriteProperty(nullableProp, true); + } + } } } diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index f8d197170..7757c710f 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -182,7 +182,22 @@ internal static partial class OpenApiV31Deserializer }, { "nullable", - (o, n, _) => o.Nullable = bool.Parse(n.GetScalarValue()) + (o, n, _) => + { + var nullable = bool.Parse(n.GetScalarValue()); + if (nullable) // if nullable, convert type into an array of type(s) and null + { + if (o.Type is string[] typeArray) + { + var typeList = new List(typeArray) { OpenApiConstants.Null }; + o.Type = typeList.ToArray(); + } + else if (o.Type is string typeString) + { + o.Type = new string[]{typeString, OpenApiConstants.Null}; + } + } + } }, { "discriminator", @@ -242,6 +257,13 @@ public static OpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocum propertyNode.ParseField(schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields); } + if (schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + { + var type = schema.Type; + schema.Type = new string[] {(string)type, OpenApiConstants.Null}; + schema.Extensions.Remove(OpenApiConstants.NullableExtension); + } + return schema; } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index af11245d4..cacb1ed86 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -1,13 +1,15 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; +using System.IO; using System.Text.Json.Nodes; using FluentAssertions; using FluentAssertions.Equivalency; -using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Tests; +using Microsoft.OpenApi.Writers; using Xunit; namespace Microsoft.OpenApi.Readers.Tests.V31Tests @@ -289,5 +291,118 @@ public void CloningSchemaWithExamplesAndEnumsShouldSucceed() clone.Examples.Should().NotBeEquivalentTo(schema.Examples); clone.Default.Should().NotBeEquivalentTo(schema.Default); } + + [Fact] + public void SerializeV31SchemaWithMultipleTypesAsV3Works() + { + // Arrange + var expected = @"type: string +nullable: true"; + + var path = Path.Combine(SampleFolderPath, "schemaWithTypeArray.yaml"); + + // Act + var schema = OpenApiModelFactory.Load(path, OpenApiSpecVersion.OpenApi3_1, out _); + + var writer = new StringWriter(); + schema.SerializeAsV3(new OpenApiYamlWriter(writer)); + var schema1String = writer.ToString(); + + schema1String.MakeLineBreaksEnvironmentNeutral().Should().Be(expected.MakeLineBreaksEnvironmentNeutral()); + } + + [Fact] + public void SerializeV31SchemaWithMultipleTypesAsV2Works() + { + // Arrange + var expected = @"type: string +x-nullable: true"; + + var path = Path.Combine(SampleFolderPath, "schemaWithTypeArray.yaml"); + + // Act + var schema = OpenApiModelFactory.Load(path, OpenApiSpecVersion.OpenApi3_1, out _); + + var writer = new StringWriter(); + schema.SerializeAsV2(new OpenApiYamlWriter(writer)); + var schema1String = writer.ToString(); + + schema1String.MakeLineBreaksEnvironmentNeutral().Should().Be(expected.MakeLineBreaksEnvironmentNeutral()); + } + + [Fact] + public void SerializeV3SchemaWithNullableAsV31Works() + { + // Arrange + var expected = @"type: + - string + - null"; + + var path = Path.Combine(SampleFolderPath, "schemaWithNullable.yaml"); + + // Act + var schema = OpenApiModelFactory.Load(path, OpenApiSpecVersion.OpenApi3_0, out _); + + var writer = new StringWriter(); + schema.SerializeAsV31(new OpenApiYamlWriter(writer)); + var schemaString = writer.ToString(); + + schemaString.MakeLineBreaksEnvironmentNeutral().Should().Be(expected.MakeLineBreaksEnvironmentNeutral()); + } + + [Fact] + public void SerializeV2SchemaWithNullableExtensionAsV31Works() + { + // Arrange + var expected = @"type: + - string + - null +x-nullable: true"; + + var path = Path.Combine(SampleFolderPath, "schemaWithNullableExtension.yaml"); + + // Act + var schema = OpenApiModelFactory.Load(path, OpenApiSpecVersion.OpenApi2_0, out _); + + var writer = new StringWriter(); + schema.SerializeAsV31(new OpenApiYamlWriter(writer)); + var schemaString = writer.ToString(); + + schemaString.MakeLineBreaksEnvironmentNeutral().Should().Be(expected.MakeLineBreaksEnvironmentNeutral()); + } + + [Fact] + public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() + { + var input = @"type: +- ""string"" +- ""int"" +nullable: true"; + + var expected = @"{ }"; + + var schema = OpenApiModelFactory.Parse(input, OpenApiSpecVersion.OpenApi3_1, out _, "yaml"); + + var writer = new StringWriter(); + schema.SerializeAsV2(new OpenApiYamlWriter(writer)); + var schemaString = writer.ToString(); + + schemaString.MakeLineBreaksEnvironmentNeutral().Should().Be(expected.MakeLineBreaksEnvironmentNeutral()); + } + + [Theory] + [InlineData("schemaWithNullable.yaml")] + [InlineData("schemaWithNullableExtension.yaml")] + public void LoadSchemaWithNullableExtensionAsV31Works(string filePath) + { + // Arrange + var path = Path.Combine(SampleFolderPath, filePath); + + // Act + var schema = OpenApiModelFactory.Load(path, OpenApiSpecVersion.OpenApi3_1, out _); + + // Assert + schema.Type.Should().BeEquivalentTo(new string[] { "string", "null" }); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithNullable.yaml b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithNullable.yaml new file mode 100644 index 000000000..913c768d3 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithNullable.yaml @@ -0,0 +1,2 @@ +type: string +nullable: true \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithNullableExtension.yaml b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithNullableExtension.yaml new file mode 100644 index 000000000..e9bfbd513 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithNullableExtension.yaml @@ -0,0 +1,2 @@ +type: string +x-nullable: true \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithTypeArray.yaml b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithTypeArray.yaml new file mode 100644 index 000000000..38ac212be --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/schemaWithTypeArray.yaml @@ -0,0 +1,3 @@ +type: +- "string" +- "null" \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index 7eb01a70c..18954d3f7 100755 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -437,7 +437,9 @@ namespace Microsoft.OpenApi.Models public const string Name = "name"; public const string Namespace = "namespace"; public const string Not = "not"; + public const string Null = "null"; public const string Nullable = "nullable"; + public const string NullableExtension = "x-nullable"; public const string OneOf = "oneOf"; public const string OpenApi = "openapi"; public const string OpenIdConnectUrl = "openIdConnectUrl";