|
2 | 2 | // Licensed under the MIT license. |
3 | 3 |
|
4 | 4 | using System; |
| 5 | +using System.Diagnostics.CodeAnalysis; |
5 | 6 | using System.IO; |
6 | 7 | using System.Linq; |
7 | 8 | using System.Security; |
@@ -362,73 +363,93 @@ private static string InspectInputFormat(string input) |
362 | 363 | return input.StartsWith("{", StringComparison.OrdinalIgnoreCase) || input.StartsWith("[", StringComparison.OrdinalIgnoreCase) ? OpenApiConstants.Json : OpenApiConstants.Yaml; |
363 | 364 | } |
364 | 365 |
|
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) |
366 | 383 | { |
367 | 384 | #if NET6_0_OR_GREATER |
368 | 385 | ArgumentNullException.ThrowIfNull(stream); |
369 | 386 | #else |
370 | 387 | if (stream is null) throw new ArgumentNullException(nameof(stream)); |
371 | 388 | #endif |
372 | 389 |
|
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 |
378 | 391 | { |
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 | + } |
381 | 400 |
|
382 | | - stream.Position = initialPosition; // Reset the stream position to the beginning |
| 401 | + stream.Position = initialPosition; // Reset the stream position to the beginning |
383 | 402 |
|
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 |
386 | 419 | { |
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; |
390 | 426 | } |
391 | 427 |
|
| 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 | + |
392 | 440 | private static async Task<(Stream, string)> PrepareStreamForReadingAsync(Stream input, string? format, CancellationToken token = default) |
393 | 441 | { |
394 | 442 | Stream preparedStream = input; |
395 | 443 |
|
396 | | - if (!input.CanSeek) |
| 444 | + if (input is MemoryStream ms) |
397 | 445 | { |
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); |
420 | 447 | } |
421 | | - else |
| 448 | + else if (!input.CanSeek || !TryInspectStreamFormat(input, out format!)) |
422 | 449 | { |
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); |
432 | 453 | } |
433 | 454 |
|
434 | 455 | return (preparedStream, format); |
|
0 commit comments