// 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.Buffers; using System.Collections.Generic; using System.IO; 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; using Moq; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Xunit; namespace Microsoft.AspNetCore.Mvc.Formatters { public class JsonInputFormatterTest { private static readonly ObjectPoolProvider _objectPoolProvider = new DefaultObjectPoolProvider(); private static readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings(); [Fact] public async Task Version_2_0_Constructor_BuffersRequestBody_ByDefault() { // Arrange #pragma warning disable CS0618 var formatter = new JsonInputFormatter( GetLogger(), _serializerSettings, ArrayPool.Shared, _objectPoolProvider); #pragma warning restore CS0618 var content = "{name: 'Person Name', Age: '30'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = new DefaultHttpContext(); httpContext.Features.Set(new TestResponseFeature()); httpContext.Request.Body = new NonSeekableReadStream(contentBytes); httpContext.Request.ContentType = "application/json"; var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // 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(formatterContext); // 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 Version_2_1_Constructor_BuffersRequestBody_UsingDefaultOptions() { // Arrange var formatter = new JsonInputFormatter( GetLogger(), _serializerSettings, ArrayPool.Shared, _objectPoolProvider, new MvcOptions(), new MvcJsonOptions()); var content = "{name: 'Person Name', Age: '30'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = new DefaultHttpContext(); httpContext.Features.Set(new TestResponseFeature()); httpContext.Request.Body = new NonSeekableReadStream(contentBytes); httpContext.Request.ContentType = "application/json"; var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // 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(formatterContext); // 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 Version_2_0_Constructor_SuppressInputFormatterBufferingSetToTrue_DoesNotBufferRequestBody() { // Arrange #pragma warning disable CS0618 var formatter = new JsonInputFormatter( GetLogger(), _serializerSettings, ArrayPool.Shared, _objectPoolProvider, suppressInputFormatterBuffering: true); #pragma warning restore CS0618 var content = "{name: 'Person Name', Age: '30'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = new DefaultHttpContext(); httpContext.Features.Set(new TestResponseFeature()); httpContext.Request.Body = new NonSeekableReadStream(contentBytes); httpContext.Request.ContentType = "application/json"; var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); var userModel = Assert.IsType(result.Model); Assert.Equal("Person Name", userModel.Name); Assert.Equal(30, userModel.Age); Assert.False(httpContext.Request.Body.CanSeek); result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); Assert.Null(result.Model); } [Fact] public async Task Version_2_1_Constructor_SuppressInputFormatterBuffering_UsingMvcOptions_DoesNotBufferRequestBody() { // Arrange var mvcOptions = new MvcOptions() { SuppressInputFormatterBuffering = true, }; var formatter = new JsonInputFormatter( GetLogger(), _serializerSettings, ArrayPool.Shared, _objectPoolProvider, mvcOptions, new MvcJsonOptions()); var content = "{name: 'Person Name', Age: '30'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = new DefaultHttpContext(); httpContext.Features.Set(new TestResponseFeature()); httpContext.Request.Body = new NonSeekableReadStream(contentBytes); httpContext.Request.ContentType = "application/json"; var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); var userModel = Assert.IsType(result.Model); Assert.Equal("Person Name", userModel.Name); Assert.Equal(30, userModel.Age); Assert.False(httpContext.Request.Body.CanSeek); result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); Assert.Null(result.Model); } [Fact] public async Task Version_2_1_Constructor_SuppressInputFormatterBufferingSetToTrue_UsingMutatedOptions() { // Arrange var mvcOptions = new MvcOptions() { SuppressInputFormatterBuffering = false, }; var formatter = new JsonInputFormatter( GetLogger(), _serializerSettings, ArrayPool.Shared, _objectPoolProvider, mvcOptions, new MvcJsonOptions()); var content = "{name: 'Person Name', Age: '30'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = new DefaultHttpContext(); httpContext.Features.Set(new TestResponseFeature()); httpContext.Request.Body = new NonSeekableReadStream(contentBytes); httpContext.Request.ContentType = "application/json"; var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act // Mutate options after passing into the constructor to make sure that the value type is not store in the constructor mvcOptions.SuppressInputFormatterBuffering = true; var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); var userModel = Assert.IsType(result.Model); Assert.Equal("Person Name", userModel.Name); Assert.Equal(30, userModel.Age); Assert.False(httpContext.Request.Body.CanSeek); result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); Assert.Null(result.Model); } [Theory] [InlineData("application/json", true)] [InlineData("application/*", false)] [InlineData("*/*", false)] [InlineData("text/json", true)] [InlineData("text/*", false)] [InlineData("text/xml", false)] [InlineData("application/xml", false)] [InlineData("application/some.entity+json", true)] [InlineData("application/some.entity+json;v=2", true)] [InlineData("application/some.entity+xml", false)] [InlineData("application/some.entity+*", false)] [InlineData("text/some.entity+json", false)] [InlineData("", false)] [InlineData(null, false)] [InlineData("invalid", false)] public void CanRead_ReturnsTrueForAnySupportedContentType(string requestContentType, bool expectedCanRead) { // Arrange var formatter = CreateFormatter(); var contentBytes = Encoding.UTF8.GetBytes("content"); var httpContext = GetHttpContext(contentBytes, contentType: requestContentType); var formatterContext = CreateInputFormatterContext(typeof(string), httpContext); // Act var result = formatter.CanRead(formatterContext); // Assert Assert.Equal(expectedCanRead, result); } [Fact] public void DefaultMediaType_ReturnsApplicationJson() { // Arrange var formatter = CreateFormatter(); // Act var mediaType = formatter.SupportedMediaTypes[0]; // Assert Assert.Equal("application/json", mediaType.ToString()); } public static IEnumerable JsonFormatterReadSimpleTypesData { get { yield return new object[] { "100", typeof(int), 100 }; yield return new object[] { "'abcd'", typeof(string), "abcd" }; yield return new object[] { "'2012-02-01 12:45 AM'", typeof(DateTime), new DateTime(2012, 02, 01, 00, 45, 00) }; } } [Theory] [MemberData(nameof(JsonFormatterReadSimpleTypesData))] public async Task JsonFormatterReadsSimpleTypes(string content, Type type, object expected) { // Arrange var formatter = CreateFormatter(); var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(type, httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); Assert.Equal(expected, result.Model); } [Fact] public async Task JsonFormatterReadsComplexTypes() { // Arrange var formatter = CreateFormatter(); var content = "{name: 'Person Name', Age: '30'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); var userModel = Assert.IsType(result.Model); Assert.Equal("Person Name", userModel.Name); Assert.Equal(30, userModel.Age); } [Fact] public async Task ReadAsync_ReadsValidArray() { // Arrange var formatter = CreateFormatter(); var content = "[0, 23, 300]"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(int[]), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); var integers = Assert.IsType(result.Model); Assert.Equal(new int[] { 0, 23, 300 }, integers); } [Theory] [InlineData(typeof(ICollection))] [InlineData(typeof(IEnumerable))] [InlineData(typeof(IList))] [InlineData(typeof(List))] public async Task ReadAsync_ReadsValidArray_AsList(Type requestedType) { // Arrange var formatter = CreateFormatter(); var content = "[0, 23, 300]"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(requestedType, httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); var integers = Assert.IsType>(result.Model); Assert.Equal(new int[] { 0, 23, 300 }, integers); } [Fact] public async Task ReadAsync_AddsModelValidationErrorsToModelState() { // Arrange var formatter = CreateFormatter(allowInputFormatterExceptionMessages: true); var content = "{name: 'Person Name', Age: 'not-an-age'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.Equal( "Could not convert string to decimal: not-an-age. Path 'Age', line 1, position 39.", formatterContext.ModelState["Age"].Errors[0].ErrorMessage); } [Fact] public async Task ReadAsync_InvalidArray_AddsOverflowErrorsToModelState() { // Arrange var formatter = CreateFormatter(allowInputFormatterExceptionMessages: true); var content = "[0, 23, 300]"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(byte[]), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.Equal("The supplied value is invalid.", formatterContext.ModelState["[2]"].Errors[0].ErrorMessage); Assert.Null(formatterContext.ModelState["[2]"].Errors[0].Exception); } [Fact] public async Task ReadAsync_InvalidComplexArray_AddsOverflowErrorsToModelState() { // Arrange var formatter = CreateFormatter(allowInputFormatterExceptionMessages: true); var content = "[{name: 'Name One', Age: 30}, {name: 'Name Two', Small: 300}]"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(User[]), httpContext, modelName: "names"); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.Equal( "Error converting value 300 to type 'System.Byte'. Path '[1].Small', line 1, position 59.", formatterContext.ModelState["names[1].Small"].Errors[0].ErrorMessage); } [Fact] public async Task ReadAsync_UsesTryAddModelValidationErrorsToModelState() { // Arrange var formatter = CreateFormatter(); var content = "{name: 'Person Name', Age: 'not-an-age'}"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); formatterContext.ModelState.MaxAllowedErrors = 3; formatterContext.ModelState.AddModelError("key1", "error1"); formatterContext.ModelState.AddModelError("key2", "error2"); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.False(formatterContext.ModelState.ContainsKey("age")); var error = Assert.Single(formatterContext.ModelState[""].Errors); Assert.IsType(error.Exception); } [Theory] [InlineData("null", true, true)] [InlineData("null", false, false)] [InlineData(" ", true, true)] [InlineData(" ", false, false)] public async Task ReadAsync_WithInputThatDeserializesToNull_SetsModelOnlyIfAllowingEmptyInput( string content, bool treatEmptyInputAsDefaultValue, bool expectedIsModelSet) { // Arrange var formatter = CreateFormatter(); var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext( typeof(object), httpContext, treatEmptyInputAsDefaultValue: treatEmptyInputAsDefaultValue); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.False(result.HasError); Assert.Equal(expectedIsModelSet, result.IsModelSet); Assert.Null(result.Model); } [Fact] public void Constructor_UsesSerializerSettings() { // Arrange var serializerSettings = new JsonSerializerSettings(); // Act var formatter = new TestableJsonInputFormatter(serializerSettings); // Assert Assert.Same(serializerSettings, formatter.SerializerSettings); } [Fact] public async Task CustomSerializerSettingsObject_TakesEffect() { // Arrange // by default we ignore missing members, so here explicitly changing it var serializerSettings = new JsonSerializerSettings { MissingMemberHandling = MissingMemberHandling.Error }; var formatter = CreateFormatter(serializerSettings, allowInputFormatterExceptionMessages: true); // missing password property here var contentBytes = Encoding.UTF8.GetBytes("{ \"UserName\" : \"John\"}"); var httpContext = GetHttpContext(contentBytes, "application/json;charset=utf-8"); var formatterContext = CreateInputFormatterContext(typeof(UserLogin), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.False(formatterContext.ModelState.IsValid); var message = formatterContext.ModelState.Values.First().Errors[0].ErrorMessage; Assert.Contains("Required property 'Password' not found in JSON", message); } [Fact] public void CreateJsonSerializer_UsesJsonSerializerSettings() { // Arrange var settings = new JsonSerializerSettings { ContractResolver = Mock.Of(), MaxDepth = 2, DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind, }; var formatter = new TestableJsonInputFormatter(settings); // Act var actual = formatter.CreateJsonSerializer(); // Assert Assert.Same(settings.ContractResolver, actual.ContractResolver); Assert.Equal(settings.MaxDepth, actual.MaxDepth); Assert.Equal(settings.DateTimeZoneHandling, actual.DateTimeZoneHandling); } [Theory] [InlineData("{", "", "Unexpected end when reading JSON. Path '', line 1, position 1.")] [InlineData("{\"a\":{\"b\"}}", "a", "Invalid character after parsing property name. Expected ':' but got: }. Path 'a', line 1, position 9.")] [InlineData("{\"age\":\"x\"}", "age", "Could not convert string to decimal: x. Path 'age', line 1, position 10.")] [InlineData("{\"login\":1}", "login", "Error converting value 1 to type 'Microsoft.AspNetCore.Mvc.Formatters.JsonInputFormatterTest+UserLogin'. Path 'login', line 1, position 10.")] [InlineData("{\"login\":{\"username\":\"somevalue\"}}", "login", "Required property 'Password' not found in JSON. Path 'login', line 1, position 33.")] public async Task ReadAsync_WithAllowInputFormatterExceptionMessages_RegistersJsonInputExceptionsAsInputFormatterException( string content, string modelStateKey, string expectedMessage) { // Arrange var formatter = CreateFormatter(allowInputFormatterExceptionMessages: true); var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.True(!formatterContext.ModelState.IsValid); Assert.True(formatterContext.ModelState.ContainsKey(modelStateKey)); var modelError = formatterContext.ModelState[modelStateKey].Errors.Single(); Assert.Equal(expectedMessage, modelError.ErrorMessage); } [Fact] public async Task ReadAsync_DefaultOptions_DoesNotWrapJsonInputExceptions() { // Arrange var formatter = new JsonInputFormatter( GetLogger(), _serializerSettings, ArrayPool.Shared, _objectPoolProvider, new MvcOptions(), new MvcJsonOptions()); var contentBytes = Encoding.UTF8.GetBytes("{"); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.True(!formatterContext.ModelState.IsValid); Assert.True(formatterContext.ModelState.ContainsKey(string.Empty)); var modelError = formatterContext.ModelState[string.Empty].Errors.Single(); Assert.IsNotType(modelError.Exception); Assert.Empty(modelError.ErrorMessage); } [Fact] public async Task ReadAsync_AllowInputFormatterExceptionMessages_DoesNotWrapJsonInputExceptions() { // Arrange var formatter = new JsonInputFormatter( GetLogger(), _serializerSettings, ArrayPool.Shared, _objectPoolProvider, new MvcOptions(), new MvcJsonOptions() { AllowInputFormatterExceptionMessages = true, }); var contentBytes = Encoding.UTF8.GetBytes("{"); var httpContext = GetHttpContext(contentBytes); var formatterContext = CreateInputFormatterContext(typeof(User), httpContext); // Act var result = await formatter.ReadAsync(formatterContext); // Assert Assert.True(result.HasError); Assert.True(!formatterContext.ModelState.IsValid); Assert.True(formatterContext.ModelState.ContainsKey(string.Empty)); var modelError = formatterContext.ModelState[string.Empty].Errors.Single(); Assert.Null(modelError.Exception); Assert.NotEmpty(modelError.ErrorMessage); } private class TestableJsonInputFormatter : JsonInputFormatter { public TestableJsonInputFormatter(JsonSerializerSettings settings) : base(GetLogger(), settings, ArrayPool.Shared, _objectPoolProvider, new MvcOptions(), new MvcJsonOptions()) { } public new JsonSerializerSettings SerializerSettings => base.SerializerSettings; public new JsonSerializer CreateJsonSerializer() => base.CreateJsonSerializer(); } private static ILogger GetLogger() { return NullLogger.Instance; } private JsonInputFormatter CreateFormatter(JsonSerializerSettings serializerSettings = null, bool allowInputFormatterExceptionMessages = false) { return new JsonInputFormatter( GetLogger(), serializerSettings ?? _serializerSettings, ArrayPool.Shared, _objectPoolProvider, new MvcOptions(), new MvcJsonOptions() { AllowInputFormatterExceptionMessages = allowInputFormatterExceptionMessages, }); } 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(requestStream); request.SetupGet(f => f.ContentType).Returns(contentType); var httpContext = new Mock(); httpContext.SetupGet(c => c.Request).Returns(request.Object); httpContext.SetupGet(c => c.Request).Returns(request.Object); return httpContext.Object; } private InputFormatterContext CreateInputFormatterContext( Type modelType, HttpContext httpContext, string modelName = null, bool treatEmptyInputAsDefaultValue = false) { var provider = new EmptyModelMetadataProvider(); var metadata = provider.GetMetadataForType(modelType); return new InputFormatterContext( httpContext, modelName: modelName ?? string.Empty, modelState: new ModelStateDictionary(), metadata: metadata, readerFactory: new TestHttpRequestStreamReaderFactory().CreateReader, treatEmptyInputAsDefaultValue: treatEmptyInputAsDefaultValue); } private IEnumerable GetModelStateErrorMessages(ModelStateDictionary modelStateDictionary) { var allErrorMessages = new List(); foreach (var keyModelStatePair in modelStateDictionary) { var key = keyModelStatePair.Key; var errors = keyModelStatePair.Value.Errors; if (errors != null && errors.Count > 0) { foreach (var modelError in errors) { if (string.IsNullOrEmpty(modelError.ErrorMessage)) { if (modelError.Exception != null) { allErrorMessages.Add(modelError.Exception.Message); } } else { allErrorMessages.Add(modelError.ErrorMessage); } } } } return allErrorMessages; } private sealed class User { public string Name { get; set; } public decimal Age { get; set; } public byte Small { get; set; } public UserLogin Login { get; set; } } private sealed class UserLogin { [JsonProperty(Required = Required.Always)] public string UserName { get; set; } [JsonProperty(Required = Required.Always)] public string Password { get; set; } } private class Location { public int Id { get; set; } public string Name { get; set; } } private class TestResponseFeature : HttpResponseFeature { public override void OnCompleted(Func callback, object state) { // do not do anything } } } }