From 640e59a143a2a6d3b79c9f6a97a6029593d7dd8f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 12 Dec 2025 09:06:30 -0500 Subject: [PATCH 1/3] fix: load JSON documents that are preceded by multiple whitespace Signed-off-by: Vincent Biret --- .../Reader/OpenApiModelFactory.cs | 6 +++-- .../Reader/OpenApiModelFactoryTests.cs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs index d6b57fbbb..2abd04873 100644 --- a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs +++ b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs @@ -360,7 +360,9 @@ SecurityException or private static string InspectInputFormat(string input) { - return input.StartsWith("{", StringComparison.OrdinalIgnoreCase) || input.StartsWith("[", StringComparison.OrdinalIgnoreCase) ? OpenApiConstants.Json : OpenApiConstants.Yaml; + var trimmedInput = input.TrimStart(); + return trimmedInput.StartsWith("{", StringComparison.OrdinalIgnoreCase) || trimmedInput.StartsWith("[", StringComparison.OrdinalIgnoreCase) ? + OpenApiConstants.Json : OpenApiConstants.Yaml; } /// @@ -393,7 +395,7 @@ private static bool TryInspectStreamFormat(Stream stream, out string? format) var firstByte = (char)stream.ReadByte(); // Skip whitespace if present and read the next non-whitespace byte - if (char.IsWhiteSpace(firstByte)) + while (char.IsWhiteSpace(firstByte)) { firstByte = (char)stream.ReadByte(); } diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs index 464e8fdf3..c51d3e9db 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs @@ -203,6 +203,32 @@ public async Task CanLoadAnAsyncOnlyStreamInYamlAndDetectFormat() Assert.Equal("Sample API", document.Info.Title); } + [Fact] + public async Task CanLoadANonSeekableStreamInJsonAndDetectFormatWhenPrecededBySpaces() + { + // Given + using var memoryStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(" " + documentJson)); + using var nonSeekableStream = new NonSeekableStream(memoryStream); + + // When + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream); + + // Then + Assert.NotNull(document); + Assert.Equal("Sample API", document.Info.Title); + } + + [Fact] + public void CanLoadAStringJsonAndDetectFormatWhenPrecededBySpaces() + { + // When + var (document, _) = OpenApiDocument.Parse(" " + documentJson); + + // Then + Assert.NotNull(document); + Assert.Equal("Sample API", document.Info.Title); + } + public sealed class AsyncOnlyStream : Stream { private readonly Stream _innerStream; From 95baa73bfa779f6648686cc30e344eb244dfc3b2 Mon Sep 17 00:00:00 2001 From: "release-please-token-provider[bot]" <225477224+release-please-token-provider[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:38:02 +0000 Subject: [PATCH 2/3] chore(support/v2): release 2.3.12 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 10 ++++++++++ Directory.Build.props | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bf97149fe..de740effa 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.3.11" + ".": "2.3.12" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e74aada15..53c21a800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [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) diff --git a/Directory.Build.props b/Directory.Build.props index 77209392f..26fffd5c2 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 - 2.3.11 + 2.3.12 From 782cf8d1ff8166e3c7be706e08dabf168b9616a4 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Wed, 17 Dec 2025 21:58:43 +0100 Subject: [PATCH 3/3] Merge pull request #2645 from desjoerd/fix/support-v2/gh-2629 feat: Add `type: "null"` downcasting when in oneOf and anyOf for OpenAPI v3 --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 78 ++++- .../Models/OpenApiSchemaTests.cs | 313 ++++++++++++++++++ 2 files changed, 379 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index f31644c1f..1ffccd272 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -438,23 +438,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); @@ -493,7 +509,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // nullable if (version == OpenApiSpecVersion.OpenApi3_0) { - SerializeNullable(writer, version); + SerializeNullable(writer, version, hasNullInComposition); } // discriminator @@ -766,14 +782,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) @@ -804,8 +823,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) => @@ -822,9 +841,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) { @@ -838,6 +857,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 6ffbb9fe4..c20da3127 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -790,6 +790,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 {