From f737083c946137dd9a411759c3ca83856a0083a2 Mon Sep 17 00:00:00 2001 From: Kiran Challa Date: Mon, 16 Feb 2015 13:52:16 -0800 Subject: [PATCH] Added JsonContractResolver to validate value type properties --- .../Formatters/JsonContractResolver.cs | 42 ++++ .../Formatters/JsonInputFormatter.cs | 2 + .../Formatters/JsonInputFormatterTest.cs | 200 ++++++++++++++++++ .../InputObjectValidationTests.cs | 6 +- .../ModelBindingTest.cs | 69 ++++++ .../Controllers/ValidationController.cs | 46 ++++ .../ModelBindingWebSite/Models/Drawing.cs | 18 ++ .../ModelBindingWebSite/Models/Line.cs | 17 ++ .../ModelBindingWebSite/Models/Point.cs | 16 ++ .../ModelBindingWebSite/Models/Rectangle.cs | 12 ++ test/WebSites/ModelBindingWebSite/Startup.cs | 2 + 11 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.AspNet.Mvc.Core/Formatters/JsonContractResolver.cs create mode 100644 test/WebSites/ModelBindingWebSite/Models/Drawing.cs create mode 100644 test/WebSites/ModelBindingWebSite/Models/Line.cs create mode 100644 test/WebSites/ModelBindingWebSite/Models/Point.cs create mode 100644 test/WebSites/ModelBindingWebSite/Models/Rectangle.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonContractResolver.cs b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonContractResolver.cs new file mode 100644 index 0000000000..775938d478 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonContractResolver.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// The default for . + /// It determines if a value type member has and sets the appropriate + /// JsonProperty settings. + /// + public class JsonContractResolver : DefaultContractResolver + { + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + + var required = member.GetCustomAttribute(typeof(RequiredAttribute), inherit: true); + if (required != null) + { + var propertyType = ((PropertyInfo)member).PropertyType; + + // DefaultObjectValidator does required attribute validation on properties based on the property + // value being null. Since this is not possible in case of value types, we depend on the formatters + // to handle value type validation. + // With the following settings here, if a value is not present on the wire for value types + // like primitive, struct etc., Json.net's serializer would throw exception which we catch + // and add it to model state. + if (propertyType.IsValueType() && !propertyType.IsNullableValueType()) + { + property.Required = Required.AllowNull; + } + } + + return property; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs index bb6ab5490e..7e4243614c 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs @@ -36,6 +36,8 @@ namespace Microsoft.AspNet.Mvc // Setting this to None prevents Json.NET from loading malicious, unsafe, or security-sensitive types TypeNameHandling = TypeNameHandling.None }; + + _jsonSerializerSettings.ContractResolver = new JsonContractResolver(); } /// diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs index b4d343bd7b..9c3e30d05c 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs @@ -4,6 +4,7 @@ #if ASPNET50 using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Text; @@ -234,6 +235,126 @@ namespace Microsoft.AspNet.Mvc Assert.Contains("Required property 'Password' not found in JSON", modelErrorMessage); } + [Fact] + public async Task ThrowsException_OnSupplyingNull_ForRequiredValueType() + { + // Arrange + var contentBytes = Encoding.UTF8.GetBytes("{\"Id\":\"null\",\"Name\":\"Programming C#\"}"); + var jsonFormatter = new JsonInputFormatter() { CaptureDeserilizationErrors = true }; + var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8"); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(Book)); + var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType); + + // Act + var obj = await jsonFormatter.ReadAsync(inputFormatterContext); + + // Assert + var book = obj as Book; + Assert.NotNull(book); + Assert.Equal(0, book.Id); + Assert.Equal("Programming C#", book.Name); + Assert.False(actionContext.ModelState.IsValid); + + Assert.Equal(1, actionContext.ModelState.Values.First().Errors.Count); + var modelErrorMessage = actionContext.ModelState.Values.First().Errors[0].Exception.Message; + Assert.Contains("Could not convert string to integer: null. Path 'Id'", modelErrorMessage); + } + + [Theory] + [InlineData(typeof(Book))] + [InlineData(typeof(EBook))] + public async Task Validates_RequiredAttribute_OnRegularAndInheritedProperties(Type type) + { + // Arrange + var contentBytes = Encoding.UTF8.GetBytes("{ \"Name\" : \"Programming C#\"}"); + var jsonFormatter = new JsonInputFormatter() { CaptureDeserilizationErrors = true }; + var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8"); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(type); + var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType); + + // Act + var obj = await jsonFormatter.ReadAsync(inputFormatterContext); + + // Assert + Assert.False(actionContext.ModelState.IsValid); + Assert.Equal(1, actionContext.ModelState.Count); + + var modelErrorMessage = actionContext.ModelState.Values.First().Errors[0].Exception.Message; + Assert.Contains("Required property 'Id' not found in JSON", modelErrorMessage); + } + + [Fact] + public async Task Validates_RequiredAttributeOnStructTypes() + { + // Arrange + var contentBytes = Encoding.UTF8.GetBytes("{\"Longitude\":{}}"); + var jsonFormatter = new JsonInputFormatter() { CaptureDeserilizationErrors = true }; + var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8"); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(GpsCoordinate)); + var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType); + + // Act + var obj = await jsonFormatter.ReadAsync(inputFormatterContext); + + // Assert + Assert.False(actionContext.ModelState.IsValid); + Assert.Equal(2, actionContext.ModelState.Count); + var errorMessages = GetModelStateErrorMessages(actionContext.ModelState); + Assert.Equal(3, errorMessages.Count()); + Assert.Contains( + errorMessages, + (errorMessage) => errorMessage.Contains("Required property 'Latitude' not found in JSON")); + Assert.Contains( + errorMessages, + (errorMessage) => errorMessage.Contains("Required property 'X' not found in JSON")); + Assert.Contains( + errorMessages, + (errorMessage) => errorMessage.Contains("Required property 'Y' not found in JSON")); + } + + [Fact] + public async Task Validation_DoesNotHappen_ForNonRequired_ValueTypeProperties() + { + // Arrange + var contentBytes = Encoding.UTF8.GetBytes("{\"Name\":\"Seattle\"}"); + var jsonFormatter = new JsonInputFormatter() { CaptureDeserilizationErrors = true }; + var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8"); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(Location)); + var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType); + + // Act + var obj = await jsonFormatter.ReadAsync(inputFormatterContext); + + // Assert + Assert.True(actionContext.ModelState.IsValid); + var location = obj as Location; + Assert.NotNull(location); + Assert.Equal(0, location.Id); + Assert.Equal("Seattle", location.Name); + } + + [Fact] + public async Task Validation_DoesNotHappen_OnNullableValueTypeProperties() + { + // Arrange + var contentBytes = Encoding.UTF8.GetBytes("{}"); + var jsonFormatter = new JsonInputFormatter() { CaptureDeserilizationErrors = true }; + var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8"); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(Venue)); + var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType); + + // Act + var obj = await jsonFormatter.ReadAsync(inputFormatterContext); + + // Assert + Assert.True(actionContext.ModelState.IsValid); + var venue = obj as Venue; + Assert.NotNull(venue); + Assert.Null(venue.Location); + Assert.Null(venue.NearByLocations); + Assert.Null(venue.Name); + } + private static ActionContext GetActionContext(byte[] contentBytes, string contentType = "application/xml") { @@ -257,6 +378,35 @@ namespace Microsoft.AspNet.Mvc return httpContext.Object; } + 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; } @@ -272,6 +422,56 @@ namespace Microsoft.AspNet.Mvc [JsonProperty(Required = Required.Always)] public string Password { get; set; } } + + private class Book + { + [Required] + public int Id { get; set; } + + [Required] + public string Name { get; set; } + } + + private class EBook : Book + { + } + + private struct Point + { + [Required] + public int X { get; set; } + + [Required] + public int Y { get; set; } + } + + private class GpsCoordinate + { + [Required] + public Point Latitude { get; set; } + + [Required] + public Point Longitude { get; set; } + } + + private class Location + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class Venue + { + [Required] + public string Name { get; set; } + + [Required] + public Point? Location { get; set; } + + [Required] + public List NearByLocations { get; set; } + } } } #endif diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/InputObjectValidationTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/InputObjectValidationTests.cs index fc92465a57..d7d07b961f 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/InputObjectValidationTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/InputObjectValidationTests.cs @@ -175,8 +175,10 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Arrange var server = TestServer.Create(_services, _app); var client = server.CreateClient(); - var content = new StringContent("{\"Alias\":\"xyz\"}", Encoding.UTF8, "application/json"); - + var kvps = new List>(); + kvps.Add(new KeyValuePair("Alias", "xyz")); + var content = new FormUrlEncodedContent(kvps); + // Act var response = await client.PostAsync("http://localhost/Validation/GetDeveloperAlias", content); diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs index 38dfc3c006..43c432e8cc 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs @@ -1777,5 +1777,74 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Should Update all included properties. Assert.Equal("March", user.RegisterationMonth); } + + public static TheoryData ModelStateHasErrorsForValueAndReferenceTypesData + { + get + { + return new TheoryData() + { + { + "{}", + new[] + { + ":Required property 'Id' not found in JSON", + "rectangle.Lines:The Lines field is required." + } + }, + { + "{\"Id\":10}", + new[] + { + "rectangle.Lines:The Lines field is required." + } + }, + { + "{\"Id\":10,\"Lines\":[{}]}", + new [] + { + "Lines[0]:Required property 'Start' not found in JSON", + "Lines[0]:Required property 'End' not found in JSON" + } + }, + { + "{\"Id\":10,\"Lines\":[{\"Start\":{\"X\":10,\"Y\":10},\"End\":{\"X\":10}}]}", + new [] + { + "Lines[0].End:Required property 'Y' not found in JSON" + } + } + }; + } + } + + [Theory] + [MemberData(nameof(ModelStateHasErrorsForValueAndReferenceTypesData))] + public async Task ModelState_HasErrors_ForValueAndReferenceTypes( + string input, + IEnumerable expectedModelStateErrorMessages) + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + var content = new StringContent(input, Encoding.UTF8, "text/json"); + + // Act + var response = await client.PostAsync( + "http://localhost/Validation/CreateRectangle", + content); + + // Assert + var data = await response.Content.ReadAsStringAsync(); + var actualModelStateErrorMessages = JsonConvert.DeserializeObject>(data); + Assert.NotNull(actualModelStateErrorMessages); + Assert.Equal(expectedModelStateErrorMessages.Count(), actualModelStateErrorMessages.Count()); + foreach (var expectedErrorMessage in expectedModelStateErrorMessages) + { + Assert.Contains( + actualModelStateErrorMessages, + (actualErrorMessage) => actualErrorMessage.StartsWith(expectedErrorMessage)); + } + } } } \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Controllers/ValidationController.cs b/test/WebSites/ModelBindingWebSite/Controllers/ValidationController.cs index 45836f4fff..f47e9554bb 100644 --- a/test/WebSites/ModelBindingWebSite/Controllers/ValidationController.cs +++ b/test/WebSites/ModelBindingWebSite/Controllers/ValidationController.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.ModelBinding; namespace ModelBindingWebSite.Controllers { @@ -21,6 +22,51 @@ namespace ModelBindingWebSite.Controllers { return ModelState.IsValid; } + + public IActionResult CreateRectangle([FromBody] Rectangle rectangle) + { + if (!ModelState.IsValid) + { + return new ObjectResult(GetModelStateErrorMessages(ModelState)) { StatusCode = 400 }; + } + + return new ObjectResult(rectangle); + } + + 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) + { + string errorMessage = null; + foreach (var modelError in errors) + { + if (string.IsNullOrEmpty(modelError.ErrorMessage)) + { + if (modelError.Exception != null) + { + errorMessage = modelError.Exception.Message; + } + } + else + { + errorMessage = modelError.ErrorMessage; + } + + if (errorMessage != null) + { + allErrorMessages.Add(string.Format("{0}:{1}", key, errorMessage)); + } + } + } + } + + return allErrorMessages; + } } public class SelfishPerson diff --git a/test/WebSites/ModelBindingWebSite/Models/Drawing.cs b/test/WebSites/ModelBindingWebSite/Models/Drawing.cs new file mode 100644 index 0000000000..d658910fd8 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Models/Drawing.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.ComponentModel.DataAnnotations; + +namespace ModelBindingWebSite +{ + public class Drawing + { + [Required] + public int Id { get; set; } + + [Required] + public List Lines { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Models/Line.cs b/test/WebSites/ModelBindingWebSite/Models/Line.cs new file mode 100644 index 0000000000..f92731c8fa --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Models/Line.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.ComponentModel.DataAnnotations; + +namespace ModelBindingWebSite +{ + public struct Line + { + [Required] + public Point Start { get; set; } + + [Required] + public Point End { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Models/Point.cs b/test/WebSites/ModelBindingWebSite/Models/Point.cs new file mode 100644 index 0000000000..982955b79c --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Models/Point.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; + +namespace ModelBindingWebSite +{ + public struct Point + { + [Required] + public int X { get; set; } + + [Required] + public int Y { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Models/Rectangle.cs b/test/WebSites/ModelBindingWebSite/Models/Rectangle.cs new file mode 100644 index 0000000000..c08da4f6c7 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Models/Rectangle.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace ModelBindingWebSite +{ + public class Rectangle : Drawing + { + + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Startup.cs b/test/WebSites/ModelBindingWebSite/Startup.cs index 51a0b3a3db..aa4839714a 100644 --- a/test/WebSites/ModelBindingWebSite/Startup.cs +++ b/test/WebSites/ModelBindingWebSite/Startup.cs @@ -29,6 +29,8 @@ namespace ModelBindingWebSite m.MaxModelValidationErrors = 8; m.ModelBinders.Insert(0, typeof(TestBindingSourceModelBinder)); + m.InputFormatters.InstanceOf().CaptureDeserilizationErrors = true; + m.AddXmlDataContractSerializerFormatter(); m.ValidationExcludeFilters.Add(typeof(Address)); });