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";