Skip to content
Open
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
54 changes: 51 additions & 3 deletions src/Microsoft.OpenApi/Models/OpenApiSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@
IList<IOpenApiSchema>? effectiveOneOf = OneOf;
IList<IOpenApiSchema>? effectiveAnyOf = AnyOf;
bool hasNullInComposition = false;
bool hasOneOfNullAndSingleEnumWith3_0 = false;
JsonSchemaType? inferredType = null;

if (version == OpenApiSpecVersion.OpenApi3_0)
Expand All @@ -531,6 +532,9 @@
(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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be count > 1 because enum values will have null and at least one enum value? Maybe I am wrong

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's valid (and more correct) to not have null in the enum array. We are dealing with something like:

"oneOf": [
  {
    "type": "null"
  },
  {
    "enum": [
      // ...
    ]
  }
]

The enum array could have a single element, and in all reasonable practical use cases, it must not have null, because then null becomes invalid as it matches both child schemas.

Note that the above shape is draft 2020-12, which isn't what we emit for OpenAPI 3.0. However, the OpenApiSchema model is designed more towards the 2020-12 draft.

That's why we can't emit the thing as-is and we have to do lots of special casing for versions. So, we emit the exact shape above that I mentioned for 3.1 and 3.2 which use 2020-12. But the same OpenApiSchema model serializes differently for 3.0 because it's not really using 2020-12 draft and the above shape isn't valid for OpenAPI 3.0.

That's at least how I'm understanding things currently.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid yes. More correct, not really. Each keyword is evaluated independently, the instance needs to validate one of the keywords. And enums are effectively oneOf consts. We could argue it's easier to ready though.

@Youssef1313 Youssef1313 Jul 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@baywet

What I mean is something like:

"oneOf": [
  {
    "type": "null"
  },
  {
    "enum": [
      "A",
      "B",
      null
    ]
  }
]

In this case, null matches both child schemas. And so, oneOf fails and null won't be considered valid.

See https://www.jsonschemavalidator.net/s/bdAKMMTl

So my point is that a schema like the above is pointless.

}

// type
Expand All @@ -543,7 +547,27 @@
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback);

// oneOf
writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback);
if (hasOneOfNullAndSingleEnumWith3_0)
{
writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf!, (writer, element) =>
{
var clonedToMutateEnum = element.CreateShallowCopy();
if (clonedToMutateEnum is OpenApiSchema { Enum: { } existingEnum } concreteCloned)
{
concreteCloned.Enum = [.. existingEnum, null!];
callback(writer, clonedToMutateEnum);
}
else
{
callback(writer, element);
}
});
}
else
{
writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback);
}


// not
writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback);
Expand Down Expand Up @@ -1070,10 +1094,34 @@

foreach (var schema in nonNullSchemas)
{
commonType |= schema.Type.GetValueOrDefault() & ~JsonSchemaType.Null;
if (schema.Type.HasValue)
{
commonType |= schema.Type.Value & ~JsonSchemaType.Null;
}
else if (schema.Enum is { Count: > 0 })
{
foreach (var enumValue in schema.Enum)
{
if (enumValue 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;
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.
Comment on lines +1103 to +1119
commonType |= JsonSchemaType.String;
}
Comment thread
Youssef1313 marked this conversation as resolved.
}

return (nonNullSchemas, commonType, true);
return (nonNullSchemas, commonType == 0 ? null : commonType, true);
}
else
{
Expand Down
75 changes: 75 additions & 0 deletions test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FluentAssertions;
using VerifyXunit;
Expand Down Expand Up @@ -1853,6 +1856,78 @@ public void DeserializeContainsExtensionsInV3AssignsContainsProperties()
Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MinContainsExtension));
}

[Fact]
public async Task SerializeNullableEnumWith3_0()
{
// https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20
// Documentation for nullable states:
// This keyword only takes effect if type is explicitly defined within the same Schema Object.
// So, we want to ensure that we emit the type property if we will be adding nullable property.
// In addition, we need to still keep 'null' in the enum array.
// Otherwise, validators will consider null as invalid even if nullable is set to true.
// It's unclear if it's an issue of the validators or not, but it's safer to do it that way.
var schema = CreateNullableEnumSchema();
var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
var expected = """
{
"type": "string",
"oneOf": [
{
"enum": [
"A",
"B",
null
]
Comment thread
baywet marked this conversation as resolved.
}
],
"nullable": true
}
""";

Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result)));
}

[Theory]
[InlineData(OpenApiSpecVersion.OpenApi3_1)]
[InlineData(OpenApiSpecVersion.OpenApi3_2)]
public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion version)
{
var schema = CreateNullableEnumSchema();
var result = await schema.SerializeAsJsonAsync(version);
var expected = """
{
"oneOf": [
{
"type": "null"
},
{
"enum": [
"A",
"B"
]
}
]
}
""";
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result)));
}

private OpenApiSchema CreateNullableEnumSchema()
{
var schema = new OpenApiSchema();
schema.OneOf ??= [];
schema.OneOf.Add(new OpenApiSchema() { Type = JsonSchemaType.Null });
schema.OneOf.Add(new OpenApiSchema()
{
Enum = new List<JsonNode>
{
JsonValue.Create("A"),
JsonValue.Create("B")
}
});
return schema;
}

internal class SchemaVisitor : OpenApiVisitorBase
{
public List<string> Titles = new();
Expand Down