Added JsonContractResolver to validate value type properties

This commit is contained in:
Kiran Challa 2015-02-16 13:52:16 -08:00
parent c276ddaa39
commit f737083c94
11 changed files with 428 additions and 2 deletions

View File

@ -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
{
/// <summary>
/// The default <see cref="IContractResolver"/> for <see cref="JsonInputFormatter"/>.
/// It determines if a value type member has <see cref="RequiredAttribute"/> and sets the appropriate
/// JsonProperty settings.
/// </summary>
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;
}
}
}

View File

@ -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();
}
/// <summary>

View File

@ -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<string> GetModelStateErrorMessages(ModelStateDictionary modelStateDictionary)
{
var allErrorMessages = new List<string>();
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<Point> NearByLocations { get; set; }
}
}
}
#endif

View File

@ -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<KeyValuePair<string, string>>();
kvps.Add(new KeyValuePair<string, string>("Alias", "xyz"));
var content = new FormUrlEncodedContent(kvps);
// Act
var response = await client.PostAsync("http://localhost/Validation/GetDeveloperAlias", content);

View File

@ -1777,5 +1777,74 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
// Should Update all included properties.
Assert.Equal("March", user.RegisterationMonth);
}
public static TheoryData<string, string[]> ModelStateHasErrorsForValueAndReferenceTypesData
{
get
{
return new TheoryData<string, string[]>()
{
{
"{}",
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<string> 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<IEnumerable<string>>(data);
Assert.NotNull(actualModelStateErrorMessages);
Assert.Equal(expectedModelStateErrorMessages.Count(), actualModelStateErrorMessages.Count());
foreach (var expectedErrorMessage in expectedModelStateErrorMessages)
{
Assert.Contains(
actualModelStateErrorMessages,
(actualErrorMessage) => actualErrorMessage.StartsWith(expectedErrorMessage));
}
}
}
}

View File

@ -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<string> GetModelStateErrorMessages(ModelStateDictionary modelStateDictionary)
{
var allErrorMessages = new List<string>();
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

View File

@ -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<Line> Lines { get; set; }
}
}

View File

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

View File

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

View File

@ -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
{
}
}

View File

@ -29,6 +29,8 @@ namespace ModelBindingWebSite
m.MaxModelValidationErrors = 8;
m.ModelBinders.Insert(0, typeof(TestBindingSourceModelBinder));
m.InputFormatters.InstanceOf<JsonInputFormatter>().CaptureDeserilizationErrors = true;
m.AddXmlDataContractSerializerFormatter();
m.ValidationExcludeFilters.Add(typeof(Address));
});