From af91b58bd3ba3dddc0829f521095dc2d0b7e1247 Mon Sep 17 00:00:00 2001 From: Kiran Challa Date: Wed, 31 May 2017 11:21:33 -0700 Subject: [PATCH] Enabled a way to buffer request body in formatters --- .../MvcOptions.cs | 5 + .../Internal/MvcJsonMvcOptionsSetup.cs | 6 +- .../JsonInputFormatter.cs | 53 ++++++++- .../JsonPatchInputFormatter.cs | 23 +++- ...mlDataContractSerializerMvcOptionsSetup.cs | 2 +- .../MvcXmlSerializerMvcOptionsSetup.cs | 2 +- ...XmlDataContractSerializerInputFormatter.cs | 34 +++++- .../XmlSerializerInputFormatter.cs | 32 +++++- .../JsonInputFormatterTest.cs | 105 ++++++++++++++++- .../JsonPatchInputFormatterTest.cs | 98 ++++++++++++++++ ...ataContractSerializerInputFormatterTest.cs | 93 +++++++++++++++ .../XmlSerializerInputFormatterTest.cs | 107 +++++++++++++++++- .../NonSeekableReadableStream.cs | 75 ++++++++++++ 13 files changed, 618 insertions(+), 17 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Mvc.TestCommon/NonSeekableReadableStream.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs b/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs index 8555a18945..55dc6c526c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs @@ -74,6 +74,11 @@ namespace Microsoft.AspNetCore.Mvc /// public FormatterCollection InputFormatters { get; } + /// + /// Gets or sets the flag to buffer the request body in input formatters. Default is false. + /// + public bool SuppressInputFormatterBuffering { get; set; } = false; + /// /// Gets or sets the maximum number of validation errors that are allowed by this application before further /// errors are ignored. diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs index d588f9a79d..31f20bd107 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs @@ -67,14 +67,16 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal jsonInputPatchLogger, _jsonSerializerSettings, _charPool, - _objectPoolProvider)); + _objectPoolProvider, + options.SuppressInputFormatterBuffering)); var jsonInputLogger = _loggerFactory.CreateLogger(); options.InputFormatters.Add(new JsonInputFormatter( jsonInputLogger, _jsonSerializerSettings, _charPool, - _objectPoolProvider)); + _objectPoolProvider, + options.SuppressInputFormatterBuffering)); options.FormatterMappings.SetMediaTypeMappingForFormat("json", MediaTypeHeaderValue.Parse("application/json")); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs index 9428a7377e..ef65cc84e4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs @@ -3,13 +3,18 @@ using System; using System.Buffers; +using System.Diagnostics; +using System.IO; using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc.Formatters.Json.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; using Microsoft.Extensions.ObjectPool; +using Microsoft.AspNetCore.WebUtilities; using Newtonsoft.Json; +using System.Threading; namespace Microsoft.AspNetCore.Mvc.Formatters { @@ -21,6 +26,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters private readonly IArrayPool _charPool; private readonly ILogger _logger; private readonly ObjectPoolProvider _objectPoolProvider; + private readonly bool _suppressInputFormatterBuffering; + private ObjectPool _jsonSerializerPool; /// @@ -38,7 +45,30 @@ namespace Microsoft.AspNetCore.Mvc.Formatters ILogger logger, JsonSerializerSettings serializerSettings, ArrayPool charPool, - ObjectPoolProvider objectPoolProvider) + ObjectPoolProvider objectPoolProvider) : + this(logger, serializerSettings, charPool, objectPoolProvider, suppressInputFormatterBuffering: false) + { + // This constructor by default buffers the request body as its the most secure setting + } + + /// + /// Initializes a new instance of . + /// + /// The . + /// + /// The . Should be either the application-wide settings + /// () or an instance + /// initially returned. + /// + /// The . + /// The . + /// Flag to buffer entire request body before deserializing it. + public JsonInputFormatter( + ILogger logger, + JsonSerializerSettings serializerSettings, + ArrayPool charPool, + ObjectPoolProvider objectPoolProvider, + bool suppressInputFormatterBuffering) { if (logger == null) { @@ -64,6 +94,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SerializerSettings = serializerSettings; _charPool = new JsonArrayPool(charPool); _objectPoolProvider = objectPoolProvider; + _suppressInputFormatterBuffering = suppressInputFormatterBuffering; SupportedEncodings.Add(UTF8EncodingWithoutBOM); SupportedEncodings.Add(UTF16EncodingLittleEndian); @@ -83,7 +114,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters protected JsonSerializerSettings SerializerSettings { get; } /// - public override Task ReadRequestBodyAsync( + public override async Task ReadRequestBodyAsync( InputFormatterContext context, Encoding encoding) { @@ -98,6 +129,18 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } var request = context.HttpContext.Request; + + if (!request.Body.CanSeek && !_suppressInputFormatterBuffering) + { + // JSON.Net does synchronous reads. In order to avoid blocking on the stream, we asynchronously + // read everything into a buffer, and then seek back to the beginning. + BufferingHelper.EnableRewind(request); + Debug.Assert(request.Body.CanSeek); + + await request.Body.DrainAsync(CancellationToken.None); + request.Body.Seek(0L, SeekOrigin.Begin); + } + using (var streamReader = context.ReaderFactory(request.Body, encoding)) { using (var jsonReader = new JsonTextReader(streamReader)) @@ -164,15 +207,15 @@ namespace Microsoft.AspNetCore.Mvc.Formatters // or the JSON-encoded value "null". The upstream BodyModelBinder needs to // be notified that we don't regard this as a real input so it can register // a model binding error. - return InputFormatterResult.NoValueAsync(); + return InputFormatterResult.NoValue(); } else { - return InputFormatterResult.SuccessAsync(model); + return InputFormatterResult.Success(model); } } - return InputFormatterResult.FailureAsync(); + return InputFormatterResult.Failure(); } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonPatchInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonPatchInputFormatter.cs index e037e0d4ab..81fc385480 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonPatchInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonPatchInputFormatter.cs @@ -34,7 +34,28 @@ namespace Microsoft.AspNetCore.Mvc.Formatters JsonSerializerSettings serializerSettings, ArrayPool charPool, ObjectPoolProvider objectPoolProvider) - : base(logger, serializerSettings, charPool, objectPoolProvider) + : this(logger, serializerSettings, charPool, objectPoolProvider, suppressInputFormatterBuffering: false) + { + } + + /// + /// Initializes a new instance. + /// + /// The . + /// + /// The . Should be either the application-wide settings + /// () or an instance + /// initially returned. + /// /// The . + /// The . + /// Flag to buffer entire request body before deserializing it. + public JsonPatchInputFormatter( + ILogger logger, + JsonSerializerSettings serializerSettings, + ArrayPool charPool, + ObjectPoolProvider objectPoolProvider, + bool suppressInputFormatterBuffering) + : base(logger, serializerSettings, charPool, objectPoolProvider, suppressInputFormatterBuffering) { // Clear all values and only include json-patch+json value. SupportedMediaTypes.Clear(); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs index c09aa07685..d605a025b8 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs @@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal options.ModelMetadataDetailsProviders.Add(new DataMemberRequiredBindingMetadataProvider()); options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter()); - options.InputFormatters.Add(new XmlDataContractSerializerInputFormatter()); + options.InputFormatters.Add(new XmlDataContractSerializerInputFormatter(options.SuppressInputFormatterBuffering)); options.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider("System.Xml.Linq.XObject")); options.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider("System.Xml.XmlNode")); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs index 0d5b184d80..1944408bbd 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs @@ -18,7 +18,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal public void Configure(MvcOptions options) { options.OutputFormatters.Add(new XmlSerializerOutputFormatter()); - options.InputFormatters.Add(new XmlSerializerInputFormatter()); + options.InputFormatters.Add(new XmlSerializerInputFormatter(options.SuppressInputFormatterBuffering)); } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs index 6761a12ebc..cfcfe8f579 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs @@ -4,14 +4,18 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Runtime.Serialization; using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Xml; +using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc.Formatters.Xml; using Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal; using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.WebUtilities; namespace Microsoft.AspNetCore.Mvc.Formatters { @@ -24,12 +28,24 @@ namespace Microsoft.AspNetCore.Mvc.Formatters private DataContractSerializerSettings _serializerSettings; private ConcurrentDictionary _serializerCache = new ConcurrentDictionary(); private readonly XmlDictionaryReaderQuotas _readerQuotas = FormattingUtilities.GetDefaultXmlReaderQuotas(); + private readonly bool _suppressInputFormatterBuffering; /// /// Initializes a new instance of DataContractSerializerInputFormatter /// - public XmlDataContractSerializerInputFormatter() + public XmlDataContractSerializerInputFormatter() : + this(suppressInputFormatterBuffering: false) { + } + + /// + /// Initializes a new instance of DataContractSerializerInputFormatter + /// + /// Flag to buffer entire request body before deserializing it. + public XmlDataContractSerializerInputFormatter(bool suppressInputFormatterBuffering) + { + _suppressInputFormatterBuffering = suppressInputFormatterBuffering; + SupportedEncodings.Add(UTF8EncodingWithoutBOM); SupportedEncodings.Add(UTF16EncodingLittleEndian); @@ -86,7 +102,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } /// - public override Task ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding) + public override async Task ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding) { if (context == null) { @@ -99,6 +115,18 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } var request = context.HttpContext.Request; + + if (!request.Body.CanSeek && !_suppressInputFormatterBuffering) + { + // XmlDataContractSerializer does synchronous reads. In order to avoid blocking on the stream, we asynchronously + // read everything into a buffer, and then seek back to the beginning. + BufferingHelper.EnableRewind(request); + Debug.Assert(request.Body.CanSeek); + + await request.Body.DrainAsync(CancellationToken.None); + request.Body.Seek(0L, SeekOrigin.Begin); + } + using (var xmlReader = CreateXmlReader(new NonDisposableStream(request.Body), encoding)) { var type = GetSerializableType(context.ModelType); @@ -116,7 +144,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } } - return InputFormatterResult.SuccessAsync(deserializedObject); + return InputFormatterResult.Success(deserializedObject); } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs index c8b7934724..b946682c4a 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs @@ -4,14 +4,18 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; +using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc.Formatters.Xml; using Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal; using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.WebUtilities; namespace Microsoft.AspNetCore.Mvc.Formatters { @@ -23,12 +27,24 @@ namespace Microsoft.AspNetCore.Mvc.Formatters { private ConcurrentDictionary _serializerCache = new ConcurrentDictionary(); private readonly XmlDictionaryReaderQuotas _readerQuotas = FormattingUtilities.GetDefaultXmlReaderQuotas(); + private readonly bool _suppressInputFormatterBuffering; /// /// Initializes a new instance of XmlSerializerInputFormatter. /// public XmlSerializerInputFormatter() + : this(suppressInputFormatterBuffering: false) { + } + + /// + /// Initializes a new instance of XmlSerializerInputFormatter. + /// + /// Flag to buffer entire request body before deserializing it. + public XmlSerializerInputFormatter(bool suppressInputFormatterBuffering) + { + _suppressInputFormatterBuffering = suppressInputFormatterBuffering; + SupportedEncodings.Add(UTF8EncodingWithoutBOM); SupportedEncodings.Add(UTF16EncodingLittleEndian); @@ -65,7 +81,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } /// - public override Task ReadRequestBodyAsync( + public override async Task ReadRequestBodyAsync( InputFormatterContext context, Encoding encoding) { @@ -80,6 +96,18 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } var request = context.HttpContext.Request; + + if (!request.Body.CanSeek && !_suppressInputFormatterBuffering) + { + // XmlSerializer does synchronous reads. In order to avoid blocking on the stream, we asynchronously + // read everything into a buffer, and then seek back to the beginning. + BufferingHelper.EnableRewind(request); + Debug.Assert(request.Body.CanSeek); + + await request.Body.DrainAsync(CancellationToken.None); + request.Body.Seek(0L, SeekOrigin.Begin); + } + using (var xmlReader = CreateXmlReader(new NonDisposableStream(request.Body), encoding)) { var type = GetSerializableType(context.ModelType); @@ -98,7 +126,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } } - return InputFormatterResult.SuccessAsync(deserializedObject); + return InputFormatterResult.Success(deserializedObject); } } diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs index ca35c0f4e4..59ab7b9b9d 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs @@ -9,7 +9,9 @@ using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.ObjectPool; @@ -25,6 +27,92 @@ namespace Microsoft.AspNetCore.Mvc.Formatters private static readonly ObjectPoolProvider _objectPoolProvider = new DefaultObjectPoolProvider(); private static readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings(); + [Fact] + public async Task BuffersRequestBody_ByDefault() + { + // Arrange + var content = "{name: 'Person Name', Age: '30'}"; + var logger = GetLogger(); + var formatter = + new JsonInputFormatter(logger, _serializerSettings, ArrayPool.Shared, _objectPoolProvider); + var contentBytes = Encoding.UTF8.GetBytes(content); + + var modelState = new ModelStateDictionary(); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/json"; + var provider = new EmptyModelMetadataProvider(); + var metadata = provider.GetMetadataForType(typeof(User)); + var context = new InputFormatterContext( + httpContext, + modelName: string.Empty, + modelState: modelState, + metadata: metadata, + readerFactory: new TestHttpRequestStreamReaderFactory().CreateReader); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + var userModel = Assert.IsType(result.Model); + Assert.Equal("Person Name", userModel.Name); + Assert.Equal(30, userModel.Age); + + Assert.True(httpContext.Request.Body.CanSeek); + httpContext.Request.Body.Seek(0L, SeekOrigin.Begin); + + result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + userModel = Assert.IsType(result.Model); + Assert.Equal("Person Name", userModel.Name); + Assert.Equal(30, userModel.Age); + } + + [Fact] + public async Task SuppressInputFormatterBufferingSetToTrue_DoesNotBufferRequestBody() + { + // Arrange + var content = "{name: 'Person Name', Age: '30'}"; + var logger = GetLogger(); + var formatter = + new JsonInputFormatter(logger, _serializerSettings, ArrayPool.Shared, _objectPoolProvider, suppressInputFormatterBuffering: true); + var contentBytes = Encoding.UTF8.GetBytes(content); + + var modelState = new ModelStateDictionary(); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/json"; + var provider = new EmptyModelMetadataProvider(); + var metadata = provider.GetMetadataForType(typeof(User)); + var context = new InputFormatterContext( + httpContext, + modelName: string.Empty, + modelState: modelState, + metadata: metadata, + readerFactory: new TestHttpRequestStreamReaderFactory().CreateReader); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + var userModel = Assert.IsType(result.Model); + Assert.Equal("Person Name", userModel.Name); + Assert.Equal(30, userModel.Age); + + // Reading again should not fail as the request body should have been buffered by the formatter + result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + Assert.Null(result.Model); + } + [Theory] [InlineData("application/json", true)] [InlineData("application/*", false)] @@ -465,11 +553,18 @@ namespace Microsoft.AspNetCore.Mvc.Formatters private static HttpContext GetHttpContext( byte[] contentBytes, string contentType = "application/json") + { + return GetHttpContext(new MemoryStream(contentBytes), contentType); + } + + private static HttpContext GetHttpContext( + Stream requestStream, + string contentType = "application/json") { var request = new Mock(); var headers = new Mock(); request.SetupGet(r => r.Headers).Returns(headers.Object); - request.SetupGet(f => f.Body).Returns(new MemoryStream(contentBytes)); + request.SetupGet(f => f.Body).Returns(requestStream); request.SetupGet(f => f.ContentType).Returns(contentType); var httpContext = new Mock(); @@ -531,5 +626,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters public string Name { get; set; } } + + private class TestResponseFeature : HttpResponseFeature + { + public override void OnCompleted(Func callback, object state) + { + // do not do anything + } + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs index ee9325e1de..cf0fece88d 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs @@ -7,8 +7,10 @@ using System.IO; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.ObjectPool; @@ -23,6 +25,94 @@ namespace Microsoft.AspNetCore.Mvc.Formatters private static readonly ObjectPoolProvider _objectPoolProvider = new DefaultObjectPoolProvider(); private static readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings(); + [Fact] + public async Task BuffersRequestBody_ByDefault() + { + // Arrange + var logger = GetLogger(); + var formatter = + new JsonPatchInputFormatter(logger, _serializerSettings, ArrayPool.Shared, _objectPoolProvider); + var content = "[{\"op\":\"add\",\"path\":\"Customer/Name\",\"value\":\"John\"}]"; + var contentBytes = Encoding.UTF8.GetBytes(content); + + var modelState = new ModelStateDictionary(); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/json"; + var provider = new EmptyModelMetadataProvider(); + var metadata = provider.GetMetadataForType(typeof(JsonPatchDocument)); + var context = new InputFormatterContext( + httpContext, + modelName: string.Empty, + modelState: modelState, + metadata: metadata, + readerFactory: new TestHttpRequestStreamReaderFactory().CreateReader); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + var patchDoc = Assert.IsType>(result.Model); + Assert.Equal("add", patchDoc.Operations[0].op); + Assert.Equal("Customer/Name", patchDoc.Operations[0].path); + Assert.Equal("John", patchDoc.Operations[0].value); + + Assert.True(httpContext.Request.Body.CanSeek); + httpContext.Request.Body.Seek(0L, SeekOrigin.Begin); + + result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + patchDoc = Assert.IsType>(result.Model); + Assert.Equal("add", patchDoc.Operations[0].op); + Assert.Equal("Customer/Name", patchDoc.Operations[0].path); + Assert.Equal("John", patchDoc.Operations[0].value); + } + + [Fact] + public async Task SuppressInputFormatterBufferingSetToTrue_DoesNotBufferRequestBody() + { + // Arrange + var logger = GetLogger(); + var formatter = + new JsonPatchInputFormatter(logger, _serializerSettings, ArrayPool.Shared, _objectPoolProvider, suppressInputFormatterBuffering: true); + var content = "[{\"op\":\"add\",\"path\":\"Customer/Name\",\"value\":\"John\"}]"; + var contentBytes = Encoding.UTF8.GetBytes(content); + + var modelState = new ModelStateDictionary(); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/json"; + var provider = new EmptyModelMetadataProvider(); + var metadata = provider.GetMetadataForType(typeof(JsonPatchDocument)); + var context = new InputFormatterContext( + httpContext, + modelName: string.Empty, + modelState: modelState, + metadata: metadata, + readerFactory: new TestHttpRequestStreamReaderFactory().CreateReader); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + var patchDoc = Assert.IsType>(result.Model); + Assert.Equal("add", patchDoc.Operations[0].op); + Assert.Equal("Customer/Name", patchDoc.Operations[0].path); + Assert.Equal("John", patchDoc.Operations[0].value); + + result = await formatter.ReadAsync(context); + + // Assert + Assert.False(result.HasError); + Assert.Null(result.Model); + } + [Fact] public async Task JsonPatchInputFormatter_ReadsOneOperation_Successfully() { @@ -209,5 +299,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters { public string Name { get; set; } } + + private class TestResponseFeature : HttpResponseFeature + { + public override void OnCompleted(Func callback, object state) + { + // do not do anything + } + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs index 9c99d85692..7e88c163cb 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs @@ -9,7 +9,9 @@ using System.Text; using System.Threading.Tasks; using System.Xml; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.TestCommon; using Moq; using Xunit; @@ -134,6 +136,84 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml Assert.True(formatter.SupportedEncodings.Any(i => i.WebName == "utf-16")); } + [Fact] + public async Task BuffersRequestBody_ByDefault() + { + // Arrange + var expectedInt = 10; + var expectedString = "TestString"; + + var input = "" + + "" + expectedInt + "" + + "" + expectedString + ""; + + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encoding.UTF8.GetBytes(input); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/json"; + var context = GetInputFormatterContext(httpContext, typeof(TestLevelOne)); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.NotNull(result); + Assert.False(result.HasError); + var model = Assert.IsType(result.Model); + + Assert.Equal(expectedInt, model.SampleInt); + Assert.Equal(expectedString, model.sampleString); + + Assert.True(httpContext.Request.Body.CanSeek); + httpContext.Request.Body.Seek(0L, SeekOrigin.Begin); + + result = await formatter.ReadAsync(context); + + // Assert + Assert.NotNull(result); + Assert.False(result.HasError); + model = Assert.IsType(result.Model); + + Assert.Equal(expectedInt, model.SampleInt); + Assert.Equal(expectedString, model.sampleString); + } + + [Fact] + public async Task SuppressInputFormatterBufferingSetToTrue_DoesNotBufferRequestBody() + { + // Arrange + var expectedInt = 10; + var expectedString = "TestString"; + + var input = "" + + "" + expectedInt + "" + + "" + expectedString + ""; + + var formatter = new XmlDataContractSerializerInputFormatter(suppressInputFormatterBuffering: true); + var contentBytes = Encoding.UTF8.GetBytes(input); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/xml"; + var context = GetInputFormatterContext(httpContext, typeof(TestLevelOne)); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.NotNull(result); + Assert.False(result.HasError); + var model = Assert.IsType(result.Model); + + Assert.Equal(expectedInt, model.SampleInt); + Assert.Equal(expectedString, model.sampleString); + + // Reading again should fail as buffering request body is disabled + await Assert.ThrowsAsync(() => formatter.ReadAsync(context)); + } + [Fact] public async Task ReadAsync_ReadsSimpleTypes() { @@ -519,6 +599,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml private InputFormatterContext GetInputFormatterContext(byte[] contentBytes, Type modelType) { var httpContext = GetHttpContext(contentBytes); + return GetInputFormatterContext(httpContext, modelType); + } + + private InputFormatterContext GetInputFormatterContext(HttpContext httpContext, Type modelType) + { var provider = new EmptyModelMetadataProvider(); var metadata = provider.GetMetadataForType(modelType); return new InputFormatterContext( @@ -555,5 +640,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml return base.CreateSerializer(type); } } + + private class TestResponseFeature : HttpResponseFeature + { + public override void OnCompleted(Func callback, object state) + { + // do not do anything + } + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs index d72377666a..4604443f16 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs @@ -10,8 +10,9 @@ using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Testing; using Microsoft.AspNetCore.Testing.xunit; using Moq; @@ -39,6 +40,97 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml public TestLevelOne TestOne { get; set; } } + [Fact] + public async Task BuffersRequestBody_ByDefault() + { + // Arrange + var expectedInt = 10; + var expectedString = "TestString"; + var expectedDateTime = XmlConvert.ToString(DateTime.UtcNow, XmlDateTimeSerializationMode.Utc); + + var input = "" + + "" + expectedInt + "" + + "" + expectedString + "" + + "" + expectedDateTime + ""; + + var formatter = new XmlSerializerInputFormatter(); + var contentBytes = Encoding.UTF8.GetBytes(input); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/json"; + var context = GetInputFormatterContext(httpContext, typeof(TestLevelOne)); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.NotNull(result); + Assert.False(result.HasError); + var model = Assert.IsType(result.Model); + + Assert.Equal(expectedInt, model.SampleInt); + Assert.Equal(expectedString, model.sampleString); + Assert.Equal( + XmlConvert.ToDateTime(expectedDateTime, XmlDateTimeSerializationMode.Utc), + model.SampleDate); + + Assert.True(httpContext.Request.Body.CanSeek); + httpContext.Request.Body.Seek(0L, SeekOrigin.Begin); + + result = await formatter.ReadAsync(context); + + // Assert + Assert.NotNull(result); + Assert.False(result.HasError); + model = Assert.IsType(result.Model); + + Assert.Equal(expectedInt, model.SampleInt); + Assert.Equal(expectedString, model.sampleString); + Assert.Equal( + XmlConvert.ToDateTime(expectedDateTime, XmlDateTimeSerializationMode.Utc), + model.SampleDate); + } + + [Fact] + public async Task SuppressInputFormatterBufferingSetToTrue_DoesNotBufferRequestBody() + { + // Arrange + var expectedInt = 10; + var expectedString = "TestString"; + var expectedDateTime = XmlConvert.ToString(DateTime.UtcNow, XmlDateTimeSerializationMode.Utc); + + var input = "" + + "" + expectedInt + "" + + "" + expectedString + "" + + "" + expectedDateTime + ""; + + var formatter = new XmlSerializerInputFormatter(suppressInputFormatterBuffering: true); + var contentBytes = Encoding.UTF8.GetBytes(input); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestResponseFeature()); + httpContext.Request.Body = new NonSeekableReadStream(contentBytes); + httpContext.Request.ContentType = "application/xml"; + var context = GetInputFormatterContext(httpContext, typeof(TestLevelOne)); + + // Act + var result = await formatter.ReadAsync(context); + + // Assert + Assert.NotNull(result); + Assert.False(result.HasError); + var model = Assert.IsType(result.Model); + + Assert.Equal(expectedInt, model.SampleInt); + Assert.Equal(expectedString, model.sampleString); + Assert.Equal( + XmlConvert.ToDateTime(expectedDateTime, XmlDateTimeSerializationMode.Utc), + model.SampleDate); + + // Reading again should fail as buffering request body is disabled + await Assert.ThrowsAsync(() => formatter.ReadAsync(context)); + } + [Theory] [InlineData("application/xml", true)] [InlineData("application/*", false)] @@ -435,6 +527,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml private InputFormatterContext GetInputFormatterContext(byte[] contentBytes, Type modelType) { var httpContext = GetHttpContext(contentBytes); + return GetInputFormatterContext(httpContext, modelType); + } + + private InputFormatterContext GetInputFormatterContext(HttpContext httpContext, Type modelType) + { var provider = new EmptyModelMetadataProvider(); var metadata = provider.GetMetadataForType(modelType); return new InputFormatterContext( @@ -471,5 +568,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml return base.CreateSerializer(type); } } + + private class TestResponseFeature : HttpResponseFeature + { + public override void OnCompleted(Func callback, object state) + { + // do not do anything + } + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.TestCommon/NonSeekableReadableStream.cs b/test/Microsoft.AspNetCore.Mvc.TestCommon/NonSeekableReadableStream.cs new file mode 100644 index 0000000000..b729dc6b68 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TestCommon/NonSeekableReadableStream.cs @@ -0,0 +1,75 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.TestCommon +{ + public class NonSeekableReadStream : Stream + { + private Stream _inner; + + public NonSeekableReadStream(byte[] data) + : this(new MemoryStream(data)) + { + } + + public NonSeekableReadStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => _inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length + { + get { throw new NotSupportedException(); } + } + + public override long Position + { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + + public override void Flush() + { + throw new NotImplementedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + count = Math.Max(count, 1); + return _inner.Read(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + count = Math.Max(count, 1); + return _inner.ReadAsync(buffer, offset, count, cancellationToken); + } + } +} +