diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 9202efd86..e0dc5001b 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "3.0.3"
+ ".": "3.1.0"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4da9e8f2..fe45c813e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,13 @@
# Changelog
-## [3.0.3](https://github.com/microsoft/OpenAPI.NET/compare/v3.0.2...v3.0.3) (2025-12-16)
+## [3.1.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.0.3...v3.1.0) (2025-12-17)
+
+
+### Features
+* Add `type: "null"` downcasting when in oneOf and anyOf for OpenAPI v3 ([782cf8d](https://github.com/microsoft/OpenAPI.NET/commit/782cf8d1ff8166e3c7be706e08dabf168b9616a4))
+
+## [3.0.3](https://github.com/microsoft/OpenAPI.NET/compare/v3.0.2...v3.0.3) (2025-12-16)
### Bug Fixes
@@ -37,6 +43,16 @@
* adds support for OpenAPI 3.2.0 ([765a8dd](https://github.com/microsoft/OpenAPI.NET/commit/765a8dd4d6efd1a31b6a76d282ccffa5877a845a))
+## [2.3.12](https://github.com/microsoft/OpenAPI.NET/compare/v2.3.11...v2.3.12) (2025-12-15)
+
+
+### Bug Fixes
+
+* load JSON documents that are preceded by multiple whitespace ([640e59a](https://github.com/microsoft/OpenAPI.NET/commit/640e59a143a2a6d3b79c9f6a97a6029593d7dd8f))
+* non-seekable json streams would fail to load as a document ([76b0159](https://github.com/microsoft/OpenAPI.NET/commit/76b0159d12790ab00310ff107e37396ecdf13336))
+* non-seekable json streams would fail to load as a document ([2436d73](https://github.com/microsoft/OpenAPI.NET/commit/2436d7382bfbf8b9ba501d88f682e952bdf27146))
+* reading streams in an asp.net context would cause async exceptions ([f9e5248](https://github.com/microsoft/OpenAPI.NET/commit/f9e524859722476b3111cb6006f77208c2d1f526))
+
## [2.3.11](https://github.com/microsoft/OpenAPI.NET/compare/v2.3.10...v2.3.11) (2025-12-08)
### Bug Fixes
diff --git a/Directory.Build.props b/Directory.Build.props
index e5fa07a18..11a996a34 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -12,7 +12,7 @@
https://github.com/Microsoft/OpenAPI.NET
© Microsoft Corporation. All rights reserved.
OpenAPI .NET
- 3.0.3
+ 3.1.0
diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
index 1bb7639da..7556c1f30 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
@@ -443,23 +443,39 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
// enum
var enumValue = Enum is not { Count: > 0 }
- && !string.IsNullOrEmpty(Const)
+ && !string.IsNullOrEmpty(Const)
&& version < OpenApiSpecVersion.OpenApi3_1
? new List { JsonValue.Create(Const)! }
: Enum;
writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s));
+ // Handle oneOf/anyOf with null type for v3.0 downcast
+ IList? effectiveOneOf = OneOf;
+ IList? effectiveAnyOf = AnyOf;
+ bool hasNullInComposition = false;
+ JsonSchemaType? inferredType = null;
+
+ if (version == OpenApiSpecVersion.OpenApi3_0)
+ {
+ (effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf);
+ hasNullInComposition |= nullInOneOf;
+ inferredType = inferredOneOf ?? inferredType;
+ (effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf);
+ hasNullInComposition |= nullInAnyOf;
+ inferredType = inferredAnyOf ?? inferredType;
+ }
+
// type
- SerializeTypeProperty(writer, version);
+ SerializeTypeProperty(writer, version, inferredType);
// allOf
writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback);
// anyOf
- writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, callback);
+ writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback);
// oneOf
- writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, callback);
+ writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback);
// not
writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback);
@@ -498,7 +514,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
// nullable
if (version == OpenApiSpecVersion.OpenApi3_0)
{
- SerializeNullable(writer, version);
+ SerializeNullable(writer, version, hasNullInComposition);
}
// discriminator
@@ -771,14 +787,17 @@ private void SerializeAsV2(
writer.WriteEndObject();
}
- private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version)
+ private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null)
{
- if (Type is null)
+ // Use original type or inferred type when the explicit type is not set
+ var typeToUse = Type ?? inferredType;
+
+ if (typeToUse is null)
{
return;
}
- var unifiedType = IsNullable ? Type.Value | JsonSchemaType.Null : Type.Value;
+ var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value;
var typeWithoutNull = unifiedType & ~JsonSchemaType.Null;
switch (version)
@@ -809,8 +828,8 @@ private static bool HasMultipleTypes(JsonSchemaType schemaType)
private static void WriteUnifiedSchemaType(JsonSchemaType type, IOpenApiWriter writer)
{
var array = (from JsonSchemaType flag in jsonSchemaTypeValues
- where type.HasFlag(flag)
- select flag.ToFirstIdentifier()).ToArray();
+ where type.HasFlag(flag)
+ select flag.ToFirstIdentifier()).ToArray();
if (array.Length > 1)
{
writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) =>
@@ -827,9 +846,9 @@ where type.HasFlag(flag)
}
}
- private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version)
+ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false)
{
- if (IsNullable)
+ if (IsNullable || hasNullInComposition)
{
switch (version)
{
@@ -843,6 +862,41 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version
}
}
+ ///
+ /// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type.
+ ///
+ /// The list of schemas in the composition.
+ /// A tuple with the effective list, inferred type, and whether null is present in composition.
+ private static (IList? effective, JsonSchemaType? inferredType, bool hasNullInComposition)
+ ProcessCompositionForNull(IList? composition)
+ {
+ if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null))
+ {
+ // Nothing to patch
+ return (composition, null, false);
+ }
+
+ var nonNullSchemas = composition
+ .Where(static s => s.Type is null or not JsonSchemaType.Null)
+ .ToList();
+
+ if (nonNullSchemas.Count > 0)
+ {
+ JsonSchemaType commonType = 0;
+
+ foreach (var schema in nonNullSchemas)
+ {
+ commonType |= schema.Type.GetValueOrDefault() & ~JsonSchemaType.Null;
+ }
+
+ return (nonNullSchemas, commonType, true);
+ }
+ else
+ {
+ return (null, null, true);
+ }
+ }
+
#if NET5_0_OR_GREATER
private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues();
#else
diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
index 04406a370..d485901af 100644
--- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
+++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
@@ -792,6 +792,319 @@ public async Task SerializeAdditionalPropertiesAsV3PlusEmits(OpenApiSpecVersion
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
}
+ [Fact]
+ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync()
+ {
+ // Arrange - oneOf with null and a reference-like schema
+ var schema = new OpenApiSchema
+ {
+ OneOf = new List
+ {
+ new OpenApiSchema { Type = JsonSchemaType.Null },
+ new OpenApiSchema
+ {
+ Type = JsonSchemaType.String,
+ MaxLength = 10
+ }
+ }
+ };
+
+ var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false });
+
+ // Act
+ schema.SerializeAsV3(writer);
+ await writer.FlushAsync();
+
+ var v3Schema = outputStringWriter.GetStringBuilder().ToString();
+
+ var expectedV3Schema =
+ """
+ {
+ "type": "string",
+ "oneOf": [
+ {
+ "maxLength": 10,
+ "type": "string"
+ }
+ ],
+ "nullable": true
+ }
+ """;
+
+ // Assert
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema)));
+ }
+
+ [Fact]
+ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNullableWithoutType()
+ {
+ // Arrange - oneOf with null, string, and number
+ var schema = new OpenApiSchema
+ {
+ OneOf = new List
+ {
+ new OpenApiSchema { Type = JsonSchemaType.Null },
+ new OpenApiSchema { Type = JsonSchemaType.String },
+ new OpenApiSchema { Type = JsonSchemaType.Number },
+ }
+ };
+
+ var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false });
+
+ // Act
+ schema.SerializeAsV3(writer);
+ await writer.FlushAsync();
+
+ var v3Schema = outputStringWriter.GetStringBuilder().ToString();
+
+ var expectedV3Schema =
+ """
+ {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ],
+ "nullable": true
+ }
+ """;
+
+ // Assert
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema)));
+ }
+
+ [Fact]
+ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync()
+ {
+ // Arrange - anyOf with null and object schema
+ var schema = new OpenApiSchema
+ {
+ AnyOf = new List
+ {
+ new OpenApiSchema { Type = JsonSchemaType.Null },
+ new OpenApiSchema
+ {
+ Type = JsonSchemaType.Object,
+ Properties = new Dictionary
+ {
+ ["id"] = new OpenApiSchema { Type = JsonSchemaType.Integer }
+ }
+ }
+ }
+ };
+
+ var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false });
+
+ // Act
+ schema.SerializeAsV3(writer);
+ await writer.FlushAsync();
+
+ var v3Schema = outputStringWriter.GetStringBuilder().ToString();
+
+ var expectedV3Schema =
+ """
+ {
+ "type": "object",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ }
+ }
+ }
+ ],
+ "nullable": true
+ }
+ """;
+ // Assert
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema)));
+ }
+
+ [Fact]
+ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullable()
+ {
+ // Arrange - anyOf with null and multiple schemas
+ var schema = new OpenApiSchema
+ {
+ AnyOf = new List
+ {
+ new OpenApiSchema { Type = JsonSchemaType.Null },
+ new OpenApiSchema { Type = JsonSchemaType.String, MinLength = 1 },
+ new OpenApiSchema { Type = JsonSchemaType.Integer }
+ }
+ };
+
+ var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false });
+
+ // Act
+ schema.SerializeAsV3(writer);
+ await writer.FlushAsync();
+
+ var v3Schema = outputStringWriter.GetStringBuilder().ToString();
+
+ var expectedV3Schema =
+ """
+ {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "integer"
+ }
+ ],
+ "nullable": true
+ }
+ """;
+
+ // Assert
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema)));
+ }
+
+ [Fact]
+ public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync()
+ {
+ // Arrange - oneOf with only null
+ var schema = new OpenApiSchema
+ {
+ OneOf = new List
+ {
+ new OpenApiSchema { Type = JsonSchemaType.Null }
+ }
+ };
+
+ var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false });
+
+ // Act
+ schema.SerializeAsV3(writer);
+ await writer.FlushAsync();
+
+ var v3Schema = outputStringWriter.GetStringBuilder().ToString();
+
+ var expectedV3Schema =
+ """
+ {
+ "nullable": true
+ }
+ """;
+
+ // Assert
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema)));
+ }
+
+ [Fact]
+ public async Task SerializeOneOfWithNullAsV31ShouldNotChangeAsync()
+ {
+ // Arrange - oneOf with null should remain unchanged in v3.1
+ var schema = new OpenApiSchema
+ {
+ OneOf = new List
+ {
+ new OpenApiSchema { Type = JsonSchemaType.Null },
+ new OpenApiSchema { Type = JsonSchemaType.String }
+ }
+ };
+
+ var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false });
+
+ // Act
+ schema.SerializeAsV31(writer);
+ await writer.FlushAsync();
+
+ var v31Schema = outputStringWriter.GetStringBuilder().ToString();
+
+ var expectedV31Schema =
+ """
+ {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ """;
+
+ // Assert
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV31Schema), JsonNode.Parse(v31Schema)));
+ }
+
+ [Fact]
+ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync()
+ {
+ // Arrange - oneOf with null and a $ref to a schema component
+ var document = new OpenApiDocument
+ {
+ Components = new OpenApiComponents
+ {
+ Schemas = new Dictionary
+ {
+ ["Pet"] = new OpenApiSchema
+ {
+ Type = JsonSchemaType.Object,
+ Properties = new Dictionary
+ {
+ ["id"] = new OpenApiSchema { Type = JsonSchemaType.Integer },
+ ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }
+ }
+ }
+ }
+ }
+ };
+
+ // Register components so references can be resolved
+ document.Workspace.RegisterComponents(document);
+
+ var schemaRef = new OpenApiSchemaReference("Pet", document);
+
+ var schema = new OpenApiSchema
+ {
+ OneOf = new List
+ {
+ new OpenApiSchema { Type = JsonSchemaType.Null },
+ schemaRef
+ }
+ };
+
+ var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false });
+
+ // Act
+ schema.SerializeAsV3(writer);
+ await writer.FlushAsync();
+
+ var v3Schema = outputStringWriter.GetStringBuilder().ToString();
+
+ var expectedV3Schema =
+ """
+ {
+ "type": "object",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/Pet"
+ }
+ ],
+ "nullable": true
+ }
+ """;
+
+ // Assert
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema)));
+ }
internal class SchemaVisitor : OpenApiVisitorBase
{