diff --git a/src/Microsoft.AspNet.Mvc.Core/FilterActionInvoker.cs b/src/Microsoft.AspNet.Mvc.Core/FilterActionInvoker.cs index 1e5ff1947..23b88b19b 100644 --- a/src/Microsoft.AspNet.Mvc.Core/FilterActionInvoker.cs +++ b/src/Microsoft.AspNet.Mvc.Core/FilterActionInvoker.cs @@ -17,6 +17,7 @@ namespace Microsoft.AspNet.Mvc { private readonly IActionBindingContextProvider _bindingProvider; private readonly INestedProviderManager _filterProvider; + private readonly IBodyModelValidator _modelValidator; private IFilter[] _filters; private FilterCursor _cursor; @@ -34,11 +35,13 @@ namespace Microsoft.AspNet.Mvc public FilterActionInvoker( [NotNull] ActionContext actionContext, [NotNull] IActionBindingContextProvider bindingContextProvider, - [NotNull] INestedProviderManager filterProvider) + [NotNull] INestedProviderManager filterProvider, + [NotNull] IBodyModelValidator modelValidator) { ActionContext = actionContext; _bindingProvider = bindingContextProvider; _filterProvider = filterProvider; + _modelValidator = modelValidator; } protected ActionContext ActionContext { get; private set; } @@ -234,9 +237,11 @@ namespace Microsoft.AspNet.Mvc for (var i = 0; i < parameters.Count; i++) { var parameter = parameters[i]; + var parameterType = parameter.BodyParameterInfo != null ? + parameter.BodyParameterInfo.ParameterType : parameter.ParameterBindingInfo.ParameterType; + if (parameter.BodyParameterInfo != null) { - var parameterType = parameter.BodyParameterInfo.ParameterType; var formatterContext = new InputFormatterContext(actionBindingContext.ActionContext, parameterType); var inputFormatter = actionBindingContext.InputFormatterSelector.SelectFormatter( @@ -250,15 +255,23 @@ namespace Microsoft.AspNet.Mvc else { parameterValues[parameter.Name] = await inputFormatter.ReadAsync(formatterContext); + var modelMetadata = + metadataProvider.GetMetadataForType(modelAccessor: null, modelType: parameterType); + modelMetadata.Model = parameterValues[parameter.Name]; + + // Validate the generated object + var validationContext = new ModelValidationContext(metadataProvider, + actionBindingContext.ValidatorProvider, + modelState, + modelMetadata, + containerMetadata: null); + _modelValidator.Validate(validationContext, parameter.Name); } } else { - var parameterType = parameter.ParameterBindingInfo.ParameterType; - var modelMetadata = metadataProvider.GetMetadataForType( - modelAccessor: null, - modelType: parameterType); - + var modelMetadata = + metadataProvider.GetMetadataForType(modelAccessor: null, modelType: parameterType); var modelBindingContext = new ModelBindingContext { ModelName = parameter.Name, diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs index 491c938ec..15479c4af 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs @@ -2,11 +2,10 @@ // 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.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNet.Mvc.Core; +using Microsoft.AspNet.Mvc.ModelBinding; using Microsoft.Framework.DependencyInjection; namespace Microsoft.AspNet.Mvc @@ -22,8 +21,9 @@ namespace Microsoft.AspNet.Mvc [NotNull] INestedProviderManager filterProvider, [NotNull] IControllerFactory controllerFactory, [NotNull] ReflectedActionDescriptor descriptor, - [NotNull] IInputFormattersProvider inputFormattersProvider) - : base(actionContext, bindingContextProvider, filterProvider) + [NotNull] IInputFormattersProvider inputFormattersProvider, + [NotNull] IBodyModelValidator modelValidator) + : base(actionContext, bindingContextProvider, filterProvider, modelValidator) { _descriptor = descriptor; _controllerFactory = controllerFactory; diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvokerProvider.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvokerProvider.cs index 242a4014b..d5f2af771 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvokerProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvokerProvider.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNet.Mvc.ModelBinding; using Microsoft.Framework.DependencyInjection; namespace Microsoft.AspNet.Mvc @@ -12,16 +13,19 @@ namespace Microsoft.AspNet.Mvc private readonly IActionBindingContextProvider _bindingProvider; private readonly IInputFormattersProvider _inputFormattersProvider; private readonly INestedProviderManager _filterProvider; + private readonly IBodyModelValidator _modelValidator; public ReflectedActionInvokerProvider(IControllerFactory controllerFactory, IActionBindingContextProvider bindingProvider, IInputFormattersProvider inputFormattersProvider, - INestedProviderManager filterProvider) + INestedProviderManager filterProvider, + IBodyModelValidator modelValidator) { _controllerFactory = controllerFactory; _bindingProvider = bindingProvider; _inputFormattersProvider = inputFormattersProvider; _filterProvider = filterProvider; + _modelValidator = modelValidator; } public int Order @@ -41,7 +45,8 @@ namespace Microsoft.AspNet.Mvc _filterProvider, _controllerFactory, actionDescriptor, - _inputFormattersProvider); + _inputFormattersProvider, + _modelValidator); } callNext(); diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs new file mode 100644 index 000000000..47bcdecbf --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs @@ -0,0 +1,22 @@ +// 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.Reflection; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + internal class TypeHelper + { + internal static bool IsSimpleType(Type type) + { + return type.GetTypeInfo().IsPrimitive || + type.Equals(typeof(decimal)) || + type.Equals(typeof(string)) || + type.Equals(typeof(DateTime)) || + type.Equals(typeof(Guid)) || + type.Equals(typeof(DateTimeOffset)) || + type.Equals(typeof(TimeSpan)); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/DefaultBodyModelValidator.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/DefaultBodyModelValidator.cs new file mode 100644 index 000000000..307bae208 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/DefaultBodyModelValidator.cs @@ -0,0 +1,248 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + /// + /// Recursively validate an object. + /// + public class DefaultBodyModelValidator : IBodyModelValidator + { + /// + public bool Validate([NotNull] ModelValidationContext modelValidationContext, string keyPrefix) + { + var metadata = modelValidationContext.ModelMetadata; + var validationContext = new ValidationContext() + { + ModelValidationContext = modelValidationContext, + Visited = new HashSet(ReferenceEqualityComparer.Instance), + KeyBuilders = new Stack(), + RootPrefix = keyPrefix + }; + + return ValidateNonVisitedNodeAndChildren(metadata, validationContext, validators: null); + } + + private bool ValidateNonVisitedNodeAndChildren( + ModelMetadata metadata, ValidationContext validationContext, IEnumerable validators) + { + // Recursion guard to avoid stack overflows + RuntimeHelpers.EnsureSufficientExecutionStack(); + + var isValid = true; + if (validators == null) + { + // The validators are not null in the case of validating an array. Since the validators are + // the same for all the elements of the array, we do not do GetValidators for each element, + // instead we just pass them over. See ValidateElements function. + validators = validationContext.ModelValidationContext.ValidatorProvider.GetValidators(metadata); + } + + // We don't need to recursively traverse the graph for null values + if (metadata.Model == null) + { + return ShallowValidate(metadata, validationContext, validators); + } + + // We don't need to recursively traverse the graph for types that shouldn't be validated + var modelType = metadata.Model.GetType(); + if (TypeHelper.IsSimpleType(modelType)) + { + return ShallowValidate(metadata, validationContext, validators); + } + + // Check to avoid infinite recursion. This can happen with cycles in an object graph. + if (validationContext.Visited.Contains(metadata.Model)) + { + return true; + } + + validationContext.Visited.Add(metadata.Model); + + // Validate the children first - depth-first traversal + var enumerableModel = metadata.Model as IEnumerable; + if (enumerableModel == null) + { + isValid = ValidateProperties(metadata, validationContext); + } + else + { + isValid = ValidateElements(enumerableModel, validationContext); + } + + if (isValid) + { + // Don't bother to validate this node if children failed. + isValid = ShallowValidate(metadata, validationContext, validators); + } + + // Pop the object so that it can be validated again in a different path + validationContext.Visited.Remove(metadata.Model); + + return isValid; + } + + private bool ValidateProperties(ModelMetadata metadata, ValidationContext validationContext) + { + var isValid = true; + var propertyScope = new PropertyScope(); + validationContext.KeyBuilders.Push(propertyScope); + foreach (var childMetadata in + validationContext.ModelValidationContext.MetadataProvider.GetMetadataForProperties( + metadata.Model, metadata.RealModelType)) + { + propertyScope.PropertyName = childMetadata.PropertyName; + if (!ValidateNonVisitedNodeAndChildren(childMetadata, validationContext, validators: null)) + { + isValid = false; + } + } + + validationContext.KeyBuilders.Pop(); + return isValid; + } + + private bool ValidateElements(IEnumerable model, ValidationContext validationContext) + { + var isValid = true; + var elementType = GetElementType(model.GetType()); + var elementMetadata = + validationContext.ModelValidationContext.MetadataProvider.GetMetadataForType( + modelAccessor: null, modelType: elementType); + + var elementScope = new ElementScope() { Index = 0 }; + validationContext.KeyBuilders.Push(elementScope); + var validators = validationContext.ModelValidationContext.ValidatorProvider.GetValidators(elementMetadata); + + // If there are no validators or the object is null we bail out quickly + // when there are large arrays of null, this will save a significant amount of processing + // with minimal impact to other scenarios. + var anyValidatorsDefined = validators.Any(); + + foreach (var element in model) + { + // If the element is non null, the recursive calls might find more validators. + // If it's null, then a shallow validation will be performed. + if (element != null || anyValidatorsDefined) + { + elementMetadata.Model = element; + + if (!ValidateNonVisitedNodeAndChildren(elementMetadata, validationContext, validators)) + { + isValid = false; + } + } + + elementScope.Index++; + } + + validationContext.KeyBuilders.Pop(); + return isValid; + } + + // Validates a single node (not including children) + // Returns true if validation passes successfully + private static bool ShallowValidate( + ModelMetadata metadata, + ValidationContext validationContext, + [NotNull] IEnumerable validators) + { + var isValid = true; + string modelKey = null; + + // When the are no validators we bail quickly. This saves a GetEnumerator allocation. + // In a large array (tens of thousands or more) scenario it's very significant. + var validatorsAsCollection = validators as ICollection; + if (validatorsAsCollection != null && validatorsAsCollection.Count == 0) + { + return isValid; + } + + var modelValidationContext = + new ModelValidationContext(validationContext.ModelValidationContext, metadata); + foreach (var validator in validators) + { + foreach (var error in validator.Validate(modelValidationContext)) + { + if (modelKey == null) + { + modelKey = validationContext.RootPrefix; + // This constructs the object heirarchy + // Example: prefix.Parent.Child + foreach (var keyBuilder in validationContext.KeyBuilders.Reverse()) + { + modelKey = keyBuilder.AppendTo(modelKey); + } + } + + var errorKey = ModelBindingHelper.CreatePropertyModelName(modelKey, error.MemberName); + validationContext.ModelValidationContext.ModelState.AddModelError(errorKey, error.Message); + isValid = false; + } + } + + return isValid; + } + + private static Type GetElementType(Type type) + { + Contract.Assert(typeof(IEnumerable).IsAssignableFrom(type)); + if (type.IsArray) + { + return type.GetElementType(); + } + + foreach (var implementedInterface in type.GetInterfaces()) + { + if (implementedInterface.IsGenericType() && + implementedInterface.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return implementedInterface.GetGenericArguments()[0]; + } + } + + return typeof(object); + } + + private interface IKeyBuilder + { + string AppendTo(string prefix); + } + + private class PropertyScope : IKeyBuilder + { + public string PropertyName { get; set; } + + public string AppendTo(string prefix) + { + return ModelBindingHelper.CreatePropertyModelName(prefix, PropertyName); + } + } + + private class ElementScope : IKeyBuilder + { + public int Index { get; set; } + + public string AppendTo(string prefix) + { + return ModelBindingHelper.CreateIndexModelName(prefix, Index); + } + } + + private class ValidationContext + { + public ModelValidationContext ModelValidationContext { get; set; } + public HashSet Visited { get; set; } + public Stack KeyBuilders { get; set; } + public string RootPrefix { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/IBodyModelValidator.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/IBodyModelValidator.cs new file mode 100644 index 000000000..398f39921 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/IBodyModelValidator.cs @@ -0,0 +1,22 @@ +// 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. + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + /// + /// Validates the body parameter of an action after the parameter + /// has been read by the Input Formatters. + /// + public interface IBodyModelValidator + { + /// + /// Determines whether the Model is valid + /// and adds any validation errors to the + /// + /// The validation context which contains the model, metadata + /// and the validator providers. + /// The to append to the key for any validation errors. + /// trueif the model is valid, false otherwise. + bool Validate(ModelValidationContext modelValidationContext, string keyPrefix); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/ReferenceEqualityComparer.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/ReferenceEqualityComparer.cs new file mode 100644 index 000000000..9f3fd19ed --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/ReferenceEqualityComparer.cs @@ -0,0 +1,31 @@ +// 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.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + internal class ReferenceEqualityComparer : IEqualityComparer + { + private static readonly ReferenceEqualityComparer _instance = new ReferenceEqualityComparer(); + + public static ReferenceEqualityComparer Instance + { + get + { + return _instance; + } + } + + public new bool Equals(object x, object y) + { + return ReferenceEquals(x, y); + } + + public int GetHashCode(object obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc/MvcServices.cs b/src/Microsoft.AspNet.Mvc/MvcServices.cs index 6cf1e0680..880fad09a 100644 --- a/src/Microsoft.AspNet.Mvc/MvcServices.cs +++ b/src/Microsoft.AspNet.Mvc/MvcServices.cs @@ -111,6 +111,8 @@ namespace Microsoft.AspNet.Mvc DefaultViewComponentInvokerProvider>(); yield return describe.Transient(); + yield return describe.Transient(); + yield return describe.Transient(); yield return describe.Singleton(); yield return describe.Singleton(); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/InputObjectBindingTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/InputObjectBindingTests.cs new file mode 100644 index 000000000..9d9a8bb7a --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/InputObjectBindingTests.cs @@ -0,0 +1,225 @@ +// 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; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.AspNet.Mvc.OptionDescriptors; +using Microsoft.AspNet.Routing; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.OptionsModel; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Core.Test +{ + public class InputObjectBindingTests + { + [Fact] + public async Task GetArguments_UsingInputFormatter_DeserializesWithoutErrors_WhenValidationAttributesAreAbsent() + { + // Arrange + var sampleName = "SampleName"; + var input = "" + + "" + sampleName + ""; + var modelStateDictionary = new ModelStateDictionary(); + var invoker = GetReflectedActionInvoker( + input, typeof(Person), new XmlSerializerInputFormatter(), "application/xml"); + + // Act + var result = await invoker.GetActionArguments(modelStateDictionary); + + // Assert + Assert.True(modelStateDictionary.IsValid); + Assert.Equal(0, modelStateDictionary.ErrorCount); + var model = result["foo"] as Person; + Assert.Equal(sampleName, model.Name); + } + + [Fact] + public async Task GetArguments_UsingInputFormatter_DeserializesWithValidationError() + { + // Arrange + var sampleName = "SampleName"; + var sampleUserName = "No5"; + var input = "" + + "" + sampleName + "" + sampleUserName + ""; + var modelStateDictionary = new ModelStateDictionary(); + var invoker = GetReflectedActionInvoker(input, typeof(User), new XmlSerializerInputFormatter(), "application/xml"); + + // Act + var result = await invoker.GetActionArguments(modelStateDictionary); + + // Assert + Assert.False(modelStateDictionary.IsValid); + Assert.Equal(1, modelStateDictionary.ErrorCount); + Assert.Equal( + "The field UserName must be a string or array type with a minimum length of '5'.", + Assert.Single(Assert.Single(modelStateDictionary.Values).Errors).ErrorMessage); + var model = result["foo"] as User; + Assert.Equal(sampleName, model.Name); + Assert.Equal(sampleUserName, model.UserName); + } + + [Fact] + public async Task GetArguments_UsingInputFormatter_DeserializesArrays() + { + // Arrange + var sampleFirstUser = "FirstUser"; + var sampleFirstUserName = "fuser"; + var sampleSecondUser = "SecondUser"; + var sampleSecondUserName = "suser"; + var input = "{'Users': [{Name : '" + sampleFirstUser + "', UserName: '" + sampleFirstUserName + + "'}, {Name: '" + sampleSecondUser + "', UserName: '" + sampleSecondUserName + "'}]}"; + var modelStateDictionary = new ModelStateDictionary(); + var invoker = GetReflectedActionInvoker(input, typeof(Customers), new JsonInputFormatter(), "application/xml"); + + // Act + var result = await invoker.GetActionArguments(modelStateDictionary); + + // Assert + Assert.True(modelStateDictionary.IsValid); + Assert.Equal(0, modelStateDictionary.ErrorCount); + var model = result["foo"] as Customers; + Assert.Equal(2, model.Users.Count); + Assert.Equal(sampleFirstUser, model.Users[0].Name); + Assert.Equal(sampleFirstUserName, model.Users[0].UserName); + Assert.Equal(sampleSecondUser, model.Users[1].Name); + Assert.Equal(sampleSecondUserName, model.Users[1].UserName); + } + + [Fact] + public async Task GetArguments_UsingInputFormatter_DeserializesArrays_WithErrors() + { + // Arrange + var sampleFirstUser = "FirstUser"; + var sampleFirstUserName = "fusr"; + var sampleSecondUser = "SecondUser"; + var sampleSecondUserName = "susr"; + var input = "{'Users': [{Name : '" + sampleFirstUser + "', UserName: '" + sampleFirstUserName + + "'}, {Name: '" + sampleSecondUser + "', UserName: '" + sampleSecondUserName + "'}]}"; + var modelStateDictionary = new ModelStateDictionary(); + var invoker = GetReflectedActionInvoker(input, typeof(Customers), new JsonInputFormatter(), "application/xml"); + + // Act + var result = await invoker.GetActionArguments(modelStateDictionary); + + // Assert + Assert.False(modelStateDictionary.IsValid); + Assert.Equal(2, modelStateDictionary.ErrorCount); + var model = result["foo"] as Customers; + Assert.Equal( + "The field UserName must be a string or array type with a minimum length of '5'.", + modelStateDictionary["foo.Users[0].UserName"].Errors[0].ErrorMessage); + Assert.Equal( + "The field UserName must be a string or array type with a minimum length of '5'.", + modelStateDictionary["foo.Users[1].UserName"].Errors[0].ErrorMessage); + Assert.Equal(2, model.Users.Count); + Assert.Equal(sampleFirstUser, model.Users[0].Name); + Assert.Equal(sampleFirstUserName, model.Users[0].UserName); + Assert.Equal(sampleSecondUser, model.Users[1].Name); + Assert.Equal(sampleSecondUserName, model.Users[1].UserName); + } + + private static ReflectedActionInvoker GetReflectedActionInvoker( + string input, Type parameterType, IInputFormatter selectedFormatter, string contentType) + { + var mvcOptions = new MvcOptions(); + var setup = new MvcOptionsSetup(); + + setup.Setup(mvcOptions); + var accessor = new Mock>(); + accessor.SetupGet(a => a.Options) + .Returns(mvcOptions); + var validatorProvider = new DefaultModelValidatorProviderProvider( + accessor.Object, Mock.Of(), Mock.Of()); + + Func method = x => 1; + var actionDescriptor = new ReflectedActionDescriptor + { + MethodInfo = method.Method, + Parameters = new List + { + new ParameterDescriptor + { + Name = "foo", + BodyParameterInfo = new BodyParameterInfo(parameterType) + } + } + }; + + var metadataProvider = new EmptyModelMetadataProvider(); + var actionContext = GetActionContext( + Encodings.UTF8EncodingWithoutBOM.GetBytes(input), actionDescriptor, contentType); + + var inputFormatterSelector = new Mock(); + inputFormatterSelector.Setup(a => a.SelectFormatter(It.IsAny())) + .Returns(selectedFormatter); + var bindingContext = new ActionBindingContext(actionContext, + metadataProvider, + Mock.Of(), + Mock.Of(), + inputFormatterSelector.Object, + new CompositeModelValidatorProvider(validatorProvider)); + + var actionBindingContextProvider = new Mock(); + actionBindingContextProvider.Setup(p => p.GetActionBindingContextAsync(It.IsAny())) + .Returns(Task.FromResult(bindingContext)); + + var inputFormattersProvider = new Mock(); + inputFormattersProvider.SetupGet(o => o.InputFormatters) + .Returns(new List()); + return new ReflectedActionInvoker(actionContext, + actionBindingContextProvider.Object, + Mock.Of>(), + Mock.Of(), + actionDescriptor, + inputFormattersProvider.Object, + new DefaultBodyModelValidator()); + } + + private static ActionContext GetActionContext(byte[] contentBytes, + ActionDescriptor actionDescriptor, + string contentType) + { + return new ActionContext(GetHttpContext(contentBytes, contentType), + new RouteData(), + actionDescriptor); + } + private static HttpContext GetHttpContext(byte[] contentBytes, + string contentType) + { + 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.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; + } + } + + public class Person + { + public string Name { get; set; } + } + + public class User : Person + { + [MinLength(5)] + public string UserName { get; set; } + } + + public class Customers + { + [Required] + public List Users { get; set; } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionInvokerTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionInvokerTest.cs index 121189569..8ffe2a318 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionInvokerTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionInvokerTest.cs @@ -1345,7 +1345,8 @@ namespace Microsoft.AspNet.Mvc filterProvider.Object, controllerFactory, actionDescriptor, - inputFormattersProvider.Object); + inputFormattersProvider.Object, + new DefaultBodyModelValidator()); return invoker; } @@ -1390,7 +1391,8 @@ namespace Microsoft.AspNet.Mvc Mock.Of>(), Mock.Of(), actionDescriptor, - inputFormattersProvider.Object); + inputFormattersProvider.Object, + new DefaultBodyModelValidator()); var modelStateDictionary = new ModelStateDictionary(); @@ -1449,7 +1451,8 @@ namespace Microsoft.AspNet.Mvc Mock.Of>(), Mock.Of(), actionDescriptor, - inputFormattersProvider.Object); + inputFormattersProvider.Object, + new DefaultBodyModelValidator()); var modelStateDictionary = new ModelStateDictionary(); @@ -1503,7 +1506,8 @@ namespace Microsoft.AspNet.Mvc Mock.Of>(), controllerFactory.Object, actionDescriptor, - inputFormattersProvider.Object); + inputFormattersProvider.Object, + new DefaultBodyModelValidator()); var modelStateDictionary = new ModelStateDictionary(); @@ -1570,7 +1574,8 @@ namespace Microsoft.AspNet.Mvc Mock.Of>(), controllerFactory.Object, actionDescriptor, - inputFormattersProvider.Object); + inputFormattersProvider.Object, + new DefaultBodyModelValidator()); // Act await invoker.InvokeAsync(); @@ -1630,13 +1635,15 @@ namespace Microsoft.AspNet.Mvc INestedProviderManager filterProvider, Mock controllerFactoryMock, ReflectedActionDescriptor descriptor, - IInputFormattersProvider inputFormattersProvider) : + IInputFormattersProvider inputFormattersProvider, + IBodyModelValidator bodyModelValidator) : base(actionContext, bindingContextProvider, filterProvider, controllerFactoryMock.Object, descriptor, - inputFormattersProvider) + inputFormattersProvider, + bodyModelValidator) { _factoryMock = controllerFactoryMock; } diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/InputObjectValidationTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/InputObjectValidationTests.cs new file mode 100644 index 000000000..79bf67fb3 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/InputObjectValidationTests.cs @@ -0,0 +1,74 @@ +// 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.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Xunit; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class InputObjectValidationTests + { + private readonly IServiceProvider _services = TestHelper.CreateServices("FormatterWebSite"); + private readonly Action _app = new FormatterWebSite.Startup().Configure; + + [Fact] + public async Task CheckIfObjectIsDeserializedWithoutErrors() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + var sampleId = 2; + var sampleName = "SampleUser"; + var sampleAlias = "SampleAlias"; + var sampleDesignation = "HelloWorld"; + var sampleDescription = "sample user"; + var input = "" + + "" + sampleId + + "" + sampleName + "" + sampleAlias + "" + + "" + sampleDesignation + "" + + sampleDescription + ""; + var content = new StringContent(input, Encoding.UTF8, "application/xml"); + + // Act + var response = await client.PostAsync("http://localhost/Validation/Index", content); + + //Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("User has been registerd : " + sampleName, + await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CheckIfObjectIsDeserialized_WithErrors() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + var sampleId = 0; + var sampleName = "user"; + var sampleAlias = "a"; + var sampleDesignation = "HelloWorld!"; + var sampleDescription = "sample user"; + var input = "{ Id:" + sampleId + ", Name:'" + sampleName + "', Alias:'" + sampleAlias + + "' ,Designation:'" + sampleDesignation + "', description:'" + sampleDescription + "'}"; + var content = new StringContent(input, Encoding.UTF8, "application/json"); + + // Act + var response = await client.PostAsync("http://localhost/Validation/Index", content); + + //Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("The field Id must be between 1 and 2000.," + + "The field Name must be a string or array type with a minimum length of '5'.," + + "The field Alias must be a string with a minimum length of 3 and a maximum length of 15.," + + "The field Designation must match the regular expression '[0-9a-zA-Z]*'.", + await response.Content.ReadAsStringAsync()); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/DefaultBodyModelValidatorTests.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/DefaultBodyModelValidatorTests.cs new file mode 100644 index 000000000..5e09f4b7e --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/DefaultBodyModelValidatorTests.cs @@ -0,0 +1,363 @@ +// 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. + +#if ASPNET50 +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNet.Mvc.OptionDescriptors; +using Microsoft.AspNet.Testing; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.OptionsModel; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class DefaultBodyModelValidatorTests + { + private static Person LonelyPerson; + + static DefaultBodyModelValidatorTests() + { + LonelyPerson = new Person() { Name = "Reallllllllly Long Name" }; + LonelyPerson.Friend = LonelyPerson; + } + + public static IEnumerable ValidationErrors + { + get + { + // returns an array of model, type of model and expected errors. + // Primitives + yield return new object[] { null, typeof(Person), new Dictionary() }; + yield return new object[] { 14, typeof(int), new Dictionary() }; + yield return new object[] { "foo", typeof(string), new Dictionary() }; + + // Object Traversal : make sure we can traverse the object graph without throwing + yield return new object[] { new ValueType() { Reference = "ref", Value = 256 }, typeof(ValueType), new Dictionary() }; + yield return new object[] { new ReferenceType() { Reference = "ref", Value = 256 }, typeof(ReferenceType), new Dictionary() }; + + // Classes + yield return new object[] { new Person() { Name = "Rick", Profession = "Astronaut" }, typeof(Person), new Dictionary() }; + yield return new object[] { new Person(), typeof(Person), new Dictionary() + { + { "Name", "The Name field is required." }, + { "Profession", "The Profession field is required." } + } + }; + + yield return new object[] { new Person() { Name = "Rick", Friend = new Person() }, typeof(Person), new Dictionary() + { + { "Profession", "The Profession field is required." }, + { "Friend.Name", "The Name field is required." }, + { "Friend.Profession", "The Profession field is required." } + } + }; + + // Collections + yield return new object[] { new Person[] { new Person(), new Person() }, typeof(Person[]), new Dictionary() + { + { "[0].Name", "The Name field is required." }, + { "[0].Profession", "The Profession field is required." }, + { "[1].Name", "The Name field is required." }, + { "[1].Profession", "The Profession field is required." } + } + }; + + yield return new object[] { new List { new Person(), new Person() }, typeof(Person[]), new Dictionary() + { + { "[0].Name", "The Name field is required." }, + { "[0].Profession", "The Profession field is required." }, + { "[1].Name", "The Name field is required." }, + { "[1].Profession", "The Profession field is required." } + } + }; + + yield return new object[] { new Dictionary { { "Joe", new Person() } , { "Mark", new Person() } }, typeof(Dictionary), new Dictionary() + { + { "[0].Value.Name", "The Name field is required." }, + { "[0].Value.Profession", "The Profession field is required." }, + { "[1].Value.Name", "The Name field is required." }, + { "[1].Value.Profession", "The Profession field is required." } + } + }; + + // IValidatableObject's + yield return new object[] { new ValidatableModel(), typeof(ValidatableModel), new Dictionary() + { + { "", "Error1" }, + { "Property1", "Error2" }, + { "Property2", "Error3" }, + { "Property3", "Error3" } + } + }; + + yield return new object[]{ new[] { new ValidatableModel() }, typeof(ValidatableModel[]), new Dictionary() + { + { "[0]", "Error1" }, + { "[0].Property1", "Error2" }, + { "[0].Property2", "Error3" }, + { "[0].Property3", "Error3" } + } + }; + + // Nested Objects + yield return new object[] { new Org() { + Id = 1, + OrgName = "Org", + Dev = new Team + { + Id = 10, + TeamName = "HelloWorldTeam", + Lead = "SampleLeadDev", + TeamSize = 2 + }, + Test = new Team + { + Id = 11, + TeamName = "HWT", + Lead = "SampleTestLead", + TeamSize = 12 + } + }, typeof(Org), new Dictionary() + { + { "OrgName", "The field OrgName must be a string with a minimum length of 4 and a maximum length of 20." }, + { "Dev.Lead", "The field Lead must be a string or array type with a maximum length of '10'." }, + { "Dev.TeamSize", "The field TeamSize must be between 3 and 100." }, + { "Test.TeamName", "The field TeamName must be a string with a minimum length of 4 and a maximum length of 20." }, + { "Test.Lead", "The field Lead must be a string or array type with a maximum length of '10'." } + } + }; + + // Testing we don't validate fields + yield return new object[] { new VariableTest() { test = 5 }, typeof(VariableTest), new Dictionary() }; + + // Testing we don't blow up on cycles + yield return new object[] { LonelyPerson, typeof(Person), new Dictionary() + { + { "Name", "The field Name must be a string with a maximum length of 10." }, + { "Profession", "The Profession field is required." } + } + }; + } + } + + [Theory] + [ReplaceCulture] + [MemberData(nameof(ValidationErrors))] + public void ExpectedValidationErrorsRaised(object model, Type type, Dictionary expectedErrors) + { + // Arrange + var validationContext = GetModelValidationContext(model, type); + + // Act + Assert.DoesNotThrow(() => + new DefaultBodyModelValidator().Validate(validationContext, keyPrefix: string.Empty) + ); + + // Assert + var actualErrors = new Dictionary(); + foreach (var keyStatePair in validationContext.ModelState) + { + foreach (var error in keyStatePair.Value.Errors) + { + actualErrors.Add(keyStatePair.Key, error.ErrorMessage); + } + } + + Assert.Equal(expectedErrors.Count, actualErrors.Count); + foreach (var keyErrorPair in expectedErrors) + { + Assert.Contains(keyErrorPair.Key, actualErrors.Keys); + Assert.Equal(keyErrorPair.Value, actualErrors[keyErrorPair.Key]); + } + } + + // This case should be handled in a better way. + // Issue - https://github.com/aspnet/Mvc/issues/1206 tracks this. + [Fact] + [ReplaceCulture] + public void BodyValidator_Throws_IfPropertyAccessorThrows() + { + // Arrange + var validationContext = GetModelValidationContext(new Uri("/api/values", UriKind.Relative), typeof(Uri)); + + // Act & Assert + Assert.Throws( + typeof(InvalidOperationException), + () => + { + new DefaultBodyModelValidator().Validate(validationContext, keyPrefix: string.Empty); + }); + } + + [Fact] + [ReplaceCulture] + public void MultipleValidationErrorsOnSameMemberReported() + { + // Arrange + var model = new Address() { Street = "Microsoft Way" }; + var validationContext = GetModelValidationContext(model, model.GetType()); + + // Act + Assert.DoesNotThrow(() => + new DefaultBodyModelValidator().Validate(validationContext, keyPrefix: string.Empty) + ); + + // Assert + Assert.Contains("Street", validationContext.ModelState.Keys); + var streetState = validationContext.ModelState["Street"]; + Assert.Equal(2, streetState.Errors.Count); + Assert.Equal( + "The field Street must be a string with a maximum length of 5.", + streetState.Errors[0].ErrorMessage); + Assert.Equal( + "The field Street must match the regular expression 'hehehe'.", + streetState.Errors[1].ErrorMessage); + } + + [Fact] + public void Validate_DoesNotUseOverridden_GetHashCodeOrEquals() + { + // Arrange + var instance = new[] { + new TypeThatOverridesEquals { Funny = "hehe" }, + new TypeThatOverridesEquals { Funny = "hehe" } + }; + var validationContext = GetModelValidationContext(instance, typeof(TypeThatOverridesEquals[])); + + // Act & Assert + Assert.DoesNotThrow( + () => new DefaultBodyModelValidator().Validate(validationContext, keyPrefix: string.Empty)); + } + + private ModelValidationContext GetModelValidationContext(object model, Type type) + { + var modelStateDictionary = new ModelStateDictionary(); + var mvcOptions = new MvcOptions(); + var setup = new MvcOptionsSetup(); + setup.Setup(mvcOptions); + var accessor = new Mock>(); + accessor.SetupGet(a => a.Options) + .Returns(mvcOptions); + var modelMetadataProvider = new EmptyModelMetadataProvider(); + return new ModelValidationContext( + modelMetadataProvider, + new CompositeModelValidatorProvider( + new DefaultModelValidatorProviderProvider( + accessor.Object, Mock.Of(), + Mock.Of())), + modelStateDictionary, + new ModelMetadata( + provider: modelMetadataProvider, + containerType: typeof(object), + modelAccessor: () => model, + modelType: type, + propertyName: null), + containerMetadata: null); + } + + public class Person + { + [Required, StringLength(10)] + public string Name { get; set; } + + [Required] + public string Profession { get; set; } + + public Person Friend { get; set; } + } + + public class Address + { + [StringLength(5)] + [RegularExpression("hehehe")] + public string Street { get; set; } + } + + public struct ValueType + { + public int Value; + public string Reference; + } + + public class ReferenceType + { + public static string StaticProperty { get { return "static"; } } + public int Value; + public string Reference; + } + + public class Pet + { + [Required] + public Person Owner { get; set; } + } + + public class ValidatableModel : IValidatableObject + { + public IEnumerable Validate(ValidationContext validationContext) + { + yield return new ValidationResult("Error1", new string[] { }); + yield return new ValidationResult("Error2", new[] { "Property1" }); + yield return new ValidationResult("Error3", new[] { "Property2", "Property3" }); + } + } + + public class TypeThatOverridesEquals + { + [StringLength(2)] + public string Funny { get; set; } + + public override bool Equals(object obj) + { + throw new InvalidOperationException(); + } + + public override int GetHashCode() + { + throw new InvalidOperationException(); + } + } + + public class VariableTest + { + [Range(15, 25)] + public int test; + } + + public class Team + { + [Required] + public int Id { get; set; } + + [Required] + [StringLength(20, MinimumLength = 4)] + public string TeamName { get; set; } + + [MaxLength(10)] + public string Lead { get; set; } + + [Range(3, 100)] + public int TeamSize { get; set; } + + public string TeamDescription { get; set; } + } + + public class Org + { + [Required] + public int Id { get; set; } + + [StringLength(20, MinimumLength = 4)] + public string OrgName { get; set; } + + [Required] + public Team Dev { get; set; } + + public Team Test { get; set; } + } + } +} +#endif \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ReferenceEqualityComparerTests.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ReferenceEqualityComparerTests.cs new file mode 100644 index 000000000..3c89a16c6 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ReferenceEqualityComparerTests.cs @@ -0,0 +1,77 @@ +// 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 Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ReferenceEqualityComparerTest + { + [Fact] + public void Equals_ReturnsTrue_ForSameObject() + { + var o = new object(); + Assert.True(ReferenceEqualityComparer.Instance.Equals(o, o)); + } + + [Fact] + public void Equals_ReturnsFalse_ForDifferentObject() + { + var o1 = new object(); + var o2 = new object(); + + Assert.False(ReferenceEqualityComparer.Instance.Equals(o1, o2)); + } + + [Fact] + public void Equals_DoesntCall_OverriddenEqualsOnTheType() + { + var t1 = new TypeThatOverridesEquals(); + var t2 = new TypeThatOverridesEquals(); + + Assert.DoesNotThrow(() => ReferenceEqualityComparer.Instance.Equals(t1, t2)); + } + + [Fact] + public void Equals_ReturnsFalse_ValueType() + { + Assert.False(ReferenceEqualityComparer.Instance.Equals(42, 42)); + } + + [Fact] + public void Equals_NullEqualsNull() + { + var comparer = ReferenceEqualityComparer.Instance; + Assert.True(comparer.Equals(null, null)); + } + + [Fact] + public void GetHashCode_ReturnsSameValueForSameObject() + { + var o = new object(); + var comparer = ReferenceEqualityComparer.Instance; + Assert.Equal(comparer.GetHashCode(o), comparer.GetHashCode(o)); + } + + [Fact] + public void GetHashCode_DoesNotThrowForNull() + { + var comparer = ReferenceEqualityComparer.Instance; + Assert.DoesNotThrow(() => comparer.GetHashCode(null)); + } + + private class TypeThatOverridesEquals + { + public override bool Equals(object obj) + { + throw new InvalidOperationException(); + } + + public override int GetHashCode() + { + throw new InvalidOperationException(); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json index 33b0cbde9..0fbf21721 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json @@ -4,7 +4,8 @@ }, "dependencies": { "Microsoft.AspNet.Http": "1.0.0-*", - "Microsoft.AspNet.Mvc.ModelBinding": "6.0.0-*", + "Microsoft.AspNet.Mvc": "", + "Microsoft.AspNet.Mvc.ModelBinding": "", "Microsoft.AspNet.PipelineCore": "1.0.0-*", "Microsoft.AspNet.Routing": "1.0.0-*", "Microsoft.AspNet.Testing": "1.0.0-*", diff --git a/test/WebSites/FormatterWebSite/Controllers/ValidationController.cs b/test/WebSites/FormatterWebSite/Controllers/ValidationController.cs new file mode 100644 index 000000000..d3a3fe4ce --- /dev/null +++ b/test/WebSites/FormatterWebSite/Controllers/ValidationController.cs @@ -0,0 +1,24 @@ +// 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 Microsoft.AspNet.Mvc; + +namespace FormatterWebSite +{ + public class ValidationController : Controller + { + [HttpPost] + public IActionResult Index([FromBody]User user) + { + if (!ModelState.IsValid) + { + return Content(ModelState["user.Id"].Errors[0].ErrorMessage + "," + + ModelState["user.Name"].Errors[0].ErrorMessage + "," + + ModelState["user.Alias"].Errors[0].ErrorMessage + "," + + ModelState["user.Designation"].Errors[0].ErrorMessage); + } + + return Content("User has been registerd : " + user.Name); + } + } +} \ No newline at end of file diff --git a/test/WebSites/FormatterWebSite/Models/User.cs b/test/WebSites/FormatterWebSite/Models/User.cs new file mode 100644 index 000000000..c594fad47 --- /dev/null +++ b/test/WebSites/FormatterWebSite/Models/User.cs @@ -0,0 +1,24 @@ +// 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 FormatterWebSite +{ + public class User + { + [Required, Range(1, 2000)] + public int Id { get; set; } + + [Required, MinLength(5)] + public string Name { get; set; } + + [StringLength(15, MinimumLength = 3)] + public string Alias { get; set; } + + [RegularExpression("[0-9a-zA-Z]*")] + public string Designation { get; set; } + + public string description { get; set; } + } +} \ No newline at end of file