Eagerly read IAsyncEnumerable{object} instances before formatting (#11118)

* Eagerly read IAsyncEnumerable{object} instances before formatting

Fixes https://github.com/aspnet/AspNetCore/issues/4833
This commit is contained in:
Pranav K 2019-06-25 10:53:43 -07:00 committed by GitHub
parent 550710f304
commit b10c8a6fc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 727 additions and 74 deletions

View File

@ -875,6 +875,7 @@ namespace Microsoft.AspNetCore.Mvc
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<Microsoft.AspNetCore.Mvc.Formatters.IInputFormatter> 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 { } }
@ -2086,7 +2087,9 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
}
public partial class ObjectResultExecutor : Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultExecutor<Microsoft.AspNetCore.Mvc.ObjectResult>
{
[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<Microsoft.AspNetCore.Mvc.MvcOptions> 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<System.IO.Stream, System.Text.Encoding, System.IO.TextWriter> WriterFactory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }

View File

@ -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<IAsyncEnumerable<object>, Task<ICollection>>;
/// <summary>
/// Type that reads an <see cref="IAsyncEnumerable{T}"/> instance into a
/// generic collection instance.
/// </summary>
/// <remarks>
/// This type is used to create a strongly typed synchronous <see cref="ICollection{T}"/> instance from
/// an <see cref="IAsyncEnumerable{T}"/>. An accurate <see cref="ICollection{T}"/> is required for XML formatters to
/// correctly serialize.
/// </remarks>
internal sealed class AsyncEnumerableReader
{
private readonly MethodInfo Converter = typeof(AsyncEnumerableReader).GetMethod(
nameof(ReadInternal),
BindingFlags.NonPublic | BindingFlags.Instance);
private readonly ConcurrentDictionary<Type, ReaderFunc> _asyncEnumerableConverters =
new ConcurrentDictionary<Type, ReaderFunc>();
private readonly MvcOptions _mvcOptions;
/// <summary>
/// Initializes a new instance of <see cref="AsyncEnumerableReader"/>.
/// </summary>
/// <param name="mvcOptions">Accessor to <see cref="MvcOptions"/>.</param>
public AsyncEnumerableReader(MvcOptions mvcOptions)
{
_mvcOptions = mvcOptions;
}
/// <summary>
/// Reads a <see cref="IAsyncEnumerable{T}"/> into an <see cref="ICollection{T}"/>.
/// </summary>
/// <param name="value">The <see cref="IAsyncEnumerable{T}"/> to read.</param>
/// <returns>The <see cref="ICollection"/>.</returns>
public Task<ICollection> ReadAsync(IAsyncEnumerable<object> 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<ICollection> ReadInternal<T>(IAsyncEnumerable<object> value)
{
var asyncEnumerable = (IAsyncEnumerable<T>)value;
var result = new List<T>();
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;
}
}
}

View File

@ -10,6 +10,7 @@ using System.Threading.Tasks;
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
/// </summary>
public class ObjectResultExecutor : IActionResultExecutor<ObjectResult>
{
private readonly AsyncEnumerableReader _asyncEnumerableReader;
/// <summary>
/// Creates a new <see cref="ObjectResultExecutor"/>.
/// </summary>
/// <param name="formatterSelector">The <see cref="OutputFormatterSelector"/>.</param>
/// <param name="writerFactory">The <see cref="IHttpResponseStreamWriterFactory"/>.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
[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)
{
}
/// <summary>
/// Creates a new <see cref="ObjectResultExecutor"/>.
/// </summary>
/// <param name="formatterSelector">The <see cref="OutputFormatterSelector"/>.</param>
/// <param name="writerFactory">The <see cref="IHttpResponseStreamWriterFactory"/>.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// <param name="mvcOptions">Accessor to <see cref="MvcOptions"/>.</param>
public ObjectResultExecutor(
OutputFormatterSelector formatterSelector,
IHttpResponseStreamWriterFactory writerFactory,
ILoggerFactory loggerFactory,
IOptions<MvcOptions> mvcOptions)
{
if (formatterSelector == null)
{
@ -47,6 +67,8 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
FormatterSelector = formatterSelector;
WriterFactory = writerFactory.CreateWriter;
Logger = loggerFactory.CreateLogger<ObjectResultExecutor>();
var options = mvcOptions?.Value ?? throw new ArgumentNullException(nameof(mvcOptions));
_asyncEnumerableReader = new AsyncEnumerableReader(options);
}
/// <summary>
@ -87,16 +109,37 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
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<object> asyncEnumerable)
{
return ExecuteAsyncEnumerable(context, result, asyncEnumerable);
}
return ExecuteAsyncCore(context, result, objectType, value);
}
private async Task ExecuteAsyncEnumerable(ActionContext context, ObjectResult result, IAsyncEnumerable<object> 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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
result.ContentTypes.Add("application/problem+xml");
}
}
private static class Log
{
private static readonly Action<ILogger, string, Exception> _bufferingAsyncEnumerable;
static Log()
{
_bufferingAsyncEnumerable = LoggerMessage.Define<string>(
LogLevel.Debug,
new EventId(1, "BufferingAsyncEnumerable"),
"Buffering IAsyncEnumerable instance of type '{Type}'.");
}
public static void BufferingAsyncEnumerable(ILogger logger, IAsyncEnumerable<object> asyncEnumerable)
=> _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null);
}
}
}

View File

@ -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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
private readonly JsonOptions _options;
private readonly ILogger<SystemTextJsonResultExecutor> _logger;
private readonly AsyncEnumerableReader _asyncEnumerableReader;
public SystemTextJsonResultExecutor(
IOptions<JsonOptions> options,
ILogger<SystemTextJsonResultExecutor> logger)
ILogger<SystemTextJsonResultExecutor> logger,
IOptions<MvcOptions> mvcOptions)
{
_options = options.Value;
_logger = logger;
_asyncEnumerableReader = new AsyncEnumerableReader(mvcOptions.Value);
}
public async Task ExecuteAsync(ActionContext context, JsonResult result)
@ -70,8 +74,15 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
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<object> 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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
new EventId(1, "JsonResultExecuting"),
"Executing JsonResult, writing value of type '{Type}'.");
private static readonly Action<ILogger, string, Exception> _bufferingAsyncEnumerable = LoggerMessage.Define<string>(
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<object> asyncEnumerable)
=> _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null);
}
}
}

View File

@ -359,6 +359,19 @@ namespace Microsoft.AspNetCore.Mvc
}
}
/// <summary>
/// Gets or sets the most number of entries of an <see cref="IAsyncEnumerable{T}"/> that
/// that <see cref="ObjectResultExecutor"/> will buffer.
/// <para>
/// When <see cref="ObjectResult.Value" /> is an instance of <see cref="IAsyncEnumerable{T}"/>,
/// <see cref="ObjectResultExecutor"/> 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.
/// </para>
/// </summary>
/// <value>Defaults to <c>8192</c>.</value>
public int MaxIAsyncEnumerableBufferLimit { get; set; } = 8192;
IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator();

View File

@ -1746,6 +1746,20 @@ namespace Microsoft.AspNetCore.Mvc.Core
internal static string FormatProperty_MustBeInstanceOfType(object p0, object p1, object p2)
=> string.Format(CultureInfo.CurrentCulture, GetString("Property_MustBeInstanceOfType"), p0, p1, p2);
/// <summary>
/// '{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.
/// </summary>
internal static string ObjectResultExecutor_MaxEnumerationExceeded
{
get => GetString("ObjectResultExecutor_MaxEnumerationExceeded");
}
/// <summary>
/// '{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.
/// </summary>
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);

View File

@ -503,5 +503,8 @@
</data>
<data name="Property_MustBeInstanceOfType" xml:space="preserve">
<value>Property '{0}.{1}' must be an instance of type '{2}'.</value>
</data>
</data>
<data name="ObjectResultExecutor_MaxEnumerationExceeded" xml:space="preserve">
<value>'{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&lt;&gt;' 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.</value>
</data>
</root>

View File

@ -275,7 +275,8 @@ namespace Microsoft.AspNetCore.Mvc
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -183,7 +183,8 @@ namespace Microsoft.AspNetCore.Mvc
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -139,7 +139,8 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -97,7 +97,8 @@ namespace Microsoft.AspNetCore.Mvc
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -110,7 +110,8 @@ namespace Microsoft.AspNetCore.Mvc
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -98,7 +98,8 @@ namespace Microsoft.AspNetCore.Mvc
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -75,7 +75,8 @@ namespace Microsoft.AspNetCore.Mvc
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -76,7 +76,8 @@ namespace Microsoft.AspNetCore.Mvc
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
return services.BuildServiceProvider();
}

View File

@ -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<ICollection<string>>(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<ICollection<object>>(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<InvalidOperationException>(() => reader.ReadAsync(enumerable));
// Assert
Assert.Equal(expected, ex.Message);
}
public static async IAsyncEnumerable<string> TestEnumerable(int count = 3)
{
await Task.Yield();
for (var i = 0; i < count; i++)
{
yield return i.ToString();
}
}
public class MultiAsyncEnumerable : IAsyncEnumerable<object>, IAsyncEnumerable<string>
{
public IAsyncEnumerator<string> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
return TestEnumerable().GetAsyncEnumerator(cancellationToken);
}
IAsyncEnumerator<object> IAsyncEnumerable<object>.GetAsyncEnumerator(CancellationToken cancellationToken)
=> GetAsyncEnumerator(cancellationToken);
}
}
}

View File

@ -1567,7 +1567,8 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
httpContext.Response.Body = new MemoryStream();
httpContext.RequestServices = services.BuildServiceProvider();

View File

@ -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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
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<JsonResult> CreateExecutor() => CreateExecutor(NullLoggerFactory.Instance);
protected abstract IActionResultExecutor<JsonResult> CreateExecutor(ILoggerFactory loggerFactory);
@ -354,5 +373,12 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
public string Property { get; set; }
}
private async IAsyncEnumerable<string> TestAsyncEnumerable()
{
await Task.Yield();
yield return "Hello";
yield return "world";
}
}
}

View File

@ -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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
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<string>), formatterContext.ObjectType);
var value = Assert.IsType<List<string>>(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<TimeZoneNotFoundException>(() => 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<List<string>>(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<InvalidOperationException>(() => executor.ExecuteAsync(actionContext, result));
}
private static async IAsyncEnumerable<string> 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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
return httpContext;
}
private static ObjectResultExecutor CreateExecutor(IOptions<MvcOptions> options = null)
private static ObjectResultExecutor CreateExecutor(MvcOptions options = null)
{
var selector = new DefaultOutputFormatterSelector(options ?? Options.Create<MvcOptions>(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 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
SupportedEncodings.Add(Encoding.UTF8);
}
public OutputFormatterWriteContext LastOutputFormatterContext { get; private set; }
public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
LastOutputFormatterContext = context;
return Task.FromResult(0);
}
}

View File

@ -11,7 +11,10 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
protected override IActionResultExecutor<JsonResult> CreateExecutor(ILoggerFactory loggerFactory)
{
return new SystemTextJsonResultExecutor(Options.Create(new JsonOptions()), loggerFactory.CreateLogger<SystemTextJsonResultExecutor>());
return new SystemTextJsonResultExecutor(
Options.Create(new JsonOptions()),
loggerFactory.CreateLogger<SystemTextJsonResultExecutor>(),
Options.Create(new MvcOptions()));
}
protected override object GetIndentedSettings()

View File

@ -97,10 +97,12 @@ namespace Microsoft.AspNetCore.Mvc
private static IServiceProvider CreateServices()
{
var services = new ServiceCollection();
var options = Options.Create(new MvcOptions());
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
new DefaultOutputFormatterSelector(Options.Create(new MvcOptions()), NullLoggerFactory.Instance),
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
new TestHttpResponseStreamWriterFactory(),
NullLoggerFactory.Instance));
NullLoggerFactory.Instance,
options));
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
return services.BuildServiceProvider();

View File

@ -6,6 +6,7 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageTags>aspnetcore;aspnetcoremvc;json</PackageTags>
<IsShippingPackage>true</IsShippingPackage>
<DefineConstants>$(DefineConstants);JSONNET</DefineConstants>
</PropertyGroup>
<ItemGroup>
@ -21,5 +22,6 @@
<ItemGroup>
<Compile Include="..\..\Mvc.Core\src\Formatters\ResponseContentTypeHelper.cs" />
<Compile Include="..\..\Mvc.Core\src\Infrastructure\AsyncEnumerableReader.cs" />
</ItemGroup>
</Project>

View File

@ -10,7 +10,6 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
{
private static readonly Action<ILogger, Exception> _jsonInputFormatterException;
private static readonly Action<ILogger, string, Exception> _jsonResultExecuting;
static NewtonsoftJsonLoggerExtensions()
{
@ -18,22 +17,11 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
LogLevel.Debug,
new EventId(1, "JsonInputException"),
"JSON input formatter threw an exception.");
_jsonResultExecuting = LoggerMessage.Define<string>(
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);
}
}
}

View File

@ -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 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
private readonly MvcOptions _mvcOptions;
private readonly MvcNewtonsoftJsonOptions _jsonOptions;
private readonly IArrayPool<char> _charPool;
private readonly AsyncEnumerableReader _asyncEnumerableReader;
/// <summary>
/// Creates a new <see cref="NewtonsoftJsonResultExecutor"/>.
@ -73,6 +73,7 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
_mvcOptions = mvcOptions?.Value ?? throw new ArgumentNullException(nameof(mvcOptions));
_jsonOptions = jsonOptions.Value;
_charPool = new JsonArrayPool<char>(charPool);
_asyncEnumerableReader = new AsyncEnumerableReader(_mvcOptions);
}
/// <summary>
@ -111,7 +112,7 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
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 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
jsonWriter.AutoCompleteOnClose = false;
var jsonSerializer = JsonSerializer.Create(jsonSerializerSettings);
jsonSerializer.Serialize(jsonWriter, result.Value);
var value = result.Value;
if (result.Value is IAsyncEnumerable<object> asyncEnumerable)
{
Log.BufferingAsyncEnumerable(_logger, asyncEnumerable);
value = await _asyncEnumerableReader.ReadAsync(asyncEnumerable);
}
jsonSerializer.Serialize(jsonWriter, value);
}
if (fileBufferingWriteStream != null)
@ -168,5 +176,33 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
return settingsFromResult;
}
}
private static class Log
{
private static readonly Action<ILogger, string, Exception> _jsonResultExecuting;
private static readonly Action<ILogger, string, Exception> _bufferingAsyncEnumerable;
static Log()
{
_jsonResultExecuting = LoggerMessage.Define<string>(
LogLevel.Information,
new EventId(1, "JsonResultExecuting"),
"Executing JsonResult, writing value of type '{Type}'.");
_bufferingAsyncEnumerable = LoggerMessage.Define<string>(
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<object> asyncEnumerable)
=> _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null);
}
}
}

View File

@ -108,6 +108,20 @@ namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
internal static string FormatTempData_CannotSerializeType(object p0, object p1)
=> string.Format(CultureInfo.CurrentCulture, GetString("TempData_CannotSerializeType"), p0, p1);
/// <summary>
/// '{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.
/// </summary>
internal static string ObjectResultExecutor_MaxEnumerationExceeded
{
get => GetString("ObjectResultExecutor_MaxEnumerationExceeded");
}
/// <summary>
/// '{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.
/// </summary>
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);

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@ -126,6 +126,9 @@
<data name="JsonHelperMustBeAnInstanceOfNewtonsoftJson" xml:space="preserve">
<value>Parameter '{0}' must be an instance of {1} provided by the '{2}' package. Configure the correct instance using '{3}' in your startup.</value>
</data>
<data name="ObjectResultExecutor_MaxEnumerationExceeded" xml:space="preserve">
<value>'{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&lt;&gt;' 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.</value>
</data>
<data name="Property_MustBeInstanceOfType" xml:space="preserve">
<value>Property '{0}.{1}' must be an instance of type '{2}'.</value>
</data>
@ -138,4 +141,4 @@
<data name="TempData_CannotSerializeType" xml:space="preserve">
<value>The '{0}' cannot serialize an object of type '{1}'.</value>
</data>
</root>
</root>

View File

@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\Mvc.Core\test\Infrastructure\AsyncEnumerableReaderTest.cs" />
<Compile Include="..\..\Mvc.Core\test\Formatters\JsonInputFormatterTestBase.cs" />
<Compile Include="..\..\Mvc.Core\test\Formatters\JsonOutputFormatterTestBase.cs" />
<Compile Include="..\..\Mvc.Core\test\Infrastructure\JsonResultExecutorTestBase.cs" />

View File

@ -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<MvcTestFixture<StartupWithJsonFormatter>>
{
public AsyncEnumerableTestBase(MvcTestFixture<StartupWithJsonFormatter> 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<List<Project>>(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;
}
}
}
}

View File

@ -261,9 +261,13 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Content = new StringContent(@"{ ""Id"": ""S-1-5-21-1004336348-1177238915-682003330-512"" }", Encoding.UTF8, "application/json"),
};
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
});
}
}
}
}

View File

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

View File

@ -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<Project> GetAllProjects()
=> GetAllProjectsCore();
[HttpGet]
public async Task<IAsyncEnumerable<Project>> GetAllProjectsAsTask()
{
await Task.Yield();
return GetAllProjectsCore();
}
[HttpGet]
public IAsyncEnumerable<Project> GetAllProjectsWithError()
=> GetAllProjectsCore(true);
public async IAsyncEnumerable<Project> 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}",
};
}
}
}
}

View File

@ -26,6 +26,8 @@ namespace FormatterWebSite
public void Configure(IApplicationBuilder app)
{
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseEndpoints(endpoints =>
{