diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs index 028c24894f..15f59080d1 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs @@ -83,6 +83,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters path = path.Substring(2); } + if (path.StartsWith("$[", StringComparison.Ordinal)) + { + path = path.Substring(1); + } + // Handle path combinations such as ""+"Property", "Parent"+"Property", or "Parent"+"[12]". var key = ModelNames.CreatePropertyModelName(context.ModelName, path); diff --git a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs index e7190037e9..fe94632904 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs @@ -3,16 +3,20 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging.Testing; +using Newtonsoft.Json; using Xunit; namespace Microsoft.AspNetCore.Mvc.Formatters { - public abstract class JsonInputFormatterTestBase + public abstract class JsonInputFormatterTestBase : LoggedTest { [Theory] [InlineData("application/json", true)] @@ -199,6 +203,26 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Equal(new int[] { 0, 23, 300 }, (IEnumerable)result.Model); } + [Fact] + public virtual async Task ReadAsync_ArrayOfObjects_HasCorrectKey() + { + // Arrange + var formatter = GetInputFormatter(); + + var content = "[{\"Age\": 5}, {\"Age\": 3}, {\"Age\": \"Cheese\"} ]"; + var contentBytes = Encoding.UTF8.GetBytes(content); + var httpContext = GetHttpContext(contentBytes); + + var formatterContext = CreateInputFormatterContext(typeof(List), httpContext); + + // Act + var result = await formatter.ReadAsync(formatterContext); + + // Assert + Assert.True(result.HasError, "Model should have had an error!"); + Assert.Single(formatterContext.ModelState["[2].Age"].Errors); + } + [Fact] public virtual async Task ReadAsync_AddsModelValidationErrorsToModelState() { @@ -215,10 +239,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters 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 44.", - formatterContext.ModelState["Age"].Errors[0].ErrorMessage); + Assert.True(result.HasError, "Model should have had an error!"); + Assert.Single(formatterContext.ModelState["Age"].Errors); } [Fact] @@ -227,19 +249,18 @@ namespace Microsoft.AspNetCore.Mvc.Formatters // Arrange var formatter = GetInputFormatter(); - var content = "[0, 23, 300]"; + var content = "[0, 23, 33767]"; var contentBytes = Encoding.UTF8.GetBytes(content); var httpContext = GetHttpContext(contentBytes); - var formatterContext = CreateInputFormatterContext(typeof(byte[]), httpContext); + var formatterContext = CreateInputFormatterContext(typeof(short[]), 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); + Assert.True(result.HasError, "Model should have produced an error!"); + Assert.True(formatterContext.ModelState.ContainsKey("[2]"), "Should have contained key '[2]'"); } [Fact] @@ -259,9 +280,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters // Assert Assert.True(result.HasError); - Assert.Equal( - "Error converting value 300 to type 'System.Byte'. Path '[1].Small', line 1, position 69.", - formatterContext.ModelState["names[1].Small"].Errors[0].ErrorMessage); + Assert.Single(formatterContext.ModelState["names[1].Small"].Errors); } [Fact] @@ -318,6 +337,45 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Null(result.Model); } + [Fact] + public async Task ReadAsync_ComplexPoco() + { + // Arrange + var formatter = GetInputFormatter(); + + var content = "{ \"Id\": 5, \"Person\": { \"Name\": \"name\", \"Numbers\": [3, 2, \"Hamburger\"]} }"; + var contentBytes = Encoding.UTF8.GetBytes(content); + var httpContext = GetHttpContext(contentBytes); + + var formatterContext = CreateInputFormatterContext(typeof(ComplexPoco), httpContext); + + // Act + var result = await formatter.ReadAsync(formatterContext); + + // Assert + Assert.True(result.HasError, "Model should have had an error!"); + Assert.Single(formatterContext.ModelState["Person.Numbers[2]"].Errors); + } + + [Fact] + public virtual async Task ReadAsync_RequiredAttribute() + { + // Arrange + var formatter = GetInputFormatter(); + var content = "{ \"Id\": 5, \"Person\": {\"Numbers\": [3]} }"; + var contentBytes = Encoding.UTF8.GetBytes(content); + var httpContext = GetHttpContext(contentBytes); + + var formatterContext = CreateInputFormatterContext(typeof(ComplexPoco), httpContext); + + // Act + var result = await formatter.ReadAsync(formatterContext); + + // Assert + Assert.True(result.HasError, "Model should have had an error!"); + Assert.Single(formatterContext.ModelState["Person.Name"].Errors); + } + protected abstract TextInputFormatter GetInputFormatter(); protected static HttpContext GetHttpContext( @@ -356,6 +414,20 @@ namespace Microsoft.AspNetCore.Mvc.Formatters treatEmptyInputAsDefaultValue: treatEmptyInputAsDefaultValue); } + protected sealed class ComplexPoco + { + public int Id { get; set; } + public Person Person{ get; set; } + } + + protected sealed class Person + { + [Required] + [JsonProperty(Required = Required.Always)] + public string Name { get; set; } + public IEnumerable Numbers { get; set; } + } + protected sealed class ComplexModel { public string Name { get; set; } diff --git a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonInputFormatterTest.cs b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonInputFormatterTest.cs index 8cea7ff821..b4d33dad96 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonInputFormatterTest.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonInputFormatterTest.cs @@ -1,40 +1,80 @@ // 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.Text; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Xunit; namespace Microsoft.AspNetCore.Mvc.Formatters { public class SystemTextJsonInputFormatterTest : JsonInputFormatterTestBase { - [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/8474")] + [Fact] public override Task ReadAsync_AddsModelValidationErrorsToModelState() { return base.ReadAsync_AddsModelValidationErrorsToModelState(); } - [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/8474")] + [Fact] public override Task ReadAsync_InvalidArray_AddsOverflowErrorsToModelState() { return base.ReadAsync_InvalidArray_AddsOverflowErrorsToModelState(); } - [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/8474")] + [Fact] public override Task ReadAsync_InvalidComplexArray_AddsOverflowErrorsToModelState() { return base.ReadAsync_InvalidComplexArray_AddsOverflowErrorsToModelState(); } - [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/8474")] + [Fact] public override Task ReadAsync_UsesTryAddModelValidationErrorsToModelState() { return base.ReadAsync_UsesTryAddModelValidationErrorsToModelState(); } + [Fact(Skip = "https://github.com/dotnet/corefx/issues/38492")] + public override Task ReadAsync_RequiredAttribute() + { + // System.Text.Json does not yet support an equivalent of Required. + throw new NotImplementedException(); + } + + [Fact] + public async Task ReadAsync_SingleError() + { + // Arrange + var failTotal = 0; + var formatter = GetInputFormatter(); + + var content = "[5, 'seven', 3, notnum ]"; + var contentBytes = Encoding.UTF8.GetBytes(content); + var httpContext = GetHttpContext(contentBytes); + + var formatterContext = CreateInputFormatterContext(typeof(List), httpContext); + + // Act + await formatter.ReadAsync(formatterContext); + + // Assert + foreach(var modelState in formatterContext.ModelState) + { + foreach(var error in modelState.Value.Errors) + { + failTotal++; + Assert.StartsWith("''' is an invalid start of a value", error.ErrorMessage); + } + } + + Assert.Equal(1, failTotal); + } + protected override TextInputFormatter GetInputFormatter() { - return new SystemTextJsonInputFormatter(new JsonOptions()); + return new SystemTextJsonInputFormatter(new JsonOptions(), LoggerFactory.CreateLogger()); } } } diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs index 31a96631fc..ca30b611c5 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs +++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs @@ -226,7 +226,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } else { - addMember = !path.EndsWith("." + member, StringComparison.Ordinal); + addMember = !path.EndsWith($".{member}", StringComparison.Ordinal) + && !path.EndsWith($"[{member}]", StringComparison.Ordinal); } } } diff --git a/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs b/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs index f9d79a6f6d..8f773cba66 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs +++ b/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs @@ -3,7 +3,7 @@ using System; using System.Buffers; -using System.IO; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -235,6 +235,45 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Equal(expectedMessage, modelError.ErrorMessage); } + [Theory] + [InlineData("[5,7,3]", 0)] + [InlineData("[5, 'seven', 3]", 1)] + [InlineData("[5, 'seven', 3, 'notnum']", 2)] + public async Task ReadAsync_AllowMultipleErrors(string content, int failCount) + { + // Arrange + var failTotal = 0; + var serializerSettings = new JsonSerializerSettings + { + Error = delegate (object sender, ErrorEventArgs args) + { + args.ErrorContext.Handled = true; + } + }; + + var formatter = CreateFormatter(serializerSettings: serializerSettings, allowInputFormatterExceptionMessages: true); + + var contentBytes = Encoding.UTF8.GetBytes(content); + var httpContext = GetHttpContext(contentBytes); + + var formatterContext = CreateInputFormatterContext(typeof(List), httpContext); + + // Act + var result = await formatter.ReadAsync(formatterContext); + + // Assert + foreach (var modelState in formatterContext.ModelState) + { + foreach (var error in modelState.Value.Errors) + { + failTotal++; + Assert.StartsWith("Could not convert string to integer:", error.ErrorMessage); + } + } + + Assert.Equal(failCount, failTotal); + } + [Fact] public async Task ReadAsync_DoNotAllowInputFormatterExceptionMessages_DoesNotWrapJsonInputExceptions() { diff --git a/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs b/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs index 751004d291..2aa4ff6829 100644 --- a/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs +++ b/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs @@ -5,19 +5,14 @@ using Microsoft.AspNetCore.Mvc; namespace MvcSandbox.Controllers { - [ApiController] public class HomeController : Controller { - [HttpPost("/")] - public IActionResult Index(Person person) + [ModelBinder] + public string Id { get; set; } + + public IActionResult Index() { - return Ok(person); + return View(); } } - - public class Person - { - public int Id { get; set; } - } - }