diff --git a/samples/MvcSample.Web/project.json b/samples/MvcSample.Web/project.json index 4ad41a1f37..12b55d5131 100644 --- a/samples/MvcSample.Web/project.json +++ b/samples/MvcSample.Web/project.json @@ -15,6 +15,7 @@ "Microsoft.AspNet.Server.IIS": "1.0.0-*", "Microsoft.AspNet.Server.WebListener": "1.0.0-*", "Microsoft.AspNet.StaticFiles": "1.0.0-*", + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.NotNullAttribute.Internal": { "type": "build", "version": "1.0.0-*" } }, "frameworks": { diff --git a/samples/TagHelperSample.Web/project.json b/samples/TagHelperSample.Web/project.json index 5e9c73e3fc..34866e528e 100644 --- a/samples/TagHelperSample.Web/project.json +++ b/samples/TagHelperSample.Web/project.json @@ -14,7 +14,9 @@ "Microsoft.AspNet.Mvc.TagHelpers": "6.0.0-*", "Microsoft.AspNet.Server.IIS": "1.0.0-*", "Microsoft.AspNet.Server.WebListener": "1.0.0-*", - "Microsoft.AspNet.StaticFiles": "1.0.0-*" + "Microsoft.AspNet.StaticFiles": "1.0.0-*", + "Microsoft.Framework.NotNullAttribute.Internal": { "type": "build", "version": "1.0.0-*" }, + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" } }, "frameworks": { "aspnet50": { }, diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/TypeHelper.cs b/src/Microsoft.AspNet.Mvc.Common/TypeHelper.cs similarity index 65% rename from src/Microsoft.AspNet.Mvc.Core/Internal/TypeHelper.cs rename to src/Microsoft.AspNet.Mvc.Common/TypeHelper.cs index 375bd70e99..868ad7c78b 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Internal/TypeHelper.cs +++ b/src/Microsoft.AspNet.Mvc.Common/TypeHelper.cs @@ -1,8 +1,10 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// 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.ComponentModel; using System.Reflection; using System.Threading.Tasks; using Microsoft.Framework.Internal; @@ -60,5 +62,35 @@ namespace Microsoft.AspNet.Mvc return dictionary; } + + public 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)) || + type.Equals(typeof(Uri)); + } + + public static bool HasStringConverter(Type type) + { + return TypeDescriptor.GetConverter(type).CanConvertFrom(typeof(string)); + } + + public static bool IsCollectionType(Type type) + { + if (type == typeof(string)) + { + // Even though string implements IEnumerable, we don't really think of it + // as a collection for the purposes of model binding. + return false; + } + + // We only need to look for IEnumerable, because IEnumerable extends it. + return typeof(IEnumerable).IsAssignableFrom(type); + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Common/project.json b/src/Microsoft.AspNet.Mvc.Common/project.json index c5c9f9ec05..f3c6da30fc 100644 --- a/src/Microsoft.AspNet.Mvc.Common/project.json +++ b/src/Microsoft.AspNet.Mvc.Common/project.json @@ -8,6 +8,7 @@ "aspnet50": { }, "aspnetcore50": { "dependencies": { + "System.ComponentModel.TypeConverter": "4.0.0-beta-*", "System.Reflection.Extensions": "4.0.0-beta-*", "System.Text.Encoding.Extensions": "4.0.10-beta-*" } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs deleted file mode 100644 index 20abd96240..0000000000 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs +++ /dev/null @@ -1,43 +0,0 @@ -// 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.ComponentModel; -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)); - } - - internal static bool HasStringConverter(Type type) - { - return TypeDescriptor.GetConverter(type).CanConvertFrom(typeof(string)); - } - - internal static bool IsCollectionType(Type type) - { - if (type == typeof(string)) - { - // Even though string implements IEnumerable, we don't really think of it - // as a collection for the purposes of model binding. - return false; - } - - // We only need to look for IEnumerable, because IEnumerable extends it. - return typeof(IEnumerable).IsAssignableFrom(type); - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/SimpleTypesExcludeFilter.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/SimpleTypesExcludeFilter.cs index 9d86aa5d19..84e31037ce 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/SimpleTypesExcludeFilter.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/SimpleTypesExcludeFilter.cs @@ -54,16 +54,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding /// protected virtual bool IsSimpleType(Type type) { - var result = 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)) || - type.Equals(typeof(Uri)); - - return result; + return TypeHelper.IsSimpleType(type); } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor.Host/project.json b/src/Microsoft.AspNet.Mvc.Razor.Host/project.json index bb88a1af07..ffe04077bb 100644 --- a/src/Microsoft.AspNet.Mvc.Razor.Host/project.json +++ b/src/Microsoft.AspNet.Mvc.Razor.Host/project.json @@ -9,6 +9,7 @@ "Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" }, "Microsoft.AspNet.Razor.Runtime": "4.0.0-*", "Microsoft.Framework.Cache.Memory": "1.0.0-*", + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.NotNullAttribute.Internal": { "version": "1.0.0-*", "type": "build" } }, "frameworks": { diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/project.json b/src/Microsoft.AspNet.Mvc.TagHelpers/project.json index f497e7fad0..306ffc3695 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/project.json +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/project.json @@ -10,6 +10,7 @@ "Microsoft.Framework.Cache.Memory": "1.0.0-*", "Microsoft.Framework.FileSystemGlobbing": "1.0.0-*", "Microsoft.Framework.Logging.Interfaces": { "version": "1.0.0-*", "type": "build" }, + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.NotNullAttribute.Internal": { "version": "1.0.0-*", "type": "build" }, "System.Security.Cryptography.Hashing.Algorithms": "4.0.0-beta-*" }, diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/project.json b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/project.json index aabaa11a96..ba75510145 100644 --- a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/project.json +++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/project.json @@ -9,6 +9,7 @@ "Microsoft.AspNet.Mvc.Core": "6.0.0-*", "Microsoft.AspNet.Mvc.ModelBinding": "6.0.0-*", "Microsoft.AspNet.WebApi.Client": "5.2.2", + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.NotNullAttribute.Internal": { "version": "1.0.0-*", "type": "build" } }, "frameworks": { diff --git a/src/Microsoft.AspNet.Mvc.Xml/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Xml/Properties/Resources.Designer.cs index fb22746534..4e5d80a54e 100644 --- a/src/Microsoft.AspNet.Mvc.Xml/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Xml/Properties/Resources.Designer.cs @@ -26,6 +26,22 @@ namespace Microsoft.AspNet.Mvc.Xml return string.Format(CultureInfo.CurrentCulture, GetString("EnumerableWrapperProvider_InvalidSourceEnumerableOfT"), p0); } + /// + /// {0} does not recognize '{1}', so instead use '{2}' with '{3}' set to '{4}' for value type property '{5}' on type '{6}'. + /// + internal static string RequiredProperty_MustHaveDataMemberRequired + { + get { return GetString("RequiredProperty_MustHaveDataMemberRequired"); } + } + + /// + /// {0} does not recognize '{1}', so instead use '{2}' with '{3}' set to '{4}' for value type property '{5}' on type '{6}'. + /// + internal static string FormatRequiredProperty_MustHaveDataMemberRequired(object p0, object p1, object p2, object p3, object p4, object p5, object p6) + { + return string.Format(CultureInfo.CurrentCulture, GetString("RequiredProperty_MustHaveDataMemberRequired"), p0, p1, p2, p3, p4, p5, p6); + } + /// /// The object to be wrapped must be of type '{0}' but was of type '{1}'. /// diff --git a/src/Microsoft.AspNet.Mvc.Xml/RequiredValidationHelper.cs b/src/Microsoft.AspNet.Mvc.Xml/RequiredValidationHelper.cs new file mode 100644 index 0000000000..e3e016ca35 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Xml/RequiredValidationHelper.cs @@ -0,0 +1,212 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Runtime.Serialization; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.Framework.Internal; + +namespace Microsoft.AspNet.Mvc.Xml +{ + /// + /// Validates types having value type properties decorated with + /// but no . + /// + /// + /// supports where as the xml formatters + /// do not. Since a user's aplication can have both Json and Xml formatters, a request could be validated + /// when posted as Json but not Xml. So to prevent end users from having a false sense of security when posting + /// as Xml, we add errors to model-state to at least let the users know that there is a problem with their models. + /// + public class DataAnnotationRequiredAttributeValidation + { + // Since formatters are 'typically' registered as single instance, concurrent dictionary is used + // here to avoid duplicate errors being added for a type. + private ConcurrentDictionary>> _cachedValidationErrors + = new ConcurrentDictionary>>(); + + public void Validate([NotNull] Type modelType, [NotNull] ModelStateDictionary modelStateDictionary) + { + var visitedTypes = new HashSet(); + + // Every node maintains a dictionary of Type => Errors. + // It's a dictionary as we want to avoid adding duplicate error messages. + // Example: + // In the following case, from the perspective of type 'Store', we should not see duplicate + // errors related to type 'Address' + // public class Store + // { + // [Required] + // public int Id { get; set; } + // public Address Address { get; set; } + // } + // public class Employee + // { + // [Required] + // public int Id { get; set; } + // public Address Address { get; set; } + // } + // public class Address + // { + // [Required] + // public string Line1 { get; set; } + // [Required] + // public int Zipcode { get; set; } + // [Required] + // public string State { get; set; } + // } + var rootNodeValidationErrors = new Dictionary>(); + + Validate(modelType, visitedTypes, rootNodeValidationErrors); + + foreach (var validationError in rootNodeValidationErrors) + { + foreach (var validationErrorMessage in validationError.Value) + { + // Add error message to model state as exception to avoid + // disclosing details to end user as SerializableError sanitizes the + // model state errors having exceptions with a generic message when sending + // it to the client. + modelStateDictionary.TryAddModelError( + validationError.Key.FullName, + new InvalidOperationException(validationErrorMessage)); + } + } + } + + private void Validate( + Type modelType, + HashSet visitedTypes, + Dictionary> errors) + { + // We don't need to code special handling for KeyValuePair (for example, when the model type + // is Dictionary<,> which implements IEnumerable>) as the model + // type here would be KeyValuePair where Key and Value are public properties + // which would also be probed for Required attribute validation. + if (modelType.IsGenericType()) + { + var enumerableOfT = modelType.ExtractGenericInterface(typeof(IEnumerable<>)); + if (enumerableOfT != null) + { + modelType = enumerableOfT.GetGenericArguments()[0]; + } + } + + if (ExcludeTypeFromValidation(modelType)) + { + return; + } + + // Avoid infinite loop in case of self-referencing properties + if (!visitedTypes.Add(modelType)) + { + return; + } + + Dictionary> cachedErrors; + if (_cachedValidationErrors.TryGetValue(modelType, out cachedErrors)) + { + foreach (var validationError in cachedErrors) + { + errors.Add(validationError.Key, validationError.Value); + } + + return; + } + + foreach (var propertyHelper in PropertyHelper.GetProperties(modelType)) + { + var propertyInfo = propertyHelper.Property; + var propertyType = propertyInfo.PropertyType; + + // Since DefaultObjectValidator can handle Required attribute validation for reference types, + // we only consider value types here. + if (propertyType.IsValueType() && !propertyType.IsNullableValueType()) + { + var validationError = GetValidationError(propertyInfo); + if (validationError != null) + { + List errorMessages; + if (!errors.TryGetValue(validationError.ModelType, out errorMessages)) + { + errorMessages = new List(); + errors.Add(validationError.ModelType, errorMessages); + } + + errorMessages.Add(Resources.FormatRequiredProperty_MustHaveDataMemberRequired( + typeof(DataContractSerializer).FullName, + typeof(RequiredAttribute).FullName, + typeof(DataMemberAttribute).FullName, + nameof(DataMemberAttribute.IsRequired), + bool.TrueString, + validationError.PropertyName, + validationError.ModelType.FullName)); + } + + // if the type is not primitve, then it could be a struct in which case + // we need to probe its properties for validation + if (propertyType.GetTypeInfo().IsPrimitive) + { + continue; + } + } + + var childNodeErrors = new Dictionary>(); + Validate(propertyType, visitedTypes, childNodeErrors); + + // Avoid adding duplicate errors at current node. + foreach (var modelTypeKey in childNodeErrors.Keys) + { + if (!errors.ContainsKey(modelTypeKey)) + { + errors.Add(modelTypeKey, childNodeErrors[modelTypeKey]); + } + } + } + + _cachedValidationErrors.TryAdd(modelType, errors); + + visitedTypes.Remove(modelType); + } + + private ValidationError GetValidationError(PropertyInfo propertyInfo) + { + var required = propertyInfo.GetCustomAttribute(typeof(RequiredAttribute), inherit: true); + if (required == null) + { + return null; + } + + var dataMemberRequired = (DataMemberAttribute)propertyInfo.GetCustomAttribute( + typeof(DataMemberAttribute), + inherit: true); + + if (dataMemberRequired != null && dataMemberRequired.IsRequired) + { + return null; + } + + return new ValidationError() + { + ModelType = propertyInfo.DeclaringType, + PropertyName = propertyInfo.Name + }; + } + + private bool ExcludeTypeFromValidation(Type modelType) + { + return TypeHelper.IsSimpleType(modelType); + } + + private class ValidationError + { + public Type ModelType { get; set; } + + public string PropertyName { get; set; } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Xml/Resources.resx b/src/Microsoft.AspNet.Mvc.Xml/Resources.resx index d2c7be467a..730dd8c289 100644 --- a/src/Microsoft.AspNet.Mvc.Xml/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Xml/Resources.resx @@ -120,6 +120,9 @@ The type must be an interface and must be or derive from '{0}'. + + {0} does not recognize '{1}', so instead use '{2}' with '{3}' set to '{4}' for value type property '{5}' on type '{6}'. + The object to be wrapped must be of type '{0}' but was of type '{1}'. diff --git a/src/Microsoft.AspNet.Mvc.Xml/XmlDataContractSerializerInputFormatter.cs b/src/Microsoft.AspNet.Mvc.Xml/XmlDataContractSerializerInputFormatter.cs index ecc3ecb67e..70fff6deaa 100644 --- a/src/Microsoft.AspNet.Mvc.Xml/XmlDataContractSerializerInputFormatter.cs +++ b/src/Microsoft.AspNet.Mvc.Xml/XmlDataContractSerializerInputFormatter.cs @@ -23,7 +23,8 @@ namespace Microsoft.AspNet.Mvc.Xml private DataContractSerializerSettings _serializerSettings; private ConcurrentDictionary _serializerCache = new ConcurrentDictionary(); private readonly XmlDictionaryReaderQuotas _readerQuotas = FormattingUtilities.GetDefaultXmlReaderQuotas(); - + private readonly DataAnnotationRequiredAttributeValidation _dataAnnotationRequiredAttributeValidation; + /// /// Initializes a new instance of DataContractSerializerInputFormatter /// @@ -39,6 +40,8 @@ namespace Microsoft.AspNet.Mvc.Xml WrapperProviderFactories = new List(); WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); + + _dataAnnotationRequiredAttributeValidation = new DataAnnotationRequiredAttributeValidation(); } /// @@ -96,6 +99,10 @@ namespace Microsoft.AspNet.Mvc.Xml { var type = GetSerializableType(context.ModelType); + _dataAnnotationRequiredAttributeValidation.Validate( + type, + context.ActionContext.ModelState); + var serializer = GetCachedSerializer(type); var deserializedObject = serializer.ReadObject(xmlReader); diff --git a/src/Microsoft.AspNet.Mvc.Xml/project.json b/src/Microsoft.AspNet.Mvc.Xml/project.json index 9fa7be918f..9fdbccb532 100644 --- a/src/Microsoft.AspNet.Mvc.Xml/project.json +++ b/src/Microsoft.AspNet.Mvc.Xml/project.json @@ -7,6 +7,7 @@ "dependencies": { "Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" }, "Microsoft.AspNet.Mvc.Core": "6.0.0-*", + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.NotNullAttribute.Internal": { "version": "1.0.0-*", "type": "build" } }, "frameworks": { diff --git a/src/Microsoft.AspNet.Mvc/project.json b/src/Microsoft.AspNet.Mvc/project.json index 13ee571eb5..c58ae7af2b 100644 --- a/src/Microsoft.AspNet.Mvc/project.json +++ b/src/Microsoft.AspNet.Mvc/project.json @@ -8,6 +8,7 @@ "Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" }, "Microsoft.AspNet.Mvc.Razor": "6.0.0-*", "Microsoft.Framework.Cache.Memory": "1.0.0-*", + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.NotNullAttribute.Internal": { "version": "1.0.0-*", "type": "build" } }, "frameworks": { diff --git a/test/Microsoft.AspNet.Mvc.Common.Test/project.json b/test/Microsoft.AspNet.Mvc.Common.Test/project.json index 879667965c..3b468c3094 100644 --- a/test/Microsoft.AspNet.Mvc.Common.Test/project.json +++ b/test/Microsoft.AspNet.Mvc.Common.Test/project.json @@ -5,6 +5,7 @@ "dependencies": { "Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" }, "Microsoft.AspNet.Testing": "1.0.0-*", + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.CopyOnWriteDictionary.Internal": { "version": "1.0.0-*", "type": "build" }, "Microsoft.Framework.NotNullAttribute.Internal": { "version": "1.0.0-*", "type": "build" }, "xunit.runner.kre": "1.0.0-*", diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/XmlDataContractSerializerInputFormatterTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/XmlDataContractSerializerInputFormatterTest.cs new file mode 100644 index 0000000000..4ddda9c425 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/XmlDataContractSerializerInputFormatterTest.cs @@ -0,0 +1,119 @@ +// 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.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.Serialization; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using XmlFormattersWebSite; +using Xunit; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class XmlDataContractSerializerInputFormatterTest + { + private readonly IServiceProvider _services = TestHelper.CreateServices(nameof(XmlFormattersWebSite)); + private readonly Action _app = new Startup().Configure; + private readonly string errorMessageFormat = string.Format( + "{{1}}:{0} does not recognize '{1}', so instead use '{2}' with '{3}' set to '{4}' for value " + + "type property '{{0}}' on type '{{1}}'.", + typeof(DataContractSerializer).FullName, + typeof(RequiredAttribute).FullName, + typeof(DataMemberAttribute).FullName, + nameof(DataMemberAttribute.IsRequired), + bool.TrueString); + + // Verifies that even though all the required data is posted to an action, the model + // state has errors related to value types's Required attribute validation. + [Fact] + public async Task RequiredDataIsProvided_AndModelIsBound_AndHasRequiredAttributeValidationErrors() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml-dcs")); + var input = "
WA" + + "98052
10
"; + var content = new StringContent(input, Encoding.UTF8, "application/xml-dcs"); + var propertiesCollection = new List>(); + propertiesCollection.Add(new KeyValuePair(nameof(Store.Id), typeof(Store).FullName)); + propertiesCollection.Add(new KeyValuePair(nameof(Address.Zipcode), typeof(Address).FullName)); + var expectedErrorMessages = propertiesCollection.Select(kvp => + { + return string.Format(errorMessageFormat, kvp.Key, kvp.Value); + }); + + // Act + var response = await client.PostAsync("http://localhost/Validation/CreateStore", content); + + //Assert + var dcsSerializer = new DataContractSerializer(typeof(ModelBindingInfo)); + var responseStream = await response.Content.ReadAsStreamAsync(); + var modelBindingInfo = dcsSerializer.ReadObject(responseStream) as ModelBindingInfo; + Assert.NotNull(modelBindingInfo); + Assert.NotNull(modelBindingInfo.Store); + Assert.Equal(10, modelBindingInfo.Store.Id); + Assert.NotNull(modelBindingInfo.Store.Address); + Assert.Equal(98052, modelBindingInfo.Store.Address.Zipcode); + Assert.Equal("WA", modelBindingInfo.Store.Address.State); + Assert.NotNull(modelBindingInfo.ModelStateErrorMessages); + Assert.Equal(expectedErrorMessages.Count(), modelBindingInfo.ModelStateErrorMessages.Count); + foreach (var expectedErrorMessage in expectedErrorMessages) + { + Assert.Contains( + modelBindingInfo.ModelStateErrorMessages, + (actualErrorMessage) => actualErrorMessage.Equals(expectedErrorMessage)); + } + } + + // Verifies that the model state has errors related to body model validation(for reference types) and also for + // Required attribute validation (for value types). + [Fact] + public async Task DataMissingForReferneceTypeProperties_AndModelIsBound_AndHasMixedValidationErrors() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml-dcs")); + var input = "" + + "
10"; + var content = new StringContent(input, Encoding.UTF8, "application/xml-dcs"); + var propertiesCollection = new List>(); + propertiesCollection.Add(new KeyValuePair(nameof(Store.Id), typeof(Store).FullName)); + propertiesCollection.Add(new KeyValuePair(nameof(Address.Zipcode), typeof(Address).FullName)); + var expectedErrorMessages = propertiesCollection.Select(kvp => + { + return string.Format(errorMessageFormat, kvp.Key, kvp.Value); + }).ToList(); + expectedErrorMessages.Add("store.Address:The Address field is required."); + + // Act + var response = await client.PostAsync("http://localhost/Validation/CreateStore", content); + + //Assert + var dcsSerializer = new DataContractSerializer(typeof(ModelBindingInfo)); + var responseStream = await response.Content.ReadAsStreamAsync(); + var modelBindingInfo = dcsSerializer.ReadObject(responseStream) as ModelBindingInfo; + Assert.NotNull(modelBindingInfo); + Assert.NotNull(modelBindingInfo.Store); + Assert.Equal(10, modelBindingInfo.Store.Id); + Assert.NotNull(modelBindingInfo.ModelStateErrorMessages); + Assert.Equal(expectedErrorMessages.Count(), modelBindingInfo.ModelStateErrorMessages.Count); + foreach (var expectedErrorMessage in expectedErrorMessages) + { + Assert.Contains( + modelBindingInfo.ModelStateErrorMessages, + (actualErrorMessage) => actualErrorMessage.Equals(expectedErrorMessage)); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs b/test/Microsoft.AspNet.Mvc.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs index 78011a34c3..680e2c2a68 100644 --- a/test/Microsoft.AspNet.Mvc.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs +++ b/test/Microsoft.AspNet.Mvc.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs @@ -2,6 +2,8 @@ // 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.Linq; using System.Runtime.Serialization; @@ -51,6 +53,15 @@ namespace Microsoft.AspNet.Mvc.Xml public TestLevelOne TestOne { get; set; } } + private readonly string requiredErrorMessageFormat = string.Format( + "{0} does not recognize '{1}', so instead use '{2}' with '{3}' set to '{4}' for value type property " + + "'{{0}}' on type '{{1}}'.", + typeof(DataContractSerializer).FullName, + typeof(RequiredAttribute).FullName, + typeof(DataMemberAttribute).FullName, + nameof(DataMemberAttribute.IsRequired), + bool.TrueString); + [Theory] [InlineData("application/xml", true)] [InlineData("application/*", true)] @@ -474,6 +485,636 @@ namespace Microsoft.AspNet.Mvc.Xml Assert.Equal(expectedString, dummyModel.SampleString); } + [Fact] + public async Task PostingListOfModels_HasRequiredAttributeValidationErrors() + { + // Arrange + var input = "" + + "
true98052" + + "
"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(List
)); + + // Act + var model = await formatter.ReadAsync(context) as List
; + + // Assert + Assert.NotNull(model); + Assert.Equal(1, model.Count); + Assert.Equal(98052, model[0].Zipcode); + Assert.Equal(true, model[0].IsResidential); + + Assert.Equal(1, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName), + string.Format( + requiredErrorMessageFormat, + nameof(Address.IsResidential), + typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingModel_HasRequiredAttributeValidationErrors() + { + // Arrange + var input = "" + + "
" + + "true98052
"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(Address)); + + // Act + var model = await formatter.ReadAsync(context) as Address; + + // Assert + Assert.NotNull(model); + Assert.Equal(98052, model.Zipcode); + Assert.Equal(true, model.IsResidential); + + Assert.Equal(1, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName), + string.Format( + requiredErrorMessageFormat, + nameof(Address.IsResidential), + typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingModelWithProperty_HasRequiredAttributeValidationErrors() + { + // Arrange + var input = "" + + "" + + "true98052" + + ""; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext( + contentBytes, + typeof(ModelWithPropertyHavingRequiredAttributeValidationErrors)); + + // Act + var model = await formatter.ReadAsync(context) as ModelWithPropertyHavingRequiredAttributeValidationErrors; + + // Assert + Assert.NotNull(model); + Assert.NotNull(model.AddressProperty); + Assert.Equal(98052, model.AddressProperty.Zipcode); + Assert.Equal(true, model.AddressProperty.IsResidential); + + Assert.Equal(1, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName), + string.Format(requiredErrorMessageFormat, nameof(Address.IsResidential), typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingModel_WithCollectionProperty_HasRequiredAttributeValidationErrors() + { + // Arrange + var input = "" + + "
" + + "true98052
" + + ""; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext( + contentBytes, + typeof(ModelWithCollectionPropertyHavingRequiredAttributeValidationErrors)); + + // Act + var model = await formatter.ReadAsync(context) + as ModelWithCollectionPropertyHavingRequiredAttributeValidationErrors; + + // Assert + Assert.NotNull(model); + Assert.NotNull(model.Addresses); + Assert.Equal(98052, model.Addresses[0].Zipcode); + Assert.Equal(true, model.Addresses[0].IsResidential); + + Assert.Equal(1, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + modelStateKey: typeof(Address).FullName, + actionContext: context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName), + string.Format(requiredErrorMessageFormat, nameof(Address.IsResidential), typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingModelInheritingType_HasRequiredAttributeValidationErrors() + { + // Arrange + var input = "" + + "" + + "true98052" + + ""; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext( + contentBytes, + typeof(ModelInheritingTypeHavingRequiredAttributeValidationErrors)); + + // Act + var model = await formatter.ReadAsync(context) + as ModelInheritingTypeHavingRequiredAttributeValidationErrors; + + // Assert + Assert.NotNull(model); + Assert.Equal(98052, model.Zipcode); + Assert.Equal(true, model.IsResidential); + + Assert.Equal(1, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName), + string.Format(requiredErrorMessageFormat, nameof(Address.IsResidential), typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingModelHavingNullableValueTypes_NoRequiredAttributeValidationErrors() + { + // Arrange + var input = "" + + "" + + "200620072005"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(CarInfo)); + var expectedModel = new CarInfo() { Year = 2005, ServicedYears = new List() }; + expectedModel.ServicedYears.Add(2006); + expectedModel.ServicedYears.Add(2007); + + // Act + var model = await formatter.ReadAsync(context) as CarInfo; + + // Assert + Assert.NotNull(model); + Assert.Equal(expectedModel.Year, model.Year); + Assert.Equal(expectedModel.ServicedYears, model.ServicedYears); + Assert.Empty(context.ActionContext.ModelState); + } + + [Fact] + public async Task PostingModel_WithPropertyHavingNullableValueTypes_NoRequiredAttributeValidationErrors() + { + // Arrange + var input = "" + + "" + + "2006" + + "20072005" + + ""; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext( + contentBytes, + typeof(ModelWithPropertyHavingTypeWithNullableProperties)); + var expectedModel = new ModelWithPropertyHavingTypeWithNullableProperties() + { + CarInfoProperty = new CarInfo() { Year = 2005, ServicedYears = new List() } + }; + + expectedModel.CarInfoProperty.ServicedYears.Add(2006); + expectedModel.CarInfoProperty.ServicedYears.Add(2007); + + // Act + var model = await formatter.ReadAsync(context) as ModelWithPropertyHavingTypeWithNullableProperties; + + // Assert + Assert.NotNull(model); + Assert.NotNull(model.CarInfoProperty); + Assert.Equal(expectedModel.CarInfoProperty.Year, model.CarInfoProperty.Year); + Assert.Equal(expectedModel.CarInfoProperty.ServicedYears, model.CarInfoProperty.ServicedYears); + Assert.Empty(context.ActionContext.ModelState); + } + + [Fact] + public async Task PostingModel_WithPropertySelfReferencingItself() + { + // Arrange + var input = "1011MikeJohn"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(Employee)); + var expectedModel = new Employee() + { + Id = 10, + Name = "John", + Manager = new Employee() + { + Id = 11, + Name = "Mike" + } + }; + + // Act + var model = await formatter.ReadAsync(context) as Employee; + + // Assert + Assert.NotNull(model); + Assert.Equal(expectedModel.Id, model.Id); + Assert.Equal(expectedModel.Name, model.Name); + Assert.NotNull(model.Manager); + Assert.Equal(expectedModel.Manager.Id, model.Manager.Id); + Assert.Equal(expectedModel.Manager.Name, model.Manager.Name); + Assert.Null(model.Manager.Manager); + + Assert.Equal(1, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Employee).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Employee.Id), typeof(Employee).FullName) + }); + } + + [Fact] + public async Task PostingModel_WithBothRequiredAndDataMemberRequired_NoValidationErrors() + { + // Arrange + var input = "" + + "" + + "10true"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(Laptop)); + + // Act + var model = await formatter.ReadAsync(context) as Laptop; + + // Assert + Assert.NotNull(model); + Assert.Equal(10, model.Id); + Assert.Equal(true, model.SupportsVirtualization); + Assert.Empty(context.ActionContext.ModelState); + } + + [Fact] + public async Task PostingListofModels_WithBothRequiredAndDataMemberRequired_NoValidationErrors() + { + // Arrange + var input = "" + + "" + + "10true"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(List)); + + // Act + var model = await formatter.ReadAsync(context) as List; + + // Assert + Assert.NotNull(model); + Assert.Equal(1, model.Count); + Assert.Equal(10, model[0].Id); + Assert.Equal(true, model[0].SupportsVirtualization); + Assert.Empty(context.ActionContext.ModelState); + } + + [Fact] + public async Task PostingModel_WithRequiredAndDataMemberNoRequired_HasValidationErrors() + { + // Arrange + var input = "" + + "" + + "10Phone"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(Product)); + + // Act + var model = await formatter.ReadAsync(context) as Product; + + // Assert + Assert.NotNull(model); + Assert.Equal(10, model.Id); + + Assert.Equal(2, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Product).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Product.Id), typeof(Product).FullName) + }); + + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName), + string.Format(requiredErrorMessageFormat, nameof(Address.IsResidential), typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingListOfModels_WithRequiredAndDataMemberNoRequired_HasValidationErrors() + { + // Arrange + var input = "" + + "" + + "10Phone"; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(List)); + + // Act + var model = await formatter.ReadAsync(context) as List; + + // Assert + Assert.NotNull(model); + Assert.Equal(1, model.Count); + Assert.Equal(10, model[0].Id); + Assert.Equal("Phone", model[0].Name); + + Assert.Equal(2, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Product).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Product.Id), typeof(Product).FullName) + }); + + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName), + string.Format(requiredErrorMessageFormat, nameof(Address.IsResidential), typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingModel_WithDeeperHierarchy_HasValidationErrors() + { + // Arrange + var input = "" + + ""; + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(Store)); + + // Act + var model = await formatter.ReadAsync(context) as Store; + + // Assert + Assert.Null(model); + + Assert.Equal(3, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.IsResidential), typeof(Address).FullName), + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName) + }); + + AssertModelStateErrorMessages( + typeof(Employee).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Employee.Id), typeof(Employee).FullName) + }); + + AssertModelStateErrorMessages( + typeof(Product).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Product.Id), typeof(Product).FullName) + }); + } + + [Fact] + public async Task PostingModelOfStructs_WithDeeperHierarchy_HasValidationErrors() + { + // Arrange + var input = ""; + + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(School)); + + // Act + var model = await formatter.ReadAsync(context); + + // Assert + Assert.Null(model); + + Assert.Equal(3, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(School).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(School.Id), typeof(School).FullName) + }); + AssertModelStateErrorMessages( + typeof(Website).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Website.Id), typeof(Website).FullName) + }); + + AssertModelStateErrorMessages( + typeof(Student).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Student.Id), typeof(Student).FullName) + }); + } + + [Fact] + public async Task PostingModel_WithDictionaryProperty_HasValidationErrorsOnKeyAndValue() + { + // Arrange + var input = ""; + + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(FavoriteLocations)); + + // Act + var model = await formatter.ReadAsync(context); + + // Assert + Assert.Null(model); + + Assert.Equal(2, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Point).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Point.X), typeof(Point).FullName), + string.Format(requiredErrorMessageFormat, nameof(Point.Y), typeof(Point).FullName) + }); + AssertModelStateErrorMessages( + typeof(Address).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Address.IsResidential), typeof(Address).FullName), + string.Format(requiredErrorMessageFormat, nameof(Address.Zipcode), typeof(Address).FullName) + }); + } + + [Fact] + public async Task PostingModel_WithDifferentValueTypeProperties_HasValidationErrors() + { + // Arrange + var input = ""; + + var formatter = new XmlDataContractSerializerInputFormatter(); + var contentBytes = Encodings.UTF8EncodingWithoutBOM.GetBytes(input); + var context = GetInputFormatterContext(contentBytes, typeof(ValueTypePropertiesModel)); + + // Act + var model = await formatter.ReadAsync(context); + + // Assert + Assert.Null(model); + + Assert.Equal(3, context.ActionContext.ModelState.Keys.Count); + AssertModelStateErrorMessages( + typeof(Point).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format(requiredErrorMessageFormat, nameof(Point.X), typeof(Point).FullName), + string.Format(requiredErrorMessageFormat, nameof(Point.X), typeof(Point).FullName) + }); + AssertModelStateErrorMessages( + typeof(GpsCoordinate).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format( + requiredErrorMessageFormat, + nameof(GpsCoordinate.Latitude), + typeof(GpsCoordinate).FullName), + string.Format( + requiredErrorMessageFormat, + nameof(GpsCoordinate.Longitude), + typeof(GpsCoordinate).FullName) + }); + AssertModelStateErrorMessages( + typeof(ValueTypePropertiesModel).FullName, + context.ActionContext, + expectedErrorMessages: new[] + { + string.Format( + requiredErrorMessageFormat, + nameof(ValueTypePropertiesModel.IntProperty), + typeof(ValueTypePropertiesModel).FullName), + string.Format( + requiredErrorMessageFormat, + nameof(ValueTypePropertiesModel.DateTimeProperty), + typeof(ValueTypePropertiesModel).FullName), + string.Format( + requiredErrorMessageFormat, + nameof(ValueTypePropertiesModel.PointProperty), + typeof(ValueTypePropertiesModel).FullName), + string.Format( + requiredErrorMessageFormat, + nameof(ValueTypePropertiesModel.GpsCoordinateProperty), + typeof(ValueTypePropertiesModel).FullName) + }); + } + + private void AssertModelStateErrorMessages( + string modelStateKey, + ActionContext actionContext, + IEnumerable expectedErrorMessages) + { + ModelState modelState; + actionContext.ModelState.TryGetValue(modelStateKey, out modelState); + + Assert.NotNull(modelState); + Assert.NotEmpty(modelState.Errors); + + var actualErrorMessages = modelState.Errors.Select(error => + { + if (string.IsNullOrEmpty(error.ErrorMessage)) + { + if (error.Exception != null) + { + return error.Exception.Message; + } + } + + return error.ErrorMessage; + }); + + Assert.Equal(expectedErrorMessages.Count(), actualErrorMessages.Count()); + + if (expectedErrorMessages != null) + { + foreach (var expectedErrorMessage in expectedErrorMessages) + { + Assert.Contains(expectedErrorMessage, actualErrorMessages); + } + } + } + private InputFormatterContext GetInputFormatterContext(byte[] contentBytes, Type modelType) { var actionContext = GetActionContext(contentBytes); @@ -514,4 +1155,178 @@ namespace Microsoft.AspNet.Mvc.Xml } } } + + public class Address + { + [Required] + public int Zipcode { get; set; } + + [Required] + public bool IsResidential { get; set; } + } + + public class CarInfo + { + [Required] + public int? Year { get; set; } + + [Required] + public List ServicedYears { get; set; } + } + + public class Employee + { + [Required] + public int Id { get; set; } + + [Required] + public string Name { get; set; } + + [Required] + public Employee Manager { get; set; } + } + + [DataContract] + public class Laptop + { + [DataMember(IsRequired = true)] + [Required] + public int Id { get; set; } + + [DataMember(IsRequired = true)] + [Required] + public bool SupportsVirtualization { get; set; } + } + + [DataContract] + public class Product + { + // Here the property has DataMember but does not set the value 'IsRequired = true' + [DataMember(Name = "Id")] + [Required] + public int Id { get; set; } + + [DataMember(Name = "Name")] + [Required] + public string Name { get; set; } + + [DataMember(Name = "Manufacturer")] + [Required] + public Manufacturer Manufacturer { get; set; } + } + + public class ModelWithPropertyHavingRequiredAttributeValidationErrors + { + public Address AddressProperty { get; set; } + } + + public class ModelWithCollectionPropertyHavingRequiredAttributeValidationErrors + { + public List
Addresses { get; set; } + } + + public class ModelInheritingTypeHavingRequiredAttributeValidationErrors : Address + { + } + + public class ModelWithPropertyHavingTypeWithNullableProperties + { + public CarInfo CarInfoProperty { get; set; } + } + + public class Store + { + public StoreDetails StoreDetails { get; set; } + + public List Products { get; set; } + } + + public class StoreDetails + { + public List Employees { get; set; } + + public Address Address { get; set; } + } + + public class Manufacturer + { + public Address Address { get; set; } + } + + public struct School + { + [Required] + public int Id { get; set; } + + public List Students { get; set; } + + public Website Address { get; set; } + } + + public struct Student + { + [Required] + public int Id { get; set; } + + public Website Address { get; set; } + } + + public struct Website + { + [Required] + public int Id { get; set; } + + [Required] + public string Name { get; set; } + } + + public struct ValueTypePropertiesModel + { + [Required] + public int IntProperty { get; set; } + + [Required] + public int? NullableIntProperty { get; set; } + + [Required] + public DateTime DateTimeProperty { get; set; } + + [Required] + public DateTime? NullableDateTimeProperty { get; set; } + + [Required] + public Point PointProperty { get; set; } + + [Required] + public Point? NullablePointProperty { get; set; } + + [Required] + public GpsCoordinate GpsCoordinateProperty { get; set; } + + [Required] + public GpsCoordinate? NullableGpsCoordinateProperty { get; set; } + } + + public struct GpsCoordinate + { + [Required] + public Point Latitude { get; set; } + + [Required] + public Point Longitude { get; set; } + } + + public struct Point + { + [Required] + public int X { get; set; } + + [Required] + public int Y { get; set; } + } + + public class FavoriteLocations + { + public Dictionary Addresses { get; set; } + } } \ No newline at end of file diff --git a/test/WebSites/FormatterWebSite/Models/User.cs b/test/WebSites/FormatterWebSite/Models/User.cs index c594fad479..ba4f91139c 100644 --- a/test/WebSites/FormatterWebSite/Models/User.cs +++ b/test/WebSites/FormatterWebSite/Models/User.cs @@ -2,23 +2,30 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; namespace FormatterWebSite { + [DataContract] public class User { + [DataMember(IsRequired = true)] [Required, Range(1, 2000)] public int Id { get; set; } + [DataMember(IsRequired = true)] [Required, MinLength(5)] public string Name { get; set; } + [DataMember] [StringLength(15, MinimumLength = 3)] public string Alias { get; set; } + [DataMember] [RegularExpression("[0-9a-zA-Z]*")] public string Designation { get; set; } + [DataMember] public string description { get; set; } } } \ No newline at end of file diff --git a/test/WebSites/PrecompilationWebSite/project.json b/test/WebSites/PrecompilationWebSite/project.json index baa2940d45..6880a1dcd7 100644 --- a/test/WebSites/PrecompilationWebSite/project.json +++ b/test/WebSites/PrecompilationWebSite/project.json @@ -10,7 +10,9 @@ "Microsoft.AspNet.Mvc.TestConfiguration": "1.0.0", "Microsoft.AspNet.Server.IIS": "1.0.0-*", "Microsoft.AspNet.Server.WebListener": "1.0.0-*", - "Microsoft.AspNet.StaticFiles": "1.0.0-*" + "Microsoft.AspNet.StaticFiles": "1.0.0-*", + "Microsoft.Framework.NotNullAttribute.Internal": { "type": "build", "version": "1.0.0-*" }, + "Microsoft.Framework.PropertyHelper.Internal": { "version": "1.0.0-*", "type": "build" } }, "frameworks": { "aspnet50": { diff --git a/test/WebSites/XmlFormattersWebSite/Controllers/ValidationController.cs b/test/WebSites/XmlFormattersWebSite/Controllers/ValidationController.cs new file mode 100644 index 0000000000..a842afa938 --- /dev/null +++ b/test/WebSites/XmlFormattersWebSite/Controllers/ValidationController.cs @@ -0,0 +1,69 @@ +// 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 Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace XmlFormattersWebSite +{ + public class ValidationController : Controller + { + public IActionResult CreateStore([FromBody] Store store) + { + // We want to verify that 'store' is model bound and also that the + // model state has the errors we are expecting. + return new ObjectResult(new ModelBindingInfo() + { + Store = store, + ModelStateErrorMessages = GetModelStateErrorMessages(ModelState) + }); + } + + // Cannot use 'SerializableError' here as 'RequiredAttribute' validation errors are added as exceptions + // into the model state dictionary and 'SerializableError' sanitizes exceptions with generic error message. + // Since the tests need to verify the messages, we are doing the following. + private List 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 ModelBindingInfo + { + public Store Store { get; set; } + + public List ModelStateErrorMessages { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/XmlFormattersWebSite/Models/Address.cs b/test/WebSites/XmlFormattersWebSite/Models/Address.cs new file mode 100644 index 0000000000..5be15796c1 --- /dev/null +++ b/test/WebSites/XmlFormattersWebSite/Models/Address.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 XmlFormattersWebSite +{ + public class Address + { + [Required] + public string State { get; set; } + + [Required] + public int Zipcode { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/XmlFormattersWebSite/Models/Store.cs b/test/WebSites/XmlFormattersWebSite/Models/Store.cs new file mode 100644 index 0000000000..08edb1abc6 --- /dev/null +++ b/test/WebSites/XmlFormattersWebSite/Models/Store.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.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace XmlFormattersWebSite +{ + public class Store + { + [Required] + public int Id { get; set; } + + [Required] + public Address Address { get; set; } + } +} \ No newline at end of file