diff --git a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs index d2eb14ebd96b..742a38116aeb 100644 --- a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs +++ b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs @@ -875,6 +875,7 @@ public MvcOptions() { } public Microsoft.AspNetCore.Mvc.Filters.FilterCollection Filters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } public Microsoft.AspNetCore.Mvc.Formatters.FormatterMappings FormatterMappings { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } public Microsoft.AspNetCore.Mvc.Formatters.FormatterCollection InputFormatters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + public int MaxIAsyncEnumerableBufferLimit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public int MaxModelBindingCollectionSize { get { throw null; } set { } } public int MaxModelBindingRecursionDepth { get { throw null; } set { } } public int MaxModelValidationErrors { get { throw null; } set { } } @@ -2085,7 +2086,9 @@ public MvcCompatibilityOptions() { } } public partial class ObjectResultExecutor : Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultExecutor { + [System.ObsoleteAttribute("This constructor is obsolete and will be removed in a future release.")] public ObjectResultExecutor(Microsoft.AspNetCore.Mvc.Infrastructure.OutputFormatterSelector formatterSelector, Microsoft.AspNetCore.Mvc.Infrastructure.IHttpResponseStreamWriterFactory writerFactory, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } + public ObjectResultExecutor(Microsoft.AspNetCore.Mvc.Infrastructure.OutputFormatterSelector formatterSelector, Microsoft.AspNetCore.Mvc.Infrastructure.IHttpResponseStreamWriterFactory writerFactory, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.Extensions.Options.IOptions mvcOptions) { } protected Microsoft.AspNetCore.Mvc.Infrastructure.OutputFormatterSelector FormatterSelector { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } protected Microsoft.Extensions.Logging.ILogger Logger { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } protected System.Func WriterFactory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableReader.cs b/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableReader.cs new file mode 100644 index 000000000000..846e8f19d504 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Infrastructure/AsyncEnumerableReader.cs @@ -0,0 +1,102 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.Extensions.Internal; + +#if JSONNET +namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson +#else +namespace Microsoft.AspNetCore.Mvc.Infrastructure +#endif +{ + using ReaderFunc = Func, Task>; + + /// + /// Type that reads an instance into a + /// generic collection instance. + /// + /// + /// This type is used to create a strongly typed synchronous instance from + /// an . An accurate is required for XML formatters to + /// correctly serialize. + /// + internal sealed class AsyncEnumerableReader + { + private readonly MethodInfo Converter = typeof(AsyncEnumerableReader).GetMethod( + nameof(ReadInternal), + BindingFlags.NonPublic | BindingFlags.Instance); + + private readonly ConcurrentDictionary _asyncEnumerableConverters = + new ConcurrentDictionary(); + private readonly MvcOptions _mvcOptions; + + /// + /// Initializes a new instance of . + /// + /// Accessor to . + public AsyncEnumerableReader(MvcOptions mvcOptions) + { + _mvcOptions = mvcOptions; + } + + /// + /// Reads a into an . + /// + /// The to read. + /// The . + public Task ReadAsync(IAsyncEnumerable value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + var type = value.GetType(); + if (!_asyncEnumerableConverters.TryGetValue(type, out var result)) + { + var enumerableType = ClosedGenericMatcher.ExtractGenericInterface(type, typeof(IAsyncEnumerable<>)); + Debug.Assert(enumerableType != null); + + var enumeratedObjectType = enumerableType.GetGenericArguments()[0]; + + var converter = (ReaderFunc)Converter + .MakeGenericMethod(enumeratedObjectType) + .CreateDelegate(typeof(ReaderFunc), this); + + _asyncEnumerableConverters.TryAdd(type, converter); + result = converter; + } + + return result(value); + } + + private async Task ReadInternal(IAsyncEnumerable value) + { + var asyncEnumerable = (IAsyncEnumerable)value; + var result = new List(); + var count = 0; + + await foreach (var item in asyncEnumerable) + { + if (count++ >= _mvcOptions.MaxIAsyncEnumerableBufferLimit) + { + throw new InvalidOperationException(Resources.FormatObjectResultExecutor_MaxEnumerationExceeded( + nameof(AsyncEnumerableReader), + value.GetType())); + } + + result.Add(item); + } + + return result; + } + } +} diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs index 6c5179d222b4..3dd2ddc84579 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.Infrastructure { @@ -18,16 +19,35 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure /// public class ObjectResultExecutor : IActionResultExecutor { + private readonly AsyncEnumerableReader _asyncEnumerableReader; + /// /// Creates a new . /// /// The . /// The . /// The . + [Obsolete("This constructor is obsolete and will be removed in a future release.")] public ObjectResultExecutor( OutputFormatterSelector formatterSelector, IHttpResponseStreamWriterFactory writerFactory, ILoggerFactory loggerFactory) + : this(formatterSelector, writerFactory, loggerFactory, mvcOptions: null) + { + } + + /// + /// Creates a new . + /// + /// The . + /// The . + /// The . + /// Accessor to . + public ObjectResultExecutor( + OutputFormatterSelector formatterSelector, + IHttpResponseStreamWriterFactory writerFactory, + ILoggerFactory loggerFactory, + IOptions mvcOptions) { if (formatterSelector == null) { @@ -47,6 +67,8 @@ public ObjectResultExecutor( FormatterSelector = formatterSelector; WriterFactory = writerFactory.CreateWriter; Logger = loggerFactory.CreateLogger(); + var options = mvcOptions?.Value ?? throw new ArgumentNullException(nameof(mvcOptions)); + _asyncEnumerableReader = new AsyncEnumerableReader(options); } /// @@ -87,16 +109,37 @@ public virtual Task ExecuteAsync(ActionContext context, ObjectResult result) InferContentTypes(context, result); var objectType = result.DeclaredType; + if (objectType == null || objectType == typeof(object)) { objectType = result.Value?.GetType(); } + var value = result.Value; + + if (value is IAsyncEnumerable asyncEnumerable) + { + return ExecuteAsyncEnumerable(context, result, asyncEnumerable); + } + + return ExecuteAsyncCore(context, result, objectType, value); + } + + private async Task ExecuteAsyncEnumerable(ActionContext context, ObjectResult result, IAsyncEnumerable asyncEnumerable) + { + Log.BufferingAsyncEnumerable(Logger, asyncEnumerable); + + var enumerated = await _asyncEnumerableReader.ReadAsync(asyncEnumerable); + await ExecuteAsyncCore(context, result, enumerated.GetType(), enumerated); + } + + private Task ExecuteAsyncCore(ActionContext context, ObjectResult result, Type objectType, object value) + { var formatterContext = new OutputFormatterWriteContext( context.HttpContext, WriterFactory, objectType, - result.Value); + value); var selectedFormatter = FormatterSelector.SelectFormatter( formatterContext, @@ -138,5 +181,21 @@ private static void InferContentTypes(ActionContext context, ObjectResult result result.ContentTypes.Add("application/problem+xml"); } } + + private static class Log + { + private static readonly Action _bufferingAsyncEnumerable; + + static Log() + { + _bufferingAsyncEnumerable = LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "BufferingAsyncEnumerable"), + "Buffering IAsyncEnumerable instance of type '{Type}'."); + } + + public static void BufferingAsyncEnumerable(ILogger logger, IAsyncEnumerable asyncEnumerable) + => _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null); + } } } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs index 49d1b3c6537d..16da9325d241 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.IO; using System.Text; using System.Text.Json; @@ -25,13 +26,16 @@ internal sealed class SystemTextJsonResultExecutor : IActionResultExecutor _logger; + private readonly AsyncEnumerableReader _asyncEnumerableReader; public SystemTextJsonResultExecutor( IOptions options, - ILogger logger) + ILogger logger, + IOptions mvcOptions) { _options = options.Value; _logger = logger; + _asyncEnumerableReader = new AsyncEnumerableReader(mvcOptions.Value); } public async Task ExecuteAsync(ActionContext context, JsonResult result) @@ -70,8 +74,15 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result) var writeStream = GetWriteStream(context.HttpContext, resolvedContentTypeEncoding); try { - var type = result.Value?.GetType() ?? typeof(object); - await JsonSerializer.WriteAsync(writeStream, result.Value, type, jsonSerializerOptions); + var value = result.Value; + if (value is IAsyncEnumerable asyncEnumerable) + { + Log.BufferingAsyncEnumerable(_logger, asyncEnumerable); + value = await _asyncEnumerableReader.ReadAsync(asyncEnumerable); + } + + var type = value?.GetType() ?? typeof(object); + await JsonSerializer.WriteAsync(writeStream, value, type, jsonSerializerOptions); await writeStream.FlushAsync(); } finally @@ -123,11 +134,19 @@ private static class Log new EventId(1, "JsonResultExecuting"), "Executing JsonResult, writing value of type '{Type}'."); + private static readonly Action _bufferingAsyncEnumerable = LoggerMessage.Define( + LogLevel.Debug, + new EventId(2, "BufferingAsyncEnumerable"), + "Buffering IAsyncEnumerable instance of type '{Type}'."); + public static void JsonResultExecuting(ILogger logger, object value) { var type = value == null ? "null" : value.GetType().FullName; _jsonResultExecuting(logger, type, null); } + + public static void BufferingAsyncEnumerable(ILogger logger, IAsyncEnumerable asyncEnumerable) + => _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null); } } } diff --git a/src/Mvc/Mvc.Core/src/MvcOptions.cs b/src/Mvc/Mvc.Core/src/MvcOptions.cs index 2cb5f9fa7216..46cf9c63a341 100644 --- a/src/Mvc/Mvc.Core/src/MvcOptions.cs +++ b/src/Mvc/Mvc.Core/src/MvcOptions.cs @@ -359,6 +359,19 @@ public int MaxModelBindingRecursionDepth } } + /// + /// Gets or sets the most number of entries of an that + /// that will buffer. + /// + /// When is an instance of , + /// will eagerly read the enumeration and add to a synchronous collection + /// prior to invoking the selected formatter. + /// This property determines the most number of entries that the executor is allowed to buffer. + /// + /// + /// Defaults to 8192. + public int MaxIAsyncEnumerableBufferLimit { get; set; } = 8192; + IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); diff --git a/src/Mvc/Mvc.Core/src/Properties/Resources.Designer.cs b/src/Mvc/Mvc.Core/src/Properties/Resources.Designer.cs index 2878cafd4ca3..c5a777379be2 100644 --- a/src/Mvc/Mvc.Core/src/Properties/Resources.Designer.cs +++ b/src/Mvc/Mvc.Core/src/Properties/Resources.Designer.cs @@ -1746,6 +1746,20 @@ internal static string Property_MustBeInstanceOfType internal static string FormatProperty_MustBeInstanceOfType(object p0, object p1, object p2) => string.Format(CultureInfo.CurrentCulture, GetString("Property_MustBeInstanceOfType"), p0, p1, p2); + /// + /// '{0}' reached the configured maximum size of the buffer when enumerating a value of type `{1}'. This limit is in place to prevent infinite streams of `IAsyncEnumerable` from continuing indefinitely. If this is not a programming mistake, consider ways to reduce the collection size, or consider manually converting '{1}' into a list rather than increasing the limit. + /// + internal static string ObjectResultExecutor_MaxEnumerationExceeded + { + get => GetString("ObjectResultExecutor_MaxEnumerationExceeded"); + } + + /// + /// '{0}' reached the configured maximum size of the buffer when enumerating a value of type `{1}'. This limit is in place to prevent infinite streams of `IAsyncEnumerable` from continuing indefinitely. If this is not a programming mistake, consider ways to reduce the collection size, or consider manually converting '{1}' into a list rather than increasing the limit. + /// + internal static string FormatObjectResultExecutor_MaxEnumerationExceeded(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ObjectResultExecutor_MaxEnumerationExceeded"), p0, p1); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx index bbf596d558eb..43d3e79b84eb 100644 --- a/src/Mvc/Mvc.Core/src/Resources.resx +++ b/src/Mvc/Mvc.Core/src/Resources.resx @@ -503,5 +503,8 @@ Property '{0}.{1}' must be an instance of type '{2}'. - + + + '{0}' reached the configured maximum size of the buffer when enumerating a value of type '{1}'. This limit is in place to prevent infinite streams of 'IAsyncEnumerable<>' from continuing indefinitely. If this is not a programming mistake, consider ways to reduce the collection size, or consider manually converting '{1}' into a list rather than increasing the limit. + diff --git a/src/Mvc/Mvc.Core/test/AcceptedAtActionResultTests.cs b/src/Mvc/Mvc.Core/test/AcceptedAtActionResultTests.cs index 721b58ca9b58..ba2cfed7ac6a 100644 --- a/src/Mvc/Mvc.Core/test/AcceptedAtActionResultTests.cs +++ b/src/Mvc/Mvc.Core/test/AcceptedAtActionResultTests.cs @@ -275,7 +275,8 @@ private static IServiceProvider CreateServices(Mock formatter) services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/AcceptedAtRouteResultTests.cs b/src/Mvc/Mvc.Core/test/AcceptedAtRouteResultTests.cs index 220013197113..63dab76bc083 100644 --- a/src/Mvc/Mvc.Core/test/AcceptedAtRouteResultTests.cs +++ b/src/Mvc/Mvc.Core/test/AcceptedAtRouteResultTests.cs @@ -183,7 +183,8 @@ private static IServiceProvider CreateServices(Mock formatter) services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs b/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs index 07ca76072e64..da714c3f50dc 100644 --- a/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs +++ b/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs @@ -139,7 +139,8 @@ private static IServiceProvider CreateServices(Mock formatter) services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/CreatedAtActionResultTests.cs b/src/Mvc/Mvc.Core/test/CreatedAtActionResultTests.cs index cad29a30437d..be493c9eda07 100644 --- a/src/Mvc/Mvc.Core/test/CreatedAtActionResultTests.cs +++ b/src/Mvc/Mvc.Core/test/CreatedAtActionResultTests.cs @@ -97,7 +97,8 @@ private static IServiceProvider CreateServices() services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/CreatedAtRouteResultTests.cs b/src/Mvc/Mvc.Core/test/CreatedAtRouteResultTests.cs index 65410977a06c..50eb7ba12bb4 100644 --- a/src/Mvc/Mvc.Core/test/CreatedAtRouteResultTests.cs +++ b/src/Mvc/Mvc.Core/test/CreatedAtRouteResultTests.cs @@ -110,7 +110,8 @@ private static IServiceProvider CreateServices() services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/CreatedResultTests.cs b/src/Mvc/Mvc.Core/test/CreatedResultTests.cs index 6d3eefc85bde..22e6b1363762 100644 --- a/src/Mvc/Mvc.Core/test/CreatedResultTests.cs +++ b/src/Mvc/Mvc.Core/test/CreatedResultTests.cs @@ -98,7 +98,8 @@ private static IServiceProvider CreateServices() services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/HttpNotFoundObjectResultTest.cs b/src/Mvc/Mvc.Core/test/HttpNotFoundObjectResultTest.cs index 498a12c57ac4..42ef48b2f0cd 100644 --- a/src/Mvc/Mvc.Core/test/HttpNotFoundObjectResultTest.cs +++ b/src/Mvc/Mvc.Core/test/HttpNotFoundObjectResultTest.cs @@ -75,7 +75,8 @@ private static IServiceProvider CreateServices() services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/HttpOkObjectResultTest.cs b/src/Mvc/Mvc.Core/test/HttpOkObjectResultTest.cs index 13f5572c4e53..49fac14fff15 100644 --- a/src/Mvc/Mvc.Core/test/HttpOkObjectResultTest.cs +++ b/src/Mvc/Mvc.Core/test/HttpOkObjectResultTest.cs @@ -76,7 +76,8 @@ private static IServiceProvider CreateServices() services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); return services.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/AsyncEnumerableReaderTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/AsyncEnumerableReaderTest.cs new file mode 100644 index 000000000000..27baf232c948 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/Infrastructure/AsyncEnumerableReaderTest.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + public class AsyncEnumerableReaderTest + { + [Fact] + public async Task ReadAsync_ReadsIAsyncEnumerable() + { + // Arrange + var options = new MvcOptions(); + var reader = new AsyncEnumerableReader(options); + + // Act + var result = await reader.ReadAsync(TestEnumerable()); + + // Assert + var collection = Assert.IsAssignableFrom>(result); + Assert.Equal(new[] { "0", "1", "2", }, collection); + } + + [Fact] + public async Task ReadAsync_ReadsIAsyncEnumerable_ImplementingMultipleAsyncEnumerableInterfaces() + { + // This test ensures the reader does not fail if you have a type that implements IAsyncEnumerable for multiple Ts + // Arrange + var options = new MvcOptions(); + var reader = new AsyncEnumerableReader(options); + + // Act + var result = await reader.ReadAsync(new MultiAsyncEnumerable()); + + // Assert + var collection = Assert.IsAssignableFrom>(result); + Assert.Equal(new[] { "0", "1", "2", }, collection); + } + + [Fact] + public async Task ReadAsync_ThrowsIfBufferimitIsReached() + { + // Arrange + var enumerable = TestEnumerable(11); + var expected = $"'AsyncEnumerableReader' reached the configured maximum size of the buffer when enumerating a value of type '{enumerable.GetType()}'. " + + "This limit is in place to prevent infinite streams of 'IAsyncEnumerable<>' from continuing indefinitely. If this is not a programming mistake, " + + $"consider ways to reduce the collection size, or consider manually converting '{enumerable.GetType()}' into a list rather than increasing the limit."; + var options = new MvcOptions { MaxIAsyncEnumerableBufferLimit = 10 }; + var reader = new AsyncEnumerableReader(options); + + // Act + var ex = await Assert.ThrowsAsync(() => reader.ReadAsync(enumerable)); + + // Assert + Assert.Equal(expected, ex.Message); + } + + public static async IAsyncEnumerable TestEnumerable(int count = 3) + { + await Task.Yield(); + for (var i = 0; i < count; i++) + { + yield return i.ToString(); + } + } + + public class MultiAsyncEnumerable : IAsyncEnumerable, IAsyncEnumerable + { + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return TestEnumerable().GetAsyncEnumerator(cancellationToken); + } + + IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken cancellationToken) + => GetAsyncEnumerator(cancellationToken); + } + } +} diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ControllerActionInvokerTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ControllerActionInvokerTest.cs index 70ce4124e1cf..dda560bba4b8 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/ControllerActionInvokerTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ControllerActionInvokerTest.cs @@ -1567,7 +1567,8 @@ private ControllerActionInvoker CreateInvoker( services.AddSingleton>(new ObjectResultExecutor( new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); httpContext.Response.Body = new MemoryStream(); httpContext.RequestServices = services.BuildServiceProvider(); diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs b/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs index 5c025909b0e9..884f4f213e41 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -310,6 +311,24 @@ public async Task ExecuteAsync_ThrowsIfSerializerSettingIsNotTheCorrectType() Assert.StartsWith("Property 'JsonResult.SerializerSettings' must be an instance of type", ex.Message); } + [Fact] + public async Task ExecuteAsync_SerializesAsyncEnumerables() + { + // Arrange + var expected = Encoding.UTF8.GetBytes(JsonSerializer.ToString(new[] { "Hello", "world" })); + + var context = GetActionContext(); + var result = new JsonResult(TestAsyncEnumerable()); + var executor = CreateExecutor(); + + // Act + await executor.ExecuteAsync(context, result); + + // Assert + var written = GetWrittenBytes(context.HttpContext); + Assert.Equal(expected, written); + } + protected IActionResultExecutor CreateExecutor() => CreateExecutor(NullLoggerFactory.Instance); protected abstract IActionResultExecutor CreateExecutor(ILoggerFactory loggerFactory); @@ -354,5 +373,12 @@ private class TestModel { public string Property { get; set; } } + + private async IAsyncEnumerable TestAsyncEnumerable() + { + await Task.Yield(); + yield return "Hello"; + yield return "world"; + } } } diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs index 207d04161f2d..8ab2d7667848 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -209,8 +210,8 @@ public async Task ExecuteAsync_NoFormatterFound_Returns406() public async Task ExecuteAsync_FallsBackOnFormattersInOptions() { // Arrange - var options = Options.Create(new MvcOptions()); - options.Value.OutputFormatters.Add(new TestJsonOutputFormatter()); + var options = new MvcOptions(); + options.OutputFormatters.Add(new TestJsonOutputFormatter()); var executor = CreateExecutor(options: options); @@ -300,8 +301,8 @@ public async Task ExecuteAsync_SelectDefaultFormatter_OnAllMediaRangeAcceptHeade string expectedContentType) { // Arrange - var options = Options.Create(new MvcOptions()); - options.Value.RespectBrowserAcceptHeader = false; + var options = new MvcOptions(); + options.RespectBrowserAcceptHeader = false; var executor = CreateExecutor(options: options); @@ -337,8 +338,8 @@ public async Task ObjectResult_PerformsContentNegotiation_OnAllMediaRangeAcceptH string expectedContentType) { // Arrange - var options = Options.Create(new MvcOptions()); - options.Value.RespectBrowserAcceptHeader = true; + var options = new MvcOptions(); + options.RespectBrowserAcceptHeader = true; var executor = CreateExecutor(options: options); @@ -360,6 +361,106 @@ public async Task ObjectResult_PerformsContentNegotiation_OnAllMediaRangeAcceptH MediaTypeAssert.Equal(expectedContentType, responseContentType); } + [Fact] + public async Task ObjectResult_ReadsAsyncEnumerables() + { + // Arrange + var executor = CreateExecutor(); + var result = new ObjectResult(AsyncEnumerable()); + var formatter = new TestJsonOutputFormatter(); + result.Formatters.Add(formatter); + + var actionContext = new ActionContext() + { + HttpContext = GetHttpContext(), + }; + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + var formatterContext = formatter.LastOutputFormatterContext; + Assert.Equal(typeof(List), formatterContext.ObjectType); + var value = Assert.IsType>(formatterContext.Object); + Assert.Equal(new[] { "Hello 0", "Hello 1", "Hello 2", "Hello 3", }, value); + } + + [Fact] + public async Task ObjectResult_Throws_IfEnumerableThrows() + { + // Arrange + var executor = CreateExecutor(); + var result = new ObjectResult(AsyncEnumerable(throwError: true)); + var formatter = new TestJsonOutputFormatter(); + result.Formatters.Add(formatter); + + var actionContext = new ActionContext() + { + HttpContext = GetHttpContext(), + }; + + // Act & Assert + await Assert.ThrowsAsync(() => executor.ExecuteAsync(actionContext, result)); + } + + [Fact] + public async Task ObjectResult_AsyncEnumeration_AtLimit() + { + // Arrange + var count = 24; + var executor = CreateExecutor(options: new MvcOptions { MaxIAsyncEnumerableBufferLimit = count }); + var result = new ObjectResult(AsyncEnumerable(count: count)); + var formatter = new TestJsonOutputFormatter(); + result.Formatters.Add(formatter); + + var actionContext = new ActionContext() + { + HttpContext = GetHttpContext(), + }; + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + var formatterContext = formatter.LastOutputFormatterContext; + var value = Assert.IsType>(formatterContext.Object); + Assert.Equal(24, value.Count); + } + + [Theory] + [InlineData(25)] + [InlineData(1024)] + public async Task ObjectResult_Throws_IfEnumerationExceedsLimit(int count) + { + // Arrange + var executor = CreateExecutor(options: new MvcOptions { MaxIAsyncEnumerableBufferLimit = 24 }); + var result = new ObjectResult(AsyncEnumerable(count: count)); + var formatter = new TestJsonOutputFormatter(); + result.Formatters.Add(formatter); + + var actionContext = new ActionContext() + { + HttpContext = GetHttpContext(), + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => executor.ExecuteAsync(actionContext, result)); + } + + private static async IAsyncEnumerable AsyncEnumerable(int count = 4, bool throwError = false) + { + await Task.Yield(); + for (var i = 0; i < count; i++) + { + yield return $"Hello {i}"; + } + + if (throwError) + { + throw new TimeZoneNotFoundException(); + } + } + private static IServiceCollection CreateServices() { var services = new ServiceCollection(); @@ -379,10 +480,12 @@ private static HttpContext GetHttpContext() return httpContext; } - private static ObjectResultExecutor CreateExecutor(IOptions options = null) + private static ObjectResultExecutor CreateExecutor(MvcOptions options = null) { - var selector = new DefaultOutputFormatterSelector(options ?? Options.Create(new MvcOptions()), NullLoggerFactory.Instance); - return new ObjectResultExecutor(selector, new TestHttpResponseStreamWriterFactory(), NullLoggerFactory.Instance); + options ??= new MvcOptions(); + var optionsAccessor = Options.Create(options); + var selector = new DefaultOutputFormatterSelector(optionsAccessor, NullLoggerFactory.Instance); + return new ObjectResultExecutor(selector, new TestHttpResponseStreamWriterFactory(), NullLoggerFactory.Instance, optionsAccessor); } private class CannotWriteFormatter : IOutputFormatter @@ -409,8 +512,11 @@ public TestJsonOutputFormatter() SupportedEncodings.Add(Encoding.UTF8); } + public OutputFormatterWriteContext LastOutputFormatterContext { get; private set; } + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) { + LastOutputFormatterContext = context; return Task.FromResult(0); } } diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs index 80c20f7a156e..5fd36427ded9 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs @@ -11,7 +11,10 @@ public class SystemTextJsonResultExecutorTest : JsonResultExecutorTestBase { protected override IActionResultExecutor CreateExecutor(ILoggerFactory loggerFactory) { - return new SystemTextJsonResultExecutor(Options.Create(new JsonOptions()), loggerFactory.CreateLogger()); + return new SystemTextJsonResultExecutor( + Options.Create(new JsonOptions()), + loggerFactory.CreateLogger(), + Options.Create(new MvcOptions())); } protected override object GetIndentedSettings() diff --git a/src/Mvc/Mvc.Core/test/ObjectResultTests.cs b/src/Mvc/Mvc.Core/test/ObjectResultTests.cs index 7b818ad97b65..5f210f29212b 100644 --- a/src/Mvc/Mvc.Core/test/ObjectResultTests.cs +++ b/src/Mvc/Mvc.Core/test/ObjectResultTests.cs @@ -97,10 +97,12 @@ public async Task ObjectResult_ExecuteResultAsync_SetsProblemDetailsStatus() private static IServiceProvider CreateServices() { var services = new ServiceCollection(); + var options = Options.Create(new MvcOptions()); services.AddSingleton>(new ObjectResultExecutor( - new DefaultOutputFormatterSelector(Options.Create(new MvcOptions()), NullLoggerFactory.Instance), + new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance), new TestHttpResponseStreamWriterFactory(), - NullLoggerFactory.Instance)); + NullLoggerFactory.Instance, + options)); services.AddSingleton(NullLoggerFactory.Instance); return services.BuildServiceProvider(); diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj b/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj index 285e6f514278..2c81323847d0 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj +++ b/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj @@ -6,6 +6,7 @@ true aspnetcore;aspnetcoremvc;json true + $(DefineConstants);JSONNET @@ -21,5 +22,6 @@ + diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonLoggerExtensions.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonLoggerExtensions.cs index dfad01025e0d..a8be4c791787 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonLoggerExtensions.cs +++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonLoggerExtensions.cs @@ -10,7 +10,6 @@ internal static class NewtonsoftJsonLoggerExtensions { private static readonly Action _jsonInputFormatterException; - private static readonly Action _jsonResultExecuting; static NewtonsoftJsonLoggerExtensions() { @@ -18,22 +17,11 @@ static NewtonsoftJsonLoggerExtensions() LogLevel.Debug, new EventId(1, "JsonInputException"), "JSON input formatter threw an exception."); - - _jsonResultExecuting = LoggerMessage.Define( - LogLevel.Information, - new EventId(1, "JsonResultExecuting"), - "Executing JsonResult, writing value of type '{Type}'."); } public static void JsonInputException(this ILogger logger, Exception exception) { _jsonInputFormatterException(logger, exception); } - - public static void JsonResultExecuting(this ILogger logger, object value) - { - var type = value == null ? "null" : value.GetType().FullName; - _jsonResultExecuting(logger, type, null); - } } } diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs index 1dc0b26fabe2..eac7d6400a6d 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs +++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs @@ -3,10 +3,9 @@ using System; using System.Buffers; -using System.IO; +using System.Collections.Generic; using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.WebUtilities; @@ -32,6 +31,7 @@ internal class NewtonsoftJsonResultExecutor : IActionResultExecutor private readonly MvcOptions _mvcOptions; private readonly MvcNewtonsoftJsonOptions _jsonOptions; private readonly IArrayPool _charPool; + private readonly AsyncEnumerableReader _asyncEnumerableReader; /// /// Creates a new . @@ -73,6 +73,7 @@ public NewtonsoftJsonResultExecutor( _mvcOptions = mvcOptions?.Value ?? throw new ArgumentNullException(nameof(mvcOptions)); _jsonOptions = jsonOptions.Value; _charPool = new JsonArrayPool(charPool); + _asyncEnumerableReader = new AsyncEnumerableReader(_mvcOptions); } /// @@ -111,7 +112,7 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result) response.StatusCode = result.StatusCode.Value; } - _logger.JsonResultExecuting(result.Value); + Log.JsonResultExecuting(_logger, result.Value); var responseStream = response.Body; FileBufferingWriteStream fileBufferingWriteStream = null; @@ -131,7 +132,14 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result) jsonWriter.AutoCompleteOnClose = false; var jsonSerializer = JsonSerializer.Create(jsonSerializerSettings); - jsonSerializer.Serialize(jsonWriter, result.Value); + var value = result.Value; + if (result.Value is IAsyncEnumerable asyncEnumerable) + { + Log.BufferingAsyncEnumerable(_logger, asyncEnumerable); + value = await _asyncEnumerableReader.ReadAsync(asyncEnumerable); + } + + jsonSerializer.Serialize(jsonWriter, value); } if (fileBufferingWriteStream != null) @@ -168,5 +176,33 @@ private JsonSerializerSettings GetSerializerSettings(JsonResult result) return settingsFromResult; } } + + private static class Log + { + private static readonly Action _jsonResultExecuting; + private static readonly Action _bufferingAsyncEnumerable; + + static Log() + { + _jsonResultExecuting = LoggerMessage.Define( + LogLevel.Information, + new EventId(1, "JsonResultExecuting"), + "Executing JsonResult, writing value of type '{Type}'."); + + _bufferingAsyncEnumerable = LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "BufferingAsyncEnumerable"), + "Buffering IAsyncEnumerable instance of type '{Type}'."); + } + + public static void JsonResultExecuting(ILogger logger, object value) + { + var type = value == null ? "null" : value.GetType().FullName; + _jsonResultExecuting(logger, type, null); + } + + public static void BufferingAsyncEnumerable(ILogger logger, IAsyncEnumerable asyncEnumerable) + => _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null); + } } } diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/Properties/Resources.Designer.cs b/src/Mvc/Mvc.NewtonsoftJson/src/Properties/Resources.Designer.cs index b089148e7765..82aedac1ec92 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/Properties/Resources.Designer.cs +++ b/src/Mvc/Mvc.NewtonsoftJson/src/Properties/Resources.Designer.cs @@ -108,6 +108,20 @@ internal static string TempData_CannotSerializeType internal static string FormatTempData_CannotSerializeType(object p0, object p1) => string.Format(CultureInfo.CurrentCulture, GetString("TempData_CannotSerializeType"), p0, p1); + /// + /// '{0}' reached the configured maximum size of the buffer when enumerating a value of type `{1}'. This limit is in place to prevent infinite streams of `IAsyncEnumerable` from continuing indefinitely. If this is not a programming mistake, consider ways to reduce the collection size, or consider manually converting '{1}' into a list rather than increasing the limit. + /// + internal static string ObjectResultExecutor_MaxEnumerationExceeded + { + get => GetString("ObjectResultExecutor_MaxEnumerationExceeded"); + } + + /// + /// '{0}' reached the configured maximum size of the buffer when enumerating a value of type `{1}'. This limit is in place to prevent infinite streams of `IAsyncEnumerable` from continuing indefinitely. If this is not a programming mistake, consider ways to reduce the collection size, or consider manually converting '{1}' into a list rather than increasing the limit. + /// + internal static string FormatObjectResultExecutor_MaxEnumerationExceeded(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ObjectResultExecutor_MaxEnumerationExceeded"), p0, p1); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx b/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx index cedf5cbcdea0..4887c8adbceb 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx +++ b/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx @@ -1,17 +1,17 @@  - @@ -126,6 +126,9 @@ Parameter '{0}' must be an instance of {1} provided by the '{2}' package. Configure the correct instance using '{3}' in your startup. + + '{0}' reached the configured maximum size of the buffer when enumerating a value of type '{1}'. This limit is in place to prevent infinite streams of 'IAsyncEnumerable<>' from continuing indefinitely. If this is not a programming mistake, consider ways to reduce the collection size, or consider manually converting '{1}' into a list rather than increasing the limit. + Property '{0}.{1}' must be an instance of type '{2}'. @@ -138,4 +141,4 @@ The '{0}' cannot serialize an object of type '{1}'. - \ No newline at end of file + diff --git a/src/Mvc/Mvc.NewtonsoftJson/test/Microsoft.AspNetCore.Mvc.NewtonsoftJson.Test.csproj b/src/Mvc/Mvc.NewtonsoftJson/test/Microsoft.AspNetCore.Mvc.NewtonsoftJson.Test.csproj index 734fc8d42912..0cf127eac92f 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/test/Microsoft.AspNetCore.Mvc.NewtonsoftJson.Test.csproj +++ b/src/Mvc/Mvc.NewtonsoftJson/test/Microsoft.AspNetCore.Mvc.NewtonsoftJson.Test.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Mvc/test/Mvc.FunctionalTests/AsyncEnumerableTestBase.cs b/src/Mvc/test/Mvc.FunctionalTests/AsyncEnumerableTestBase.cs new file mode 100644 index 000000000000..75160338f05a --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/AsyncEnumerableTestBase.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using System.Xml.Linq; +using FormatterWebSite; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class AsyncEnumerableTestBase : IClassFixture> + { + public AsyncEnumerableTestBase(MvcTestFixture fixture) + { + Client = fixture.CreateDefaultClient(); + } + + public HttpClient Client { get; } + + [Fact] + public Task AsyncEnumerableReturnedWorks() => AsyncEnumerableWorks(); + + [Fact] + public Task AsyncEnumerableWrappedInTask() => AsyncEnumerableWorks(); + + private async Task AsyncEnumerableWorks() + { + // Act + var response = await Client.GetAsync("asyncenumerable/getallprojects"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + + // Some sanity tests to verify things serialized correctly. + var projects = JsonSerializer.Parse>(content, TestJsonSerializerOptionsProvider.Options); + Assert.Equal(10, projects.Count); + Assert.Equal("Project0", projects[0].Name); + Assert.Equal("Project9", projects[9].Name); + } + + [Fact] + public async Task AsyncEnumerableExceptionsAreThrown() + { + // Act + var response = await Client.GetAsync("asyncenumerable/GetAllProjectsWithError"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.InternalServerError); + + var content = await response.Content.ReadAsStringAsync(); + + // Verify that the exception shows up in the callstack + Assert.Contains(nameof(InvalidTimeZoneException), content); + } + + [Fact] + public async Task AsyncEnumerableWithXmlFormatterWorks() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "asyncenumerable/getallprojects"); + request.Headers.Add("Accept", "application/xml"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + + // Some sanity tests to verify things serialized correctly. + var xml = XDocument.Parse(content); + var @namespace = xml.Root.Name.NamespaceName; + var projects = xml.Root.Elements(XName.Get("Project", @namespace)); + Assert.Equal(10, projects.Count()); + + Assert.Equal("Project0", GetName(projects.ElementAt(0))); + Assert.Equal("Project9", GetName(projects.ElementAt(9))); + + string GetName(XElement element) + { + var name = element.Element(XName.Get("Name", @namespace)); + Assert.NotNull(name); + + return name.Value; + } + } + } +} diff --git a/src/Mvc/test/Mvc.FunctionalTests/InputObjectValidationTests.cs b/src/Mvc/test/Mvc.FunctionalTests/InputObjectValidationTests.cs index 78c3ce254c74..97290f727830 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/InputObjectValidationTests.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/InputObjectValidationTests.cs @@ -261,9 +261,13 @@ public async Task ValidationThrowsError_WhenValidationExceedsMaxValidationDepth( Content = new StringContent(@"{ ""Id"": ""S-1-5-21-1004336348-1177238915-682003330-512"" }", Encoding.UTF8, "application/json"), }; - // Act & Assert - var ex = await Assert.ThrowsAsync(() => Client.SendAsync(requestMessage)); - Assert.Equal(expected, ex.Message); + // Act + var response = await Client.SendAsync(requestMessage); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.InternalServerError); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(expected, content); } [Fact] @@ -356,4 +360,4 @@ public async Task ErrorsDeserializingMalformedXml_AreReportedForModelsWithoutAny }); } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Mvc.FunctionalTests/TestJsonSerializationOptionsProvider.cs b/src/Mvc/test/Mvc.FunctionalTests/TestJsonSerializationOptionsProvider.cs new file mode 100644 index 000000000000..4a418cb0f6f8 --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/TestJsonSerializationOptionsProvider.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Json; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + internal static class TestJsonSerializerOptionsProvider + { + public static JsonSerializerOptions Options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + } +} diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/AsyncEnumerableController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/AsyncEnumerableController.cs new file mode 100644 index 000000000000..864e385c7638 --- /dev/null +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/AsyncEnumerableController.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace FormatterWebSite.Controllers +{ + [ApiController] + [Route("{controller}/{action}")] + public class AsyncEnumerableController : ControllerBase + { + [HttpGet] + public IAsyncEnumerable GetAllProjects() + => GetAllProjectsCore(); + + [HttpGet] + public async Task> GetAllProjectsAsTask() + { + await Task.Yield(); + return GetAllProjectsCore(); + } + + [HttpGet] + public IAsyncEnumerable GetAllProjectsWithError() + => GetAllProjectsCore(true); + + public async IAsyncEnumerable GetAllProjectsCore(bool throwError = false) + { + await Task.Delay(5); + for (var i = 0; i < 10; i++) + { + if (throwError && i == 5) + { + throw new InvalidTimeZoneException(); + } + + yield return new Project + { + Id = i, + Name = $"Project{i}", + }; + } + } + } +} diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs b/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs index ba036d9a081e..ac3d04966f8f 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs @@ -26,6 +26,8 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app) { + app.UseDeveloperExceptionPage(); + app.UseRouting(); app.UseEndpoints(endpoints => {