Skip to content

Nullable fixes #2358

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 19, 2025
Merged
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
157 changes: 57 additions & 100 deletions src/Microsoft.OpenApi/Models/OpenApiSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ 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;

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

Expand Down Expand Up @@ -437,7 +445,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
writer.WriteOptionalCollection(OpenApiConstants.Enum, Enum, (nodeWriter, s) => nodeWriter.WriteAny(s));

// type
SerializeTypeProperty(Type, writer, version);
SerializeTypeProperty(writer, version);

// allOf
writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback);
Expand Down Expand Up @@ -479,6 +487,12 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
// default
writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d));

// nullable
if (version == OpenApiSpecVersion.OpenApi3_0)
{
SerializeNullable(writer, version);
}

// discriminator
writer.WriteOptionalObject(OpenApiConstants.Discriminator, Discriminator, callback);

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

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

// description
writer.WriteProperty(OpenApiConstants.Description, Description);
Expand Down Expand Up @@ -742,68 +756,36 @@ private void SerializeAsV2(
// example
writer.WriteOptionalObject(OpenApiConstants.Example, Example, (w, e) => w.WriteAny(e));

// x-nullable extension
SerializeNullable(writer, OpenApiSpecVersion.OpenApi2_0);

// extensions
writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0);

writer.WriteEndObject();
}

private void SerializeTypeProperty(JsonSchemaType? type, IOpenApiWriter writer, OpenApiSpecVersion version)
private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version)
{
// check whether nullable is true for upcasting purposes
var 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;
if (type is null)
if (Type is null)
{
if (version is OpenApiSpecVersion.OpenApi3_0 && isNullable)
{
writer.WriteProperty(OpenApiConstants.Nullable, true);
}
return;
}
else if (!HasMultipleTypes(type.Value))
{

switch (version)
{
case OpenApiSpecVersion.OpenApi3_1 when isNullable:
UpCastSchemaTypeToV31(type.Value, writer);
break;
case OpenApiSpecVersion.OpenApi3_0 when isNullable && type.Value == JsonSchemaType.Null:
writer.WriteProperty(OpenApiConstants.Nullable, true);
writer.WriteProperty(OpenApiConstants.Type, JsonSchemaType.Object.ToFirstIdentifier());
break;
case OpenApiSpecVersion.OpenApi3_0 when isNullable && type.Value != JsonSchemaType.Null:
writer.WriteProperty(OpenApiConstants.Nullable, true);
writer.WriteProperty(OpenApiConstants.Type, type.Value.ToFirstIdentifier());
break;
default:
writer.WriteProperty(OpenApiConstants.Type, type.Value.ToFirstIdentifier());
break;
}
}
else
var unifiedType = IsNullable ? Type.Value | JsonSchemaType.Null : Type.Value;
var typeWithoutNull = unifiedType & ~JsonSchemaType.Null;

switch (version)
{
// type
if (version is OpenApiSpecVersion.OpenApi2_0 || version is OpenApiSpecVersion.OpenApi3_0)
{
DowncastTypeArrayToV2OrV3(type.Value, writer, version);
}
else
{
var list = (from JsonSchemaType flag in jsonSchemaTypeValues
where type.Value.HasFlag(flag)
select flag).ToList();
writer.WriteOptionalCollection(OpenApiConstants.Type, list, (w, s) =>
case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0:
if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull))
{
foreach (var item in s.ToIdentifiers())
{
w.WriteValue(item);
}
});
}
writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier());
}
break;
default:
WriteUnifiedSchemaType(unifiedType, writer);
break;
}
}

Expand All @@ -815,20 +797,17 @@ private static bool IsPowerOfTwo(int x)
private static bool HasMultipleTypes(JsonSchemaType schemaType)
{
var schemaTypeNumeric = (int)schemaType;
return !IsPowerOfTwo(schemaTypeNumeric) && // Boolean, Integer, Number, String, Array, Object
schemaTypeNumeric != (int)JsonSchemaType.Null;
return !IsPowerOfTwo(schemaTypeNumeric);
}

private static void UpCastSchemaTypeToV31(JsonSchemaType type, IOpenApiWriter writer)
private static void WriteUnifiedSchemaType(JsonSchemaType type, IOpenApiWriter writer)
{
// create a new array and insert the type and "null" as values
var temporaryType = type | JsonSchemaType.Null;
var list = (from JsonSchemaType flag in jsonSchemaTypeValues// Check if the flag is set in 'type' using a bitwise AND operation
where temporaryType.HasFlag(flag)
select flag.ToFirstIdentifier()).ToList();
if (list.Count > 1)
var array = (from JsonSchemaType flag in jsonSchemaTypeValues
where type.HasFlag(flag)
select flag.ToFirstIdentifier()).ToArray();
if (array.Length > 1)
{
writer.WriteOptionalCollection(OpenApiConstants.Type, list, (w, s) =>
writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) =>
{
if (!string.IsNullOrEmpty(s) && s is not null)
{
Expand All @@ -838,54 +817,32 @@ where temporaryType.HasFlag(flag)
}
else
{
writer.WriteProperty(OpenApiConstants.Type, list[0]);
writer.WriteProperty(OpenApiConstants.Type, array[0]);
}
}

#if NET5_0_OR_GREATER
private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues<JsonSchemaType>();
#else
private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues(typeof(JsonSchemaType));
#endif

private static void DowncastTypeArrayToV2OrV3(JsonSchemaType schemaType, IOpenApiWriter writer, OpenApiSpecVersion version)
private void SerializeNullable(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 (!HasMultipleTypes(schemaType & ~JsonSchemaType.Null) && (schemaType & JsonSchemaType.Null) == JsonSchemaType.Null) // checks for two values and one is null
{
foreach (JsonSchemaType flag in jsonSchemaTypeValues)
{
// Skip if the flag is not set or if it's the Null flag
if (schemaType.HasFlag(flag) && flag != JsonSchemaType.Null)
{
// Write the non-null flag value to the writer
writer.WriteProperty(OpenApiConstants.Type, flag.ToFirstIdentifier());
}
}
writer.WriteProperty(nullableProp, true);
}
else if (!HasMultipleTypes(schemaType))
if (IsNullable)
{
if (schemaType is JsonSchemaType.Null)
{
writer.WriteProperty(nullableProp, true);
}
else
switch (version)
{
writer.WriteProperty(OpenApiConstants.Type, schemaType.ToFirstIdentifier());
case OpenApiSpecVersion.OpenApi2_0:
writer.WriteProperty(OpenApiConstants.NullableExtension, true);
break;
case OpenApiSpecVersion.OpenApi3_0:
writer.WriteProperty(OpenApiConstants.Nullable, true);
break;
}
}
}

#if NET5_0_OR_GREATER
private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues<JsonSchemaType>();
#else
private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues(typeof(JsonSchemaType));
#endif

/// <inheritdoc/>
public IOpenApiSchema CreateShallowCopy()
{
Expand Down
10 changes: 10 additions & 0 deletions src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,16 @@ public static IOpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocu
propertyNode.ParseField(schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument);
}

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);
}

return schema;
}
}
Expand Down
12 changes: 11 additions & 1 deletion src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ internal static partial class OpenApiV3Deserializer
"type",
(o, n, _) => {
var type = n.GetScalarValue()?.ToJsonSchemaType();
// so we don't loose the value from nullable
// so we don't lose the value from nullable
if (o.Type.HasValue)
o.Type |= type;
else
Expand Down Expand Up @@ -307,6 +307,16 @@ public static IOpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocu
propertyNode.ParseField(schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument);
}

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);
}

return schema;
}
}
Expand Down
12 changes: 9 additions & 3 deletions src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,10 @@ internal static partial class OpenApiV31Deserializer
var nullable = bool.Parse(value);
if (nullable) // if nullable, convert type into an array of type(s) and null
{
o.Type |= JsonSchemaType.Null;
if (o.Type.HasValue)
o.Type |= JsonSchemaType.Null;
else
o.Type = JsonSchemaType.Null;
}
}
}
Expand Down Expand Up @@ -392,8 +395,11 @@ public static IOpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocu

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
// Licensed under the MIT license.

using System.IO;
using FluentAssertions;
using Microsoft.OpenApi.Reader.V2;
using Xunit;
using Microsoft.OpenApi.Reader.ParseNodes;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Extensions;
using System.Text.Json.Nodes;
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using FluentAssertions.Equivalency;
using Microsoft.OpenApi.Extensions;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Models.References;
using Microsoft.OpenApi.Reader;
using Microsoft.OpenApi.Reader.ParseNodes;
using Microsoft.OpenApi.Reader.V2;
using Microsoft.OpenApi.Tests;
using Microsoft.OpenApi.Writers;
using Microsoft.OpenApi.Models.Interfaces;
using Xunit;

namespace Microsoft.OpenApi.Readers.Tests.V2Tests
{
Expand Down Expand Up @@ -98,6 +99,7 @@ public void ParseSchemaWithEnumShouldSucceed()
.Excluding((IMemberInfo memberInfo) =>
memberInfo.Path.EndsWith("Parent")));
}

[Fact]
public void PropertiesReferenceShouldWork()
{
Expand Down Expand Up @@ -152,5 +154,42 @@ public void PropertiesReferenceShouldWork()
);
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(json), expected));
}

[Fact]
public async Task SerializeSchemaWithNullableShouldSucceed()
{
// Arrange
var expected = @"type: string
x-nullable: true";

var path = Path.Combine(SampleFolderPath, "schemaWithNullableExtension.yaml");

// Act
var schema = await OpenApiModelFactory.LoadAsync<OpenApiSchema>(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings);

var writer = new StringWriter();
schema.SerializeAsV2(new OpenApiYamlWriter(writer));
var schemaString = writer.ToString();

Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), schemaString.MakeLineBreaksEnvironmentNeutral());
}

[Fact]
public async Task SerializeSchemaWithOnlyNullableShouldSucceed()
{
// Arrange
var expected = @"x-nullable: true";

var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullableExtension.yaml");

// Act
var schema = await OpenApiModelFactory.LoadAsync<OpenApiSchema>(path, OpenApiSpecVersion.OpenApi2_0, new(), SettingsFixture.ReaderSettings);

var writer = new StringWriter();
schema.SerializeAsV2(new OpenApiYamlWriter(writer));
var schemaString = writer.ToString();

Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), schemaString.MakeLineBreaksEnvironmentNeutral());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
type: string
x-nullable: true
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
x-nullable: true
Loading