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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

## [3.0.2](https://github.com/microsoft/OpenAPI.NET/compare/v3.0.1...v3.0.2) (2025-12-08)


### Bug Fixes

* additional properties serialization should not emit a schema in v2 ([946cba9](https://github.com/microsoft/OpenAPI.NET/commit/946cba992a2733a60182453e38722b4ed789b729))
Expand All @@ -29,6 +28,14 @@

* adds support for OpenAPI 3.2.0 ([765a8dd](https://github.com/microsoft/OpenAPI.NET/commit/765a8dd4d6efd1a31b6a76d282ccffa5877a845a))

## [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)

* empty strings should be quoted in yaml ([e919b33](https://github.com/microsoft/OpenAPI.NET/commit/e919b33e9d09159217066248483ef4c767865c82))
Expand Down
119 changes: 71 additions & 48 deletions src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Security;
Expand Down Expand Up @@ -308,7 +309,7 @@
var response = await settings.HttpClient.GetAsync(url, token).ConfigureAwait(false);
var mediaType = response.Content.Headers.ContentType?.MediaType;
var contentType = mediaType?.Split(";".ToCharArray(), StringSplitOptions.RemoveEmptyEntries)[0];
format = contentType?.Split('/').Last().Split('+').Last().Split('-').Last();

Check warning on line 312 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Indexing at Count-1 should be used instead of the "Enumerable" extension method "Last" (https://rules.sonarsource.com/csharp/RSPEC-6608)

Check warning on line 312 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Indexing at Count-1 should be used instead of the "Enumerable" extension method "Last" (https://rules.sonarsource.com/csharp/RSPEC-6608)

Check warning on line 312 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Indexing at Count-1 should be used instead of the "Enumerable" extension method "Last" (https://rules.sonarsource.com/csharp/RSPEC-6608)

Check warning on line 312 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Indexing at Count-1 should be used instead of the "Enumerable" extension method "Last" (https://rules.sonarsource.com/csharp/RSPEC-6608)

Check warning on line 312 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Indexing at Count-1 should be used instead of the "Enumerable" extension method "Last" (https://rules.sonarsource.com/csharp/RSPEC-6608)

Check warning on line 312 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Indexing at Count-1 should be used instead of the "Enumerable" extension method "Last" (https://rules.sonarsource.com/csharp/RSPEC-6608)

Check warning on line 312 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Indexing at Count-1 should be used instead of the "Enumerable" extension method "Last" (https://rules.sonarsource.com/csharp/RSPEC-6608)

// for non-standard MIME types e.g. text/x-yaml used in older libs or apps
#if NETSTANDARD2_0
Expand Down Expand Up @@ -359,76 +360,98 @@

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

private static string InspectStreamFormat(Stream stream)
/// <summary>
/// Reads the initial bytes of the stream to determine if it is JSON or YAML.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="stream">The stream to inspect</param>
/// <returns>The format of the stream.</returns>
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);
#else
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

Choose a reason for hiding this comment

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

It feels like this comment is misleading. It mentions that it will read the next non-whitespace byte, but actually it seems like it reads the next byte unconditionally, regardless of what character it is. Considering whitespace is insignificant in JSON, it seems fair to assume multiple leading spaces are present. Changing the if to a while seems like it could do the trick?

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the suggestion, it was a bit outside of the initial scope but I made the change.

while (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<MemoryStream> 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);

// 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;
}
format ??= InspectStreamFormat(ms);
}
else
else if (!input.CanSeek || !TryInspectStreamFormat(input, out format!))
{
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);
}

return (preparedStream, format);
Expand Down
Loading
Loading