From e059baf49e2af8b4d91a5b0d53feb71761cd2df9 Mon Sep 17 00:00:00 2001 From: Brennan Date: Tue, 17 Sep 2024 10:30:17 -0700 Subject: [PATCH 1/4] Fix IAsyncEnumerable controller methods to allow setting headers --- .../SystemTextJsonOutputFormatter.cs | 12 +- .../Infrastructure/AsyncEnumerableHelper.cs | 32 +++++ .../SystemTextJsonResultExecutor.cs | 10 +- .../SystemTextJsonOutputFormatterTest.cs | 117 ++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs index c51ca745d8e7..ae2e42f97431 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Core.Infrastructure; namespace Microsoft.AspNetCore.Mvc.Formatters; @@ -88,10 +89,17 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon try { var responseWriter = httpContext.Response.BodyWriter; + if (!httpContext.Response.HasStarted) { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - await httpContext.Response.StartAsync(); + var typeToCheck = context.ObjectType ?? context.Object?.GetType(); + // Don't call StartAsync for IAsyncEnumerable methods. Headers might be set in the controller method which isn't invoked until + // JsonSerializer starts iterating over the IAsyncEnumerable. + if (typeToCheck is not null && !AsyncEnumerableHelper.IsIAsyncEnumerable(typeToCheck)) + { + // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. + await httpContext.Response.StartAsync(); + } } if (jsonTypeInfo is not null) diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs b/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs new file mode 100644 index 000000000000..021100ca3fa7 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Core.Infrastructure; + +internal static class AsyncEnumerableHelper +{ + internal static bool IsIAsyncEnumerable(Type type) => GetIAsyncEnumerableInterface(type) is not null; + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070:UnrecognizedReflectionPattern", + Justification = "The 'IAsyncEnumerable<>' Type must exist (due to typeof(IAsyncEnumerable<>) used below)" + + " and so the trimmer kept it. In which case " + + "It also kept it on any type which implements it. The below call to GetInterfaces " + + "may return fewer results when trimmed but it will return 'IAsyncEnumerable<>' " + + "if the type implemented it, even after trimming.")] + private static Type? GetIAsyncEnumerableInterface(Type type) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) + { + return type; + } + + if (type.GetInterface("IAsyncEnumerable`1") is Type t) + { + return t; + } + + return null; + } +} diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs index cfce28c8dc64..ad4b478b5d6c 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Core.Infrastructure; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -68,8 +69,13 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result) var responseWriter = response.BodyWriter; if (!response.HasStarted) { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - await response.StartAsync(); + // Don't call StartAsync for IAsyncEnumerable methods. Headers might be set in the controller method which isn't invoked until + // JsonSerializer starts iterating over the IAsyncEnumerable. + if (!AsyncEnumerableHelper.IsIAsyncEnumerable(objectType)) + { + // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. + await response.StartAsync(); + } } await JsonSerializer.SerializeAsync(responseWriter, value, objectType, jsonSerializerOptions, context.HttpContext.RequestAborted); diff --git a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs index efbe148abb5c..01033e7aa359 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Pipelines; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.InternalTesting; using Microsoft.DotNet.RemoteExecutor; using Microsoft.Extensions.Primitives; @@ -113,6 +115,121 @@ public async Task WriteResponseBodyAsync_ForLargeAsyncEnumerable() Assert.Equal(expected.ToArray(), body.ToArray()); } + // Regression test: https://github.com/dotnet/aspnetcore/issues/57895 + [Fact] + public async Task WriteResponseBodyAsync_AsyncEnumerableStartAsyncNotCalled() + { + // Arrange + TestHttpResponseBodyFeature responseBodyFeature = null; + var expected = new MemoryStream(); + await JsonSerializer.SerializeAsync(expected, AsyncEnumerable(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var formatter = GetOutputFormatter(); + var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); + var encoding = CreateOrGetSupportedEncoding(formatter, "utf-8", isDefaultEncoding: true); + + var body = new MemoryStream(); + + var actionContext = GetActionContext(mediaType, body); + responseBodyFeature = new TestHttpResponseBodyFeature(actionContext.HttpContext.Features.Get()); + actionContext.HttpContext.Features.Set(responseBodyFeature); + + var asyncEnumerable = AsyncEnumerable(); + var outputFormatterContext = new OutputFormatterWriteContext( + actionContext.HttpContext, + new TestHttpResponseStreamWriterFactory().CreateWriter, + asyncEnumerable.GetType(), + asyncEnumerable) + { + ContentType = new StringSegment(mediaType.ToString()), + }; + + // Act + await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-8")); + + // Assert + Assert.Equal(expected.ToArray(), body.ToArray()); + + async IAsyncEnumerable AsyncEnumerable() + { + // StartAsync shouldn't be called by SystemTestJsonOutputFormatter when using IAsyncEnumerable + // This allows Controller methods to set Headers, etc. + Assert.False(responseBodyFeature?.StartCalled ?? false); + await Task.Yield(); + yield return 1; + } + } + + [Fact] + public async Task WriteResponseBodyAsync_StartAsyncCalled() + { + // Arrange + TestHttpResponseBodyFeature responseBodyFeature = null; + var expected = new MemoryStream(); + await JsonSerializer.SerializeAsync(expected, 1, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var formatter = GetOutputFormatter(); + var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); + var encoding = CreateOrGetSupportedEncoding(formatter, "utf-8", isDefaultEncoding: true); + + var body = new MemoryStream(); + + var actionContext = GetActionContext(mediaType, body); + responseBodyFeature = new TestHttpResponseBodyFeature(actionContext.HttpContext.Features.Get()); + actionContext.HttpContext.Features.Set(responseBodyFeature); + + var outputFormatterContext = new OutputFormatterWriteContext( + actionContext.HttpContext, + new TestHttpResponseStreamWriterFactory().CreateWriter, + typeof(int), + 1) + { + ContentType = new StringSegment(mediaType.ToString()), + }; + + // Act + await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-8")); + + // Assert + Assert.Equal(expected.ToArray(), body.ToArray()); + Assert.True(responseBodyFeature.StartCalled); + } + + public class TestHttpResponseBodyFeature : IHttpResponseBodyFeature + { + private readonly IHttpResponseBodyFeature _inner; + + public bool StartCalled; + + public TestHttpResponseBodyFeature(IHttpResponseBodyFeature inner) + { + _inner = inner; + } + + public Stream Stream => _inner.Stream; + + public PipeWriter Writer => _inner.Writer; + + public Task CompleteAsync() + { + return _inner.CompleteAsync(); + } + + public void DisableBuffering() + { + _inner.DisableBuffering(); + } + + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) + { + return _inner.SendFileAsync(path, offset, count, cancellationToken); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + StartCalled = true; + return _inner.StartAsync(cancellationToken); + } + } + [Fact] public async Task WriteResponseBodyAsync_AsyncEnumerableConnectionCloses() { From 67ff576c7eb8794b9445cf0e0b953909b019909e Mon Sep 17 00:00:00 2001 From: Brennan Date: Tue, 17 Sep 2024 11:33:10 -0700 Subject: [PATCH 2/4] name --- src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs b/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs index 021100ca3fa7..55e86a67dae5 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs @@ -22,9 +22,9 @@ internal static class AsyncEnumerableHelper return type; } - if (type.GetInterface("IAsyncEnumerable`1") is Type t) + if (type.GetInterface("IAsyncEnumerable`1") is Type asyncEnumerableType) { - return t; + return asyncEnumerableType; } return null; From 2144ed380102fd6c857b95cefaf63a1810e0f0e6 Mon Sep 17 00:00:00 2001 From: Brennan Date: Wed, 18 Sep 2024 14:24:41 -0700 Subject: [PATCH 3/4] httpjson extensions too --- .../src/HttpResponseJsonExtensions.cs | 21 +++- ...icrosoft.AspNetCore.Http.Extensions.csproj | 1 + .../test/HttpResponseJsonExtensionsTests.cs | 117 +++++++++++++++++- .../SystemTextJsonOutputFormatter.cs | 2 +- .../SystemTextJsonResultExecutor.cs | 1 - .../src/Microsoft.AspNetCore.Mvc.Core.csproj | 1 + .../Reflection}/AsyncEnumerableHelper.cs | 6 +- 7 files changed, 139 insertions(+), 10 deletions(-) rename src/{Mvc/Mvc.Core/src/Infrastructure => Shared/Reflection}/AsyncEnumerableHelper.cs (88%) diff --git a/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs index c7d003e6bb0b..daff7e5048e8 100644 --- a/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs +++ b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -91,7 +92,9 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; var startTask = Task.CompletedTask; - if (!response.HasStarted) + // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until + // JsonSerializer starts iterating over the IAsyncEnumerable. + if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(typeof(TValue))) { // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. startTask = response.StartAsync(cancellationToken); @@ -132,7 +135,9 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; var startTask = Task.CompletedTask; - if (!response.HasStarted) + // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until + // JsonSerializer starts iterating over the IAsyncEnumerable. + if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(typeof(TValue))) { // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. startTask = response.StartAsync(cancellationToken); @@ -185,7 +190,9 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; var startTask = Task.CompletedTask; - if (!response.HasStarted) + // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until + // JsonSerializer starts iterating over the IAsyncEnumerable. + if (!response.HasStarted && value is not null && !AsyncEnumerableHelper.IsIAsyncEnumerable(value.GetType())) { // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. startTask = response.StartAsync(cancellationToken); @@ -305,7 +312,9 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; var startTask = Task.CompletedTask; - if (!response.HasStarted) + // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until + // JsonSerializer starts iterating over the IAsyncEnumerable. + if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(type)) { // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. startTask = response.StartAsync(cancellationToken); @@ -368,7 +377,9 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; var startTask = Task.CompletedTask; - if (!response.HasStarted) + // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until + // JsonSerializer starts iterating over the IAsyncEnumerable. + if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(type)) { // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. startTask = response.StartAsync(cancellationToken); diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index aa9645a5faa1..54985f077711 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -36,6 +36,7 @@ + diff --git a/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs b/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs index 35cfa265d7f1..770c27061ee3 100644 --- a/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs +++ b/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs @@ -1,12 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Pipelines; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Microsoft.AspNetCore.InternalTesting; +using Microsoft.AspNetCore.Http.Features; #nullable enable @@ -481,6 +482,83 @@ public async Task WriteAsJsonAsync_NullValue_WithJsonTypeInfo_JsonResponse() Assert.Equal("null", data); } + [Fact] + public async Task WriteAsJsonAsyncGeneric_AsyncEnumerableStartAsyncNotCalled() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + var responseBodyFeature = new TestHttpResponseBodyFeature(context.Features.GetRequiredFeature()); + context.Features.Set(responseBodyFeature); + + // Act + await context.Response.WriteAsJsonAsync(AsyncEnumerable()); + + // Assert + Assert.Equal(ContentTypeConstants.JsonContentTypeWithCharset, context.Response.ContentType); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + Assert.Equal("[1,2]", Encoding.UTF8.GetString(body.ToArray())); + + async IAsyncEnumerable AsyncEnumerable() + { + Assert.False(responseBodyFeature.StartCalled); + await Task.Yield(); + yield return 1; + yield return 2; + } + } + + [Fact] + public async Task WriteAsJsonAsync_AsyncEnumerableStartAsyncNotCalled() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + var responseBodyFeature = new TestHttpResponseBodyFeature(context.Features.GetRequiredFeature()); + context.Features.Set(responseBodyFeature); + + // Act + await context.Response.WriteAsJsonAsync(AsyncEnumerable(), typeof(IAsyncEnumerable)); + + // Assert + Assert.Equal(ContentTypeConstants.JsonContentTypeWithCharset, context.Response.ContentType); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + Assert.Equal("[1,2]", Encoding.UTF8.GetString(body.ToArray())); + + async IAsyncEnumerable AsyncEnumerable() + { + Assert.False(responseBodyFeature.StartCalled); + await Task.Yield(); + yield return 1; + yield return 2; + } + } + + [Fact] + public async Task WriteAsJsonAsync_StartAsyncCalled() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + var responseBodyFeature = new TestHttpResponseBodyFeature(context.Features.GetRequiredFeature()); + context.Features.Set(responseBodyFeature); + + // Act + await context.Response.WriteAsJsonAsync(new int[] {1, 2}, typeof(int[])); + + // Assert + Assert.Equal(ContentTypeConstants.JsonContentTypeWithCharset, context.Response.ContentType); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + Assert.Equal("[1,2]", Encoding.UTF8.GetString(body.ToArray())); + Assert.True(responseBodyFeature.StartCalled); + } + public class TestObject { public string? StringProperty { get; set; } @@ -530,4 +608,41 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo return new ValueTask(tcs.Task); } } + + public class TestHttpResponseBodyFeature : IHttpResponseBodyFeature + { + private readonly IHttpResponseBodyFeature _inner; + + public bool StartCalled; + + public TestHttpResponseBodyFeature(IHttpResponseBodyFeature inner) + { + _inner = inner; + } + + public Stream Stream => _inner.Stream; + + public PipeWriter Writer => _inner.Writer; + + public Task CompleteAsync() + { + return _inner.CompleteAsync(); + } + + public void DisableBuffering() + { + _inner.DisableBuffering(); + } + + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) + { + return _inner.SendFileAsync(path, offset, count, cancellationToken); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + StartCalled = true; + return _inner.StartAsync(cancellationToken); + } + } } diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs index ae2e42f97431..c0cb11dc206d 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs @@ -7,7 +7,7 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Core.Infrastructure; +using Microsoft.AspNetCore.Internal; namespace Microsoft.AspNetCore.Mvc.Formatters; diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs index ad4b478b5d6c..6938e10e874f 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs @@ -6,7 +6,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Core; -using Microsoft.AspNetCore.Mvc.Core.Infrastructure; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj index 2f0ca390d01f..d3276f56bdaa 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj @@ -40,6 +40,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute + diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs b/src/Shared/Reflection/AsyncEnumerableHelper.cs similarity index 88% rename from src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs rename to src/Shared/Reflection/AsyncEnumerableHelper.cs index 55e86a67dae5..c0e77afcafa4 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableHelper.cs +++ b/src/Shared/Reflection/AsyncEnumerableHelper.cs @@ -1,9 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -namespace Microsoft.AspNetCore.Mvc.Core.Infrastructure; +namespace Microsoft.AspNetCore.Internal; internal static class AsyncEnumerableHelper { @@ -15,7 +17,7 @@ internal static class AsyncEnumerableHelper "It also kept it on any type which implements it. The below call to GetInterfaces " + "may return fewer results when trimmed but it will return 'IAsyncEnumerable<>' " + "if the type implemented it, even after trimming.")] - private static Type? GetIAsyncEnumerableInterface(Type type) + internal static Type? GetIAsyncEnumerableInterface(Type type) { if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) { From fa447de795e4d9187aa2843369659c7757c90267 Mon Sep 17 00:00:00 2001 From: Brennan Date: Fri, 20 Sep 2024 10:48:00 -0700 Subject: [PATCH 4/4] revert --- .../src/HttpResponseJsonExtensions.cs | 113 +++----------- ...icrosoft.AspNetCore.Http.Extensions.csproj | 1 - .../test/HttpResponseJsonExtensionsTests.cs | 143 ++++++------------ ...ft.AspNetCore.Http.Extensions.Tests.csproj | 1 + .../SystemTextJsonOutputFormatter.cs | 13 -- .../SystemTextJsonResultExecutor.cs | 11 -- .../src/Microsoft.AspNetCore.Mvc.Core.csproj | 1 - .../SystemTextJsonOutputFormatterTest.cs | 117 -------------- .../SystemTextJsonOutputFormatterTest.cs | 17 +++ ...SystemTextJsonOutputFormatterController.cs | 8 + .../Reflection/AsyncEnumerableHelper.cs | 34 ----- 11 files changed, 96 insertions(+), 363 deletions(-) delete mode 100644 src/Shared/Reflection/AsyncEnumerableHelper.cs diff --git a/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs index daff7e5048e8..84e09c1a3581 100644 --- a/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs +++ b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs @@ -7,7 +7,6 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http.Json; -using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -91,24 +90,12 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; - var startTask = Task.CompletedTask; - // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until - // JsonSerializer starts iterating over the IAsyncEnumerable. - if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(typeof(TValue))) - { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - startTask = response.StartAsync(cancellationToken); - } - // if no user provided token, pass the RequestAborted token and ignore OperationCanceledException - if (!startTask.IsCompleted || !cancellationToken.CanBeCanceled) + if (!cancellationToken.CanBeCanceled) { - return WriteAsJsonAsyncSlow(startTask, response.BodyWriter, value, options, - ignoreOCE: !cancellationToken.CanBeCanceled, - cancellationToken.CanBeCanceled ? cancellationToken : response.HttpContext.RequestAborted); + return WriteAsJsonAsyncSlow(response.BodyWriter, value, options, response.HttpContext.RequestAborted); } - startTask.GetAwaiter().GetResult(); return JsonSerializer.SerializeAsync(response.BodyWriter, value, options, cancellationToken); } @@ -134,35 +121,22 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; - var startTask = Task.CompletedTask; - // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until - // JsonSerializer starts iterating over the IAsyncEnumerable. - if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(typeof(TValue))) - { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - startTask = response.StartAsync(cancellationToken); - } - // if no user provided token, pass the RequestAborted token and ignore OperationCanceledException - if (!startTask.IsCompleted || !cancellationToken.CanBeCanceled) + if (!cancellationToken.CanBeCanceled) { - return WriteAsJsonAsyncSlow(startTask, response, value, jsonTypeInfo, - ignoreOCE: !cancellationToken.CanBeCanceled, - cancellationToken.CanBeCanceled ? cancellationToken : response.HttpContext.RequestAborted); + return WriteAsJsonAsyncSlow(response, value, jsonTypeInfo, response.HttpContext.RequestAborted); } - startTask.GetAwaiter().GetResult(); return JsonSerializer.SerializeAsync(response.BodyWriter, value, jsonTypeInfo, cancellationToken); - static async Task WriteAsJsonAsyncSlow(Task startTask, HttpResponse response, TValue value, JsonTypeInfo jsonTypeInfo, - bool ignoreOCE, CancellationToken cancellationToken) + static async Task WriteAsJsonAsyncSlow(HttpResponse response, TValue value, JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken) { try { - await startTask; await JsonSerializer.SerializeAsync(response.BodyWriter, value, jsonTypeInfo, cancellationToken); } - catch (OperationCanceledException) when (ignoreOCE) { } + catch (OperationCanceledException) { } } } @@ -189,54 +163,38 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; - var startTask = Task.CompletedTask; - // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until - // JsonSerializer starts iterating over the IAsyncEnumerable. - if (!response.HasStarted && value is not null && !AsyncEnumerableHelper.IsIAsyncEnumerable(value.GetType())) - { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - startTask = response.StartAsync(cancellationToken); - } - // if no user provided token, pass the RequestAborted token and ignore OperationCanceledException - if (!startTask.IsCompleted || !cancellationToken.CanBeCanceled) + if (!cancellationToken.CanBeCanceled) { - return WriteAsJsonAsyncSlow(startTask, response, value, jsonTypeInfo, - ignoreOCE: !cancellationToken.CanBeCanceled, - cancellationToken.CanBeCanceled ? cancellationToken : response.HttpContext.RequestAborted); + return WriteAsJsonAsyncSlow(response, value, jsonTypeInfo, response.HttpContext.RequestAborted); } - startTask.GetAwaiter().GetResult(); return JsonSerializer.SerializeAsync(response.BodyWriter, value, jsonTypeInfo, cancellationToken); - static async Task WriteAsJsonAsyncSlow(Task startTask, HttpResponse response, object? value, JsonTypeInfo jsonTypeInfo, - bool ignoreOCE, CancellationToken cancellationToken) + static async Task WriteAsJsonAsyncSlow(HttpResponse response, object? value, JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken) { try { - await startTask; await JsonSerializer.SerializeAsync(response.BodyWriter, value, jsonTypeInfo, cancellationToken); } - catch (OperationCanceledException) when (ignoreOCE) { } + catch (OperationCanceledException) { } } } [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(RequiresDynamicCodeMessage)] private static async Task WriteAsJsonAsyncSlow( - Task startTask, PipeWriter body, TValue value, JsonSerializerOptions? options, - bool ignoreOCE, CancellationToken cancellationToken) { try { - await startTask; await JsonSerializer.SerializeAsync(body, value, options, cancellationToken); } - catch (OperationCanceledException) when (ignoreOCE) { } + catch (OperationCanceledException) { } } /// @@ -311,44 +269,30 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; - var startTask = Task.CompletedTask; - // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until - // JsonSerializer starts iterating over the IAsyncEnumerable. - if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(type)) - { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - startTask = response.StartAsync(cancellationToken); - } - // if no user provided token, pass the RequestAborted token and ignore OperationCanceledException - if (!startTask.IsCompleted || !cancellationToken.CanBeCanceled) + if (!cancellationToken.CanBeCanceled) { - return WriteAsJsonAsyncSlow(startTask, response.BodyWriter, value, type, options, - ignoreOCE: !cancellationToken.CanBeCanceled, - cancellationToken.CanBeCanceled ? cancellationToken : response.HttpContext.RequestAborted); + return WriteAsJsonAsyncSlow(response.BodyWriter, value, type, options, + response.HttpContext.RequestAborted); } - startTask.GetAwaiter().GetResult(); return JsonSerializer.SerializeAsync(response.BodyWriter, value, type, options, cancellationToken); } [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(RequiresDynamicCodeMessage)] private static async Task WriteAsJsonAsyncSlow( - Task startTask, PipeWriter body, object? value, Type type, JsonSerializerOptions? options, - bool ignoreOCE, CancellationToken cancellationToken) { try { - await startTask; await JsonSerializer.SerializeAsync(body, value, type, options, cancellationToken); } - catch (OperationCanceledException) when (ignoreOCE) { } + catch (OperationCanceledException) { } } /// @@ -376,35 +320,22 @@ public static Task WriteAsJsonAsync( response.ContentType = contentType ?? ContentTypeConstants.JsonContentTypeWithCharset; - var startTask = Task.CompletedTask; - // Don't call StartAsync for IAsyncEnumerable. Headers might be set at the beginning of the generator which isn't invoked until - // JsonSerializer starts iterating over the IAsyncEnumerable. - if (!response.HasStarted && !AsyncEnumerableHelper.IsIAsyncEnumerable(type)) - { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - startTask = response.StartAsync(cancellationToken); - } - // if no user provided token, pass the RequestAborted token and ignore OperationCanceledException - if (!startTask.IsCompleted || !cancellationToken.CanBeCanceled) + if (!cancellationToken.CanBeCanceled) { - return WriteAsJsonAsyncSlow(startTask, response.BodyWriter, value, type, context, - ignoreOCE: !cancellationToken.CanBeCanceled, - cancellationToken.CanBeCanceled ? cancellationToken : response.HttpContext.RequestAborted); + return WriteAsJsonAsyncSlow(response.BodyWriter, value, type, context, response.HttpContext.RequestAborted); } - startTask.GetAwaiter().GetResult(); return JsonSerializer.SerializeAsync(response.BodyWriter, value, type, context, cancellationToken); - static async Task WriteAsJsonAsyncSlow(Task startTask, PipeWriter body, object? value, Type type, JsonSerializerContext context, - bool ignoreOCE, CancellationToken cancellationToken) + static async Task WriteAsJsonAsyncSlow(PipeWriter body, object? value, Type type, JsonSerializerContext context, + CancellationToken cancellationToken) { try { - await startTask; await JsonSerializer.SerializeAsync(body, value, type, context, cancellationToken); } - catch (OperationCanceledException) when (ignoreOCE) { } + catch (OperationCanceledException) { } } } diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index 54985f077711..aa9645a5faa1 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -36,7 +36,6 @@ - diff --git a/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs b/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs index 770c27061ee3..3d5007e73d26 100644 --- a/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs +++ b/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs @@ -7,7 +7,9 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.TestHost; #nullable enable @@ -482,81 +484,69 @@ public async Task WriteAsJsonAsync_NullValue_WithJsonTypeInfo_JsonResponse() Assert.Equal("null", data); } + // Regression test: https://github.com/dotnet/aspnetcore/issues/57895 [Fact] - public async Task WriteAsJsonAsyncGeneric_AsyncEnumerableStartAsyncNotCalled() + public async Task AsyncEnumerableCanSetHeader() { - // Arrange - var body = new MemoryStream(); - var context = new DefaultHttpContext(); - context.Response.Body = body; - var responseBodyFeature = new TestHttpResponseBodyFeature(context.Features.GetRequiredFeature()); - context.Features.Set(responseBodyFeature); - - // Act - await context.Response.WriteAsJsonAsync(AsyncEnumerable()); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); - // Assert - Assert.Equal(ContentTypeConstants.JsonContentTypeWithCharset, context.Response.ContentType); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + await using var app = builder.Build(); - Assert.Equal("[1,2]", Encoding.UTF8.GetString(body.ToArray())); - - async IAsyncEnumerable AsyncEnumerable() + app.MapGet("/", IAsyncEnumerable (HttpContext httpContext) => { - Assert.False(responseBodyFeature.StartCalled); - await Task.Yield(); - yield return 1; - yield return 2; - } - } + return AsyncEnum(); - [Fact] - public async Task WriteAsJsonAsync_AsyncEnumerableStartAsyncNotCalled() - { - // Arrange - var body = new MemoryStream(); - var context = new DefaultHttpContext(); - context.Response.Body = body; - var responseBodyFeature = new TestHttpResponseBodyFeature(context.Features.GetRequiredFeature()); - context.Features.Set(responseBodyFeature); + async IAsyncEnumerable AsyncEnum() + { + await Task.Yield(); + httpContext.Response.Headers["Test"] = "t"; + yield return 1; + } + }); - // Act - await context.Response.WriteAsJsonAsync(AsyncEnumerable(), typeof(IAsyncEnumerable)); + await app.StartAsync(); - // Assert - Assert.Equal(ContentTypeConstants.JsonContentTypeWithCharset, context.Response.ContentType); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + var client = app.GetTestClient(); - Assert.Equal("[1,2]", Encoding.UTF8.GetString(body.ToArray())); + var result = await client.GetAsync("/"); + result.EnsureSuccessStatusCode(); + var headerValue = Assert.Single(result.Headers.GetValues("Test")); + Assert.Equal("t", headerValue); - async IAsyncEnumerable AsyncEnumerable() - { - Assert.False(responseBodyFeature.StartCalled); - await Task.Yield(); - yield return 1; - yield return 2; - } + await app.StopAsync(); } + // Regression test: https://github.com/dotnet/aspnetcore/issues/57895 [Fact] - public async Task WriteAsJsonAsync_StartAsyncCalled() + public async Task EnumerableCanSetHeader() { - // Arrange - var body = new MemoryStream(); - var context = new DefaultHttpContext(); - context.Response.Body = body; - var responseBodyFeature = new TestHttpResponseBodyFeature(context.Features.GetRequiredFeature()); - context.Features.Set(responseBodyFeature); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); - // Act - await context.Response.WriteAsJsonAsync(new int[] {1, 2}, typeof(int[])); + await using var app = builder.Build(); - // Assert - Assert.Equal(ContentTypeConstants.JsonContentTypeWithCharset, context.Response.ContentType); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + app.MapGet("/", IEnumerable (HttpContext httpContext) => + { + return Enum(); - Assert.Equal("[1,2]", Encoding.UTF8.GetString(body.ToArray())); - Assert.True(responseBodyFeature.StartCalled); + IEnumerable Enum() + { + httpContext.Response.Headers["Test"] = "t"; + yield return 1; + } + }); + + await app.StartAsync(); + + var client = app.GetTestClient(); + + var result = await client.GetAsync("/"); + result.EnsureSuccessStatusCode(); + var headerValue = Assert.Single(result.Headers.GetValues("Test")); + Assert.Equal("t", headerValue); + + await app.StopAsync(); } public class TestObject @@ -608,41 +598,4 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo return new ValueTask(tcs.Task); } } - - public class TestHttpResponseBodyFeature : IHttpResponseBodyFeature - { - private readonly IHttpResponseBodyFeature _inner; - - public bool StartCalled; - - public TestHttpResponseBodyFeature(IHttpResponseBodyFeature inner) - { - _inner = inner; - } - - public Stream Stream => _inner.Stream; - - public PipeWriter Writer => _inner.Writer; - - public Task CompleteAsync() - { - return _inner.CompleteAsync(); - } - - public void DisableBuffering() - { - _inner.DisableBuffering(); - } - - public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) - { - return _inner.SendFileAsync(path, offset, count, cancellationToken); - } - - public Task StartAsync(CancellationToken cancellationToken = default) - { - StartCalled = true; - return _inner.StartAsync(cancellationToken); - } - } } diff --git a/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj b/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj index 686ab34dd28a..4a35778afa55 100644 --- a/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj +++ b/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs index c0cb11dc206d..f4e82f6857f7 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs @@ -7,7 +7,6 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Internal; namespace Microsoft.AspNetCore.Mvc.Formatters; @@ -90,18 +89,6 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon { var responseWriter = httpContext.Response.BodyWriter; - if (!httpContext.Response.HasStarted) - { - var typeToCheck = context.ObjectType ?? context.Object?.GetType(); - // Don't call StartAsync for IAsyncEnumerable methods. Headers might be set in the controller method which isn't invoked until - // JsonSerializer starts iterating over the IAsyncEnumerable. - if (typeToCheck is not null && !AsyncEnumerableHelper.IsIAsyncEnumerable(typeToCheck)) - { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - await httpContext.Response.StartAsync(); - } - } - if (jsonTypeInfo is not null) { await JsonSerializer.SerializeAsync(responseWriter, context.Object, jsonTypeInfo, httpContext.RequestAborted); diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs index 6938e10e874f..167d4f71bec0 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs @@ -66,17 +66,6 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result) try { var responseWriter = response.BodyWriter; - if (!response.HasStarted) - { - // Don't call StartAsync for IAsyncEnumerable methods. Headers might be set in the controller method which isn't invoked until - // JsonSerializer starts iterating over the IAsyncEnumerable. - if (!AsyncEnumerableHelper.IsIAsyncEnumerable(objectType)) - { - // Flush headers before starting Json serialization. This avoids an extra layer of buffering before the first flush. - await response.StartAsync(); - } - } - await JsonSerializer.SerializeAsync(responseWriter, value, objectType, jsonSerializerOptions, context.HttpContext.RequestAborted); } catch (OperationCanceledException) when (context.HttpContext.RequestAborted.IsCancellationRequested) { } diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj index d3276f56bdaa..2f0ca390d01f 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj @@ -40,7 +40,6 @@ Microsoft.AspNetCore.Mvc.RouteAttribute - diff --git a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs index 01033e7aa359..efbe148abb5c 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs @@ -1,13 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Pipelines; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.InternalTesting; using Microsoft.DotNet.RemoteExecutor; using Microsoft.Extensions.Primitives; @@ -115,121 +113,6 @@ public async Task WriteResponseBodyAsync_ForLargeAsyncEnumerable() Assert.Equal(expected.ToArray(), body.ToArray()); } - // Regression test: https://github.com/dotnet/aspnetcore/issues/57895 - [Fact] - public async Task WriteResponseBodyAsync_AsyncEnumerableStartAsyncNotCalled() - { - // Arrange - TestHttpResponseBodyFeature responseBodyFeature = null; - var expected = new MemoryStream(); - await JsonSerializer.SerializeAsync(expected, AsyncEnumerable(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); - var formatter = GetOutputFormatter(); - var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); - var encoding = CreateOrGetSupportedEncoding(formatter, "utf-8", isDefaultEncoding: true); - - var body = new MemoryStream(); - - var actionContext = GetActionContext(mediaType, body); - responseBodyFeature = new TestHttpResponseBodyFeature(actionContext.HttpContext.Features.Get()); - actionContext.HttpContext.Features.Set(responseBodyFeature); - - var asyncEnumerable = AsyncEnumerable(); - var outputFormatterContext = new OutputFormatterWriteContext( - actionContext.HttpContext, - new TestHttpResponseStreamWriterFactory().CreateWriter, - asyncEnumerable.GetType(), - asyncEnumerable) - { - ContentType = new StringSegment(mediaType.ToString()), - }; - - // Act - await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-8")); - - // Assert - Assert.Equal(expected.ToArray(), body.ToArray()); - - async IAsyncEnumerable AsyncEnumerable() - { - // StartAsync shouldn't be called by SystemTestJsonOutputFormatter when using IAsyncEnumerable - // This allows Controller methods to set Headers, etc. - Assert.False(responseBodyFeature?.StartCalled ?? false); - await Task.Yield(); - yield return 1; - } - } - - [Fact] - public async Task WriteResponseBodyAsync_StartAsyncCalled() - { - // Arrange - TestHttpResponseBodyFeature responseBodyFeature = null; - var expected = new MemoryStream(); - await JsonSerializer.SerializeAsync(expected, 1, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - var formatter = GetOutputFormatter(); - var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); - var encoding = CreateOrGetSupportedEncoding(formatter, "utf-8", isDefaultEncoding: true); - - var body = new MemoryStream(); - - var actionContext = GetActionContext(mediaType, body); - responseBodyFeature = new TestHttpResponseBodyFeature(actionContext.HttpContext.Features.Get()); - actionContext.HttpContext.Features.Set(responseBodyFeature); - - var outputFormatterContext = new OutputFormatterWriteContext( - actionContext.HttpContext, - new TestHttpResponseStreamWriterFactory().CreateWriter, - typeof(int), - 1) - { - ContentType = new StringSegment(mediaType.ToString()), - }; - - // Act - await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-8")); - - // Assert - Assert.Equal(expected.ToArray(), body.ToArray()); - Assert.True(responseBodyFeature.StartCalled); - } - - public class TestHttpResponseBodyFeature : IHttpResponseBodyFeature - { - private readonly IHttpResponseBodyFeature _inner; - - public bool StartCalled; - - public TestHttpResponseBodyFeature(IHttpResponseBodyFeature inner) - { - _inner = inner; - } - - public Stream Stream => _inner.Stream; - - public PipeWriter Writer => _inner.Writer; - - public Task CompleteAsync() - { - return _inner.CompleteAsync(); - } - - public void DisableBuffering() - { - _inner.DisableBuffering(); - } - - public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) - { - return _inner.SendFileAsync(path, offset, count, cancellationToken); - } - - public Task StartAsync(CancellationToken cancellationToken = default) - { - StartCalled = true; - return _inner.StartAsync(cancellationToken); - } - } - [Fact] public async Task WriteResponseBodyAsync_AsyncEnumerableConnectionCloses() { diff --git a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs index df54ab0d8cd9..d4906ab320f6 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs @@ -65,4 +65,21 @@ public async Task Formatting_PolymorphicModel_WithJsonPolymorphism() await response.AssertStatusCodeAsync(HttpStatusCode.OK); Assert.Equal(expected, await response.Content.ReadAsStringAsync()); } + + // Regression test: https://github.com/dotnet/aspnetcore/issues/57895 + [Fact] + public async Task CanSetHeaderWithAsyncEnumerable() + { + // Arrange + var expected = "[1]"; + + // Act + var response = await Client.GetAsync($"/SystemTextJsonOutputFormatter/{nameof(SystemTextJsonOutputFormatterController.AsyncEnumerable)}"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + Assert.Equal(expected, await response.Content.ReadAsStringAsync()); + var headerValue = Assert.Single(response.Headers.GetValues("Test")); + Assert.Equal("t", headerValue); + } } diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/SystemTextJsonOutputFormatterController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/SystemTextJsonOutputFormatterController.cs index 287ffa90fd91..dcbd10cb1171 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/SystemTextJsonOutputFormatterController.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/SystemTextJsonOutputFormatterController.cs @@ -19,6 +19,14 @@ public class SystemTextJsonOutputFormatterController : ControllerBase Address = "Some address", }; + [HttpGet] + public async IAsyncEnumerable AsyncEnumerable() + { + await Task.Yield(); + HttpContext.Response.Headers["Test"] = "t"; + yield return 1; + } + [JsonPolymorphic] [JsonDerivedType(typeof(DerivedModel), nameof(DerivedModel))] public class SimpleModel diff --git a/src/Shared/Reflection/AsyncEnumerableHelper.cs b/src/Shared/Reflection/AsyncEnumerableHelper.cs deleted file mode 100644 index c0e77afcafa4..000000000000 --- a/src/Shared/Reflection/AsyncEnumerableHelper.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNetCore.Internal; - -internal static class AsyncEnumerableHelper -{ - internal static bool IsIAsyncEnumerable(Type type) => GetIAsyncEnumerableInterface(type) is not null; - - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070:UnrecognizedReflectionPattern", - Justification = "The 'IAsyncEnumerable<>' Type must exist (due to typeof(IAsyncEnumerable<>) used below)" + - " and so the trimmer kept it. In which case " + - "It also kept it on any type which implements it. The below call to GetInterfaces " + - "may return fewer results when trimmed but it will return 'IAsyncEnumerable<>' " + - "if the type implemented it, even after trimming.")] - internal static Type? GetIAsyncEnumerableInterface(Type type) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) - { - return type; - } - - if (type.GetInterface("IAsyncEnumerable`1") is Type asyncEnumerableType) - { - return asyncEnumerableType; - } - - return null; - } -}