Skip to content

Commit 76b0159

Browse files
authored
Merge pull request #2639 from microsoft/fix/non-seekable-stream
fix: non-seekable json streams would fail to load as a document
2 parents 1f9bff4 + fff8759 commit 76b0159

File tree

2 files changed

+430
-47
lines changed

2 files changed

+430
-47
lines changed

src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

Lines changed: 68 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT license.
33

44
using System;
5+
using System.Diagnostics.CodeAnalysis;
56
using System.IO;
67
using System.Linq;
78
using System.Security;
@@ -362,73 +363,93 @@ private static string InspectInputFormat(string input)
362363
return input.StartsWith("{", StringComparison.OrdinalIgnoreCase) || input.StartsWith("[", StringComparison.OrdinalIgnoreCase) ? OpenApiConstants.Json : OpenApiConstants.Yaml;
363364
}
364365

365-
private static string InspectStreamFormat(Stream stream)
366+
/// <summary>
367+
/// Reads the initial bytes of the stream to determine if it is JSON or YAML.
368+
/// </summary>
369+
/// <remarks>
370+
/// It is important NOT TO change the stream type from MemoryStream.
371+
/// In Asp.Net core 3.0+ we could get passed a stream from a request or response body.
372+
/// In such case, we CAN'T use the ReadByte method as it throws NotSupportedException.
373+
/// Therefore, we need to ensure that the stream is a MemoryStream before calling this method.
374+
/// Maintaining this type ensures there won't be any unforeseen wrong usage of the method.
375+
/// </remarks>
376+
/// <param name="stream">The stream to inspect</param>
377+
/// <returns>The format of the stream.</returns>
378+
private static string InspectStreamFormat(MemoryStream stream)
379+
{
380+
return TryInspectStreamFormat(stream, out var format) ? format! : throw new InvalidOperationException("Could not determine the format of the stream.");
381+
}
382+
private static bool TryInspectStreamFormat(Stream stream, out string? format)
366383
{
367384
#if NET6_0_OR_GREATER
368385
ArgumentNullException.ThrowIfNull(stream);
369386
#else
370387
if (stream is null) throw new ArgumentNullException(nameof(stream));
371388
#endif
372389

373-
long initialPosition = stream.Position;
374-
int firstByte = stream.ReadByte();
375-
376-
// Skip whitespace if present and read the next non-whitespace byte
377-
if (char.IsWhiteSpace((char)firstByte))
390+
try
378391
{
379-
firstByte = stream.ReadByte();
380-
}
392+
var initialPosition = stream.Position;
393+
var firstByte = (char)stream.ReadByte();
394+
395+
// Skip whitespace if present and read the next non-whitespace byte
396+
if (char.IsWhiteSpace(firstByte))
397+
{
398+
firstByte = (char)stream.ReadByte();
399+
}
381400

382-
stream.Position = initialPosition; // Reset the stream position to the beginning
401+
stream.Position = initialPosition; // Reset the stream position to the beginning
383402

384-
char firstChar = (char)firstByte;
385-
return firstChar switch
403+
format = firstByte switch
404+
{
405+
'{' or '[' => OpenApiConstants.Json, // If the first character is '{' or '[', assume JSON
406+
_ => OpenApiConstants.Yaml // Otherwise assume YAML
407+
};
408+
return true;
409+
}
410+
catch (NotSupportedException)
411+
{
412+
// https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/Kestrel/Core/src/Internal/Http/HttpResponseStream.cs#L40
413+
}
414+
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
415+
catch (InvalidOperationException ex) when (ex.Message.Contains("AllowSynchronousIO", StringComparison.Ordinal))
416+
#else
417+
catch (InvalidOperationException ex) when (ex.Message.Contains("AllowSynchronousIO"))
418+
#endif
386419
{
387-
'{' or '[' => OpenApiConstants.Json, // If the first character is '{' or '[', assume JSON
388-
_ => OpenApiConstants.Yaml // Otherwise assume YAML
389-
};
420+
// https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/HttpSys/src/RequestProcessing/RequestStream.cs#L100-L108
421+
// https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/IIS/IIS/src/Core/HttpRequestStream.cs#L24-L30
422+
// https://github.com/dotnet/aspnetcore/blob/c9d0750396e1d319301255ba61842721ab72ab10/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestStream.cs#L54-L60
423+
}
424+
format = null;
425+
return false;
390426
}
391427

428+
private static async Task<MemoryStream> CopyToMemoryStreamAsync(Stream input, CancellationToken token)
429+
{
430+
var bufferStream = new MemoryStream();
431+
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
432+
await input.CopyToAsync(bufferStream, token).ConfigureAwait(false);
433+
#else
434+
await input.CopyToAsync(bufferStream, 81920, token).ConfigureAwait(false);
435+
#endif
436+
bufferStream.Position = 0;
437+
return bufferStream;
438+
}
439+
392440
private static async Task<(Stream, string)> PrepareStreamForReadingAsync(Stream input, string? format, CancellationToken token = default)
393441
{
394442
Stream preparedStream = input;
395443

396-
if (!input.CanSeek)
444+
if (input is MemoryStream ms)
397445
{
398-
// Use a temporary buffer to read a small portion for format detection
399-
using var bufferStream = new MemoryStream();
400-
await input.CopyToAsync(bufferStream, 1024, token).ConfigureAwait(false);
401-
bufferStream.Position = 0;
402-
403-
// Inspect the format from the buffered portion
404-
format ??= InspectStreamFormat(bufferStream);
405-
406-
// If format is JSON, no need to buffer further — use the original stream.
407-
if (format.Equals(OpenApiConstants.Json, StringComparison.OrdinalIgnoreCase))
408-
{
409-
preparedStream = input;
410-
}
411-
else
412-
{
413-
// YAML or other non-JSON format; copy remaining input to a new stream.
414-
preparedStream = new MemoryStream();
415-
bufferStream.Position = 0;
416-
await bufferStream.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy buffered portion
417-
await input.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false); // Copy remaining data
418-
preparedStream.Position = 0;
419-
}
446+
format ??= InspectStreamFormat(ms);
420447
}
421-
else
448+
else if (!input.CanSeek || !TryInspectStreamFormat(input, out format!))
422449
{
423-
format ??= InspectStreamFormat(input);
424-
425-
if (!format.Equals(OpenApiConstants.Json, StringComparison.OrdinalIgnoreCase))
426-
{
427-
// Buffer stream for non-JSON formats (e.g., YAML) since they require synchronous reading
428-
preparedStream = new MemoryStream();
429-
await input.CopyToAsync(preparedStream, 81920, token).ConfigureAwait(false);
430-
preparedStream.Position = 0;
431-
}
450+
// Copy to a MemoryStream to enable seeking and perform format inspection
451+
var bufferStream = await CopyToMemoryStreamAsync(input, token).ConfigureAwait(false);
452+
return await PrepareStreamForReadingAsync(bufferStream, format, token).ConfigureAwait(false);
432453
}
433454

434455
return (preparedStream, format);

0 commit comments

Comments
 (0)