From 6115e5fbfe1886e3b072a1a66cd69568f1d57314 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, 8 Dec 2025 18:16:25 +0000 Subject: [PATCH 01/11] chore(support/v2): release 2.3.11 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 9 +++++++++ Directory.Build.props | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 380448567..bf97149fe 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.3.10" + ".": "2.3.11" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c809876c..e74aada15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.3.11](https://github.com/microsoft/OpenAPI.NET/compare/v2.3.10...v2.3.11) (2025-12-08) + + +### Bug Fixes + +* additional properties serialization should not emit a schema in v2 ([946cba9](https://github.com/microsoft/OpenAPI.NET/commit/946cba992a2733a60182453e38722b4ed789b729)) +* additional properties serialization should not emit a schema in v2 fix: additional properties serialization should not emit booleans in v3.1+ ([275dd9d](https://github.com/microsoft/OpenAPI.NET/commit/275dd9d7525b1f490eccaf1e6e60829ae51bdf5d)) +* additional properties serialization should not emit booleans in v3.1+ ([946cba9](https://github.com/microsoft/OpenAPI.NET/commit/946cba992a2733a60182453e38722b4ed789b729)) + ## [2.3.10](https://github.com/microsoft/OpenAPI.NET/compare/v2.3.9...v2.3.10) (2025-11-17) diff --git a/Directory.Build.props b/Directory.Build.props index 3f2536c56..77209392f 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.10 + 2.3.11 From 1633535e93d02c1a15fe280858ee2c3c85d42f07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:04:51 +0000 Subject: [PATCH 02/11] Bump PublicApiGenerator from 11.5.3 to 11.5.4 --- updated-dependencies: - dependency-name: PublicApiGenerator dependency-version: 11.5.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj index e0f481858..57388da62 100644 --- a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj +++ b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj @@ -18,7 +18,7 @@ - + From 597fc12fd1ddf5b184a2794315f49a392fdeb671 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:05:34 +0000 Subject: [PATCH 03/11] Bump the microsoftextensions group with 6 updates Bumps Microsoft.Extensions.DependencyInjection from 10.0.0 to 10.0.1 Bumps Microsoft.Extensions.Logging from 10.0.0 to 10.0.1 Bumps Microsoft.Extensions.Logging.Abstractions from 10.0.0 to 10.0.1 Bumps Microsoft.Extensions.Logging.Console from 10.0.0 to 10.0.1 Bumps Microsoft.Extensions.Logging.Debug from 10.0.0 to 10.0.1 Bumps System.Text.Json from 10.0.0 to 10.0.1 --- updated-dependencies: - dependency-name: Microsoft.Extensions.DependencyInjection dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: Microsoft.Extensions.Logging dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: Microsoft.Extensions.Logging dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: Microsoft.Extensions.Logging.Abstractions dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: Microsoft.Extensions.Logging.Console dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: System.Text.Json dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: Microsoft.Extensions.Logging.Console dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: Microsoft.Extensions.Logging.Debug dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions - dependency-name: Microsoft.Extensions.Logging.Debug dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoftextensions ... Signed-off-by: dependabot[bot] --- performance/resultsComparer/resultsComparer.csproj | 10 +++++----- .../Microsoft.OpenApi.Hidi.csproj | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/performance/resultsComparer/resultsComparer.csproj b/performance/resultsComparer/resultsComparer.csproj index 3fda31e2d..aab8f01bb 100644 --- a/performance/resultsComparer/resultsComparer.csproj +++ b/performance/resultsComparer/resultsComparer.csproj @@ -8,12 +8,12 @@ - - - - + + + + - + diff --git a/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj b/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj index b6afc5950..458972ef0 100644 --- a/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj +++ b/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj @@ -29,10 +29,10 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all From d6f54f372178f5c60b3ffe60ca00de4348a4572e Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Dec 2025 16:12:21 -0500 Subject: [PATCH 04/11] chore: bumps STJ to unblock extensions upgrade --- .../Microsoft.OpenApi.Readers.Tests.csproj | 2 +- test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj index d1d237681..a49f77ebd 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj +++ b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj @@ -19,7 +19,7 @@ - + diff --git a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj index 57388da62..6913fdd97 100644 --- a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj +++ b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj @@ -13,7 +13,7 @@ - + From 2436d7382bfbf8b9ba501d88f682e952bdf27146 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 11 Dec 2025 12:51:07 -0500 Subject: [PATCH 05/11] fix: non-seekable json streams would fail to load as a document --- .../Reader/OpenApiModelFactory.cs | 20 +-- .../Reader/OpenApiModelFactoryTests.cs | 166 ++++++++++++++++++ 2 files changed, 172 insertions(+), 14 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs index 6f02d9426..9903f7329 100644 --- a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs +++ b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs @@ -403,20 +403,12 @@ private static string InspectStreamFormat(Stream stream) // Inspect the format from the buffered portion format ??= InspectStreamFormat(bufferStream); - // If format is JSON, no need to buffer further — use the original stream. - if (format.Equals(OpenApiConstants.Json, StringComparison.OrdinalIgnoreCase)) - { - preparedStream = input; - } - else - { - // YAML or other non-JSON format; copy remaining input to a new stream. - preparedStream = new MemoryStream(); - bufferStream.Position = 0; - await bufferStream.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy buffered portion - await input.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy remaining data - preparedStream.Position = 0; - } + // we need to copy the stream to memory string we've already started reading it and can't reposition it + preparedStream = new MemoryStream(); + bufferStream.Position = 0; + await bufferStream.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy buffered portion + await input.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy remaining data + preparedStream.Position = 0; } else { diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs index 26bd6d472..bc7571b4e 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using System.IO; using System; +using System.Threading; namespace Microsoft.OpenApi.Tests.Reader; @@ -119,4 +120,169 @@ await File.WriteAllTextAsync(tempFilePathReferrer, Assert.NotNull(readResult.Document.Components); Assert.Equal(baseUri, readResult.Document.BaseUri); } + [Fact] + public async Task CanLoadANonSeekableStream() + { + // Given + var documentJson = +""" +{ + "openapi": "3.1.0", + "info": { + "title": "Sample API", + "version": "1.0.0" + }, + "paths": {} +} +"""; + 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); + } + + public sealed class NonSeekableStream : Stream + { + private readonly Stream _innerStream; + public NonSeekableStream(Stream stream) : base() + { + _innerStream = stream; + } + public override bool CanSeek => false; + + public override long Position { get => _innerStream.Position; set => throw new InvalidOperationException("Seeking is not supported."); } + + public override bool CanRead => _innerStream.CanRead; + + public override bool CanWrite => _innerStream.CanWrite; + + public override long Length => _innerStream.Length; + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state) + { + return _innerStream.BeginRead(buffer, offset, count, callback, state); + } + + public override void Flush() + { + _innerStream.Flush(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _innerStream.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException("Seeking is not supported."); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _innerStream.Write(buffer, offset, count); + } + protected override void Dispose(bool disposing) + { + _innerStream.Dispose(); + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _innerStream.DisposeAsync(); + await base.DisposeAsync(); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override bool CanTimeout => _innerStream.CanTimeout; + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state) + { + return _innerStream.BeginWrite(buffer, offset, count, callback, state); + } + + public override void CopyTo(Stream destination, int bufferSize) + { + _innerStream.CopyTo(destination, bufferSize); + } + + public override void Close() + { + _innerStream.Close(); + } + + public override int EndRead(IAsyncResult asyncResult) + { + return _innerStream.EndRead(asyncResult); + } + + public override void EndWrite(IAsyncResult asyncResult) + { + _innerStream.EndWrite(asyncResult); + } + + public override int ReadByte() + { + return _innerStream.ReadByte(); + } + + public override void WriteByte(byte value) + { + _innerStream.WriteByte(value); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _innerStream.FlushAsync(cancellationToken); + } + + public override int Read(Span buffer) + { + return _innerStream.Read(buffer); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.ReadAsync(buffer, cancellationToken); + } + + public override int ReadTimeout { get => _innerStream.ReadTimeout; set => _innerStream.ReadTimeout = value; } + + public override void Write(ReadOnlySpan buffer) + { + _innerStream.Write(buffer); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.WriteAsync(buffer, cancellationToken); + } + + public override int WriteTimeout { get => _innerStream.WriteTimeout; set => _innerStream.WriteTimeout = value; } + + } } From f9e524859722476b3111cb6006f77208c2d1f526 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 11 Dec 2025 14:34:46 -0500 Subject: [PATCH 06/11] fix: reading streams in an asp.net context would cause async exceptions --- .../Reader/OpenApiModelFactory.cs | 112 ++++++---- .../Reader/OpenApiModelFactoryTests.cs | 208 +++++++++++++++++- 2 files changed, 275 insertions(+), 45 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs index 9903f7329..06c60889d 100644 --- a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs +++ b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Security; @@ -362,7 +363,23 @@ private static string InspectInputFormat(string input) return input.StartsWith("{", StringComparison.OrdinalIgnoreCase) || input.StartsWith("[", StringComparison.OrdinalIgnoreCase) ? OpenApiConstants.Json : OpenApiConstants.Yaml; } - private static string InspectStreamFormat(Stream stream) + /// + /// Reads the initial bytes of the stream to determine if it is JSON or YAML. + /// + /// + /// It is important NOT TO change the stream type from MemoryStream. + /// In Asp.Net core 3.0+ we could get passed a stream from a request or response body. + /// In such case, we CAN'T use the ReadByte method as it throws NotSupportedException. + /// Therefore, we need to ensure that the stream is a MemoryStream before calling this method. + /// Maintaining this type ensures there won't be any unforeseen wrong usage of the method. + /// + /// The stream to inspect + /// The format of the stream. + private static string InspectStreamFormat(MemoryStream stream) + { + return TryInspectStreamFormat(stream, out var format) ? format! : throw new InvalidOperationException("Could not determine the format of the stream."); + } + private static bool TryInspectStreamFormat(Stream stream, out string? format) { #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(stream); @@ -370,57 +387,74 @@ private static string InspectStreamFormat(Stream stream) if (stream is null) throw new ArgumentNullException(nameof(stream)); #endif - long initialPosition = stream.Position; - int firstByte = stream.ReadByte(); - - // Skip whitespace if present and read the next non-whitespace byte - if (char.IsWhiteSpace((char)firstByte)) + try { - firstByte = stream.ReadByte(); - } + var initialPosition = stream.Position; + var firstByte = (char)stream.ReadByte(); + + // Skip whitespace if present and read the next non-whitespace byte + if (char.IsWhiteSpace(firstByte)) + { + firstByte = (char)stream.ReadByte(); + } - stream.Position = initialPosition; // Reset the stream position to the beginning + stream.Position = initialPosition; // Reset the stream position to the beginning - char firstChar = (char)firstByte; - return firstChar switch + format = firstByte switch + { + '{' or '[' => OpenApiConstants.Json, // If the first character is '{' or '[', assume JSON + _ => OpenApiConstants.Yaml // Otherwise assume YAML + }; + return true; + } + catch (NotSupportedException) + { + // https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/Kestrel/Core/src/Internal/Http/HttpResponseStream.cs#L40 + } +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER + catch (InvalidOperationException ex) when (ex.Message.Contains("AllowSynchronousIO", StringComparison.Ordinal)) +#else + catch (InvalidOperationException ex) when (ex.Message.Contains("AllowSynchronousIO")) +#endif { - '{' or '[' => OpenApiConstants.Json, // If the first character is '{' or '[', assume JSON - _ => OpenApiConstants.Yaml // Otherwise assume YAML - }; + // https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/HttpSys/src/RequestProcessing/RequestStream.cs#L100-L108 + // https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/IIS/IIS/src/Core/HttpRequestStream.cs#L24-L30 + // https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestStream.cs#L54-L60 + } + format = null; + return false; } + private static async Task CopyToMemoryStreamAsync(Stream input, CancellationToken token) + { + var bufferStream = new MemoryStream(); +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER + await input.CopyToAsync(bufferStream, token).ConfigureAwait(false); +#else + await input.CopyToAsync(bufferStream, 81920, token).ConfigureAwait(false); +#endif + bufferStream.Position = 0; + return bufferStream; + } + private static async Task<(Stream, string)> PrepareStreamForReadingAsync(Stream input, string? format, CancellationToken token = default) { Stream preparedStream = input; - if (!input.CanSeek) + if (input is MemoryStream ms) { - // Use a temporary buffer to read a small portion for format detection - using var bufferStream = new MemoryStream(); - await input.CopyToAsync(bufferStream, 1024, token).ConfigureAwait(false); - bufferStream.Position = 0; - - // Inspect the format from the buffered portion - format ??= InspectStreamFormat(bufferStream); - - // we need to copy the stream to memory string we've already started reading it and can't reposition it - preparedStream = new MemoryStream(); - bufferStream.Position = 0; - await bufferStream.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy buffered portion - await input.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy remaining data - preparedStream.Position = 0; + format ??= InspectStreamFormat(ms); } - else + else if (!input.CanSeek) { - format ??= InspectStreamFormat(input); - - if (!format.Equals(OpenApiConstants.Json, StringComparison.OrdinalIgnoreCase)) - { - // Buffer stream for non-JSON formats (e.g., YAML) since they require synchronous reading - preparedStream = new MemoryStream(); - await input.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); - preparedStream.Position = 0; - } + // Copy to a MemoryStream to enable seeking and perform format inspection + var bufferStream = await CopyToMemoryStreamAsync(input, token).ConfigureAwait(false); + return await PrepareStreamForReadingAsync(bufferStream, format, token).ConfigureAwait(false); + } + else if (!TryInspectStreamFormat(input, out format!)) + { + var bufferStream = await CopyToMemoryStreamAsync(input, token).ConfigureAwait(false); + return await PrepareStreamForReadingAsync(bufferStream, format, token).ConfigureAwait(false); } return (preparedStream, format); diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs index bc7571b4e..464e8fdf3 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs @@ -120,11 +120,7 @@ await File.WriteAllTextAsync(tempFilePathReferrer, Assert.NotNull(readResult.Document.Components); Assert.Equal(baseUri, readResult.Document.BaseUri); } - [Fact] - public async Task CanLoadANonSeekableStream() - { - // Given - var documentJson = + private readonly string documentJson = """ { "openapi": "3.1.0", @@ -135,6 +131,18 @@ public async Task CanLoadANonSeekableStream() "paths": {} } """; + private readonly string documentYaml = +""" +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +paths: {} +"""; + [Fact] + public async Task CanLoadANonSeekableStreamInJsonAndDetectFormat() + { + // Given using var memoryStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(documentJson)); using var nonSeekableStream = new NonSeekableStream(memoryStream); @@ -146,6 +154,194 @@ public async Task CanLoadANonSeekableStream() Assert.Equal("Sample API", document.Info.Title); } + [Fact] + public async Task CanLoadANonSeekableStreamInYamlAndDetectFormat() + { + // Given + using var memoryStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(documentYaml)); + using var nonSeekableStream = new NonSeekableStream(memoryStream); + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + + // When + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, settings: settings); + + // Then + Assert.NotNull(document); + Assert.Equal("Sample API", document.Info.Title); + } + + [Fact] + public async Task CanLoadAnAsyncOnlyStreamInJsonAndDetectFormat() + { + // Given + await using var memoryStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(documentJson)); + await using var nonSeekableStream = new AsyncOnlyStream(memoryStream); + + // When + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream); + + // Then + Assert.NotNull(document); + Assert.Equal("Sample API", document.Info.Title); + } + + [Fact] + public async Task CanLoadAnAsyncOnlyStreamInYamlAndDetectFormat() + { + // Given + await using var memoryStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(documentYaml)); + await using var nonSeekableStream = new AsyncOnlyStream(memoryStream); + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + + // When + var (document, _) = await OpenApiDocument.LoadAsync(nonSeekableStream, settings: settings); + + // Then + Assert.NotNull(document); + Assert.Equal("Sample API", document.Info.Title); + } + + public sealed class AsyncOnlyStream : Stream + { + private readonly Stream _innerStream; + public AsyncOnlyStream(Stream stream) : base() + { + _innerStream = stream; + } + public override bool CanSeek => _innerStream.CanSeek; + + public override long Position { get => _innerStream.Position; set => throw new NotSupportedException("Blocking operations are not supported"); } + + public override bool CanRead => _innerStream.CanRead; + + public override bool CanWrite => _innerStream.CanWrite; + + public override long Length => _innerStream.Length; + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state) + { + return _innerStream.BeginRead(buffer, offset, count, callback, state); + } + + public override void Flush() + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + protected override void Dispose(bool disposing) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override async ValueTask DisposeAsync() + { + await _innerStream.DisposeAsync(); + await base.DisposeAsync(); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override bool CanTimeout => _innerStream.CanTimeout; + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state) + { + return _innerStream.BeginWrite(buffer, offset, count, callback, state); + } + + public override void CopyTo(Stream destination, int bufferSize) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override void Close() + { + _innerStream.Close(); + } + + public override int EndRead(IAsyncResult asyncResult) + { + return _innerStream.EndRead(asyncResult); + } + + public override void EndWrite(IAsyncResult asyncResult) + { + _innerStream.EndWrite(asyncResult); + } + + public override int ReadByte() + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override void WriteByte(byte value) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _innerStream.FlushAsync(cancellationToken); + } + + public override int Read(Span buffer) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.ReadAsync(buffer, cancellationToken); + } + + public override int ReadTimeout { get => _innerStream.ReadTimeout; set => _innerStream.ReadTimeout = value; } + + public override void Write(ReadOnlySpan buffer) + { + throw new NotSupportedException("Blocking operations are not supported."); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.WriteAsync(buffer, cancellationToken); + } + + public override int WriteTimeout { get => _innerStream.WriteTimeout; set => _innerStream.WriteTimeout = value; } + + } + public sealed class NonSeekableStream : Stream { private readonly Stream _innerStream; @@ -155,7 +351,7 @@ public NonSeekableStream(Stream stream) : base() } public override bool CanSeek => false; - public override long Position { get => _innerStream.Position; set => throw new InvalidOperationException("Seeking is not supported."); } + public override long Position { get => _innerStream.Position; set => throw new NotSupportedException("Seeking is not supported."); } public override bool CanRead => _innerStream.CanRead; From fff87597988af9368c70fcd56fe8f613919f3b8b Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 11 Dec 2025 14:56:48 -0500 Subject: [PATCH 07/11] chore: linting --- src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs index 06c60889d..d6b57fbbb 100644 --- a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs +++ b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs @@ -445,17 +445,12 @@ private static async Task CopyToMemoryStreamAsync(Stream input, Ca { format ??= InspectStreamFormat(ms); } - else if (!input.CanSeek) + else if (!input.CanSeek || !TryInspectStreamFormat(input, out format!)) { // Copy to a MemoryStream to enable seeking and perform format inspection var bufferStream = await CopyToMemoryStreamAsync(input, token).ConfigureAwait(false); return await PrepareStreamForReadingAsync(bufferStream, format, token).ConfigureAwait(false); } - else if (!TryInspectStreamFormat(input, out format!)) - { - var bufferStream = await CopyToMemoryStreamAsync(input, token).ConfigureAwait(false); - return await PrepareStreamForReadingAsync(bufferStream, format, token).ConfigureAwait(false); - } return (preparedStream, format); } From 6461bac01c4176424210e9ac249698f665a514a6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 12 Dec 2025 09:06:30 -0500 Subject: [PATCH 08/11] 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 2e5895c01..c3d1f87d1 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 2c99ccfe6caae1eb6fad3d7848f9229cbc7b1481 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:01:25 +0000 Subject: [PATCH 09/11] chore(deps): bump actions/upload-artifact from 5 to 6 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4d53ec759..c13b4d179 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -76,7 +76,7 @@ jobs: working-directory: ./performance/benchmark - name: Publish benchmark results - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: if-no-files-found: error name: benchmark-results From 9eeaab5b35b0d8000f28f45e47a14adaa93e95c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:01:32 +0000 Subject: [PATCH 10/11] chore(deps): bump actions/cache from 4 to 5 Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/sonarcloud.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 3f969e825..7cd77844c 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -47,7 +47,7 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Cache SonarCloud packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar From 7c2568f4fa1013a84f44dbf8081874ae1efd9886 Mon Sep 17 00:00:00 2001 From: "release-please-token-provider[bot]" <225477224+release-please-token-provider[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:41:14 +0000 Subject: [PATCH 11/11] chore(main): release 3.0.3 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 9 +++++++++ Directory.Build.props | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a1304a172..9202efd86 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.0.2" + ".": "3.0.3" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bcf2306..e4da9e8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [3.0.3](https://github.com/microsoft/OpenAPI.NET/compare/v3.0.2...v3.0.3) (2025-12-16) + + +### Bug Fixes + +* load JSON documents that are preceded by multiple whitespace ([6461bac](https://github.com/microsoft/OpenAPI.NET/commit/6461bac01c4176424210e9ac249698f665a514a6)) +* 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)) + ## [3.0.2](https://github.com/microsoft/OpenAPI.NET/compare/v3.0.1...v3.0.2) (2025-12-08) ### Bug Fixes diff --git a/Directory.Build.props b/Directory.Build.props index 8582f5826..e5fa07a18 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.2 + 3.0.3