diff --git a/src/Microsoft.AspNet.Mvc.Core/ControllerActionArgumentBinder.cs b/src/Microsoft.AspNet.Mvc.Core/ControllerActionArgumentBinder.cs index b3d854a079..9cfff8ec0a 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ControllerActionArgumentBinder.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ControllerActionArgumentBinder.cs @@ -16,14 +16,11 @@ namespace Microsoft.AspNet.Mvc /// public class DefaultControllerActionArgumentBinder : IControllerActionArgumentBinder { - private readonly IBodyModelValidator _modelValidator; private readonly IActionBindingContextProvider _bindingContextProvider; - public DefaultControllerActionArgumentBinder(IActionBindingContextProvider bindingContextProvider, - IBodyModelValidator modelValidator) + public DefaultControllerActionArgumentBinder(IActionBindingContextProvider bindingContextProvider) { _bindingContextProvider = bindingContextProvider; - _modelValidator = modelValidator; } public async Task> GetActionArgumentsAsync(ActionContext actionContext) @@ -49,8 +46,8 @@ namespace Microsoft.AspNet.Mvc } private async Task PopulateActionArgumentsAsync(IEnumerable modelMetadatas, - ActionBindingContext actionBindingContext, - IDictionary invocationInfo) + ActionBindingContext actionBindingContext, + IDictionary invocationInfo) { var bodyBoundParameterCount = modelMetadatas.Count( modelMetadata => modelMetadata.Marker is IBodyBinderMarker); @@ -61,19 +58,7 @@ namespace Microsoft.AspNet.Mvc foreach (var modelMetadata in modelMetadatas) { - var parameterType = modelMetadata.ModelType; - var modelBindingContext = new ModelBindingContext - { - ModelName = modelMetadata.PropertyName, - ModelMetadata = modelMetadata, - ModelState = actionBindingContext.ActionContext.ModelState, - ModelBinder = actionBindingContext.ModelBinder, - ValidatorProvider = actionBindingContext.ValidatorProvider, - MetadataProvider = actionBindingContext.MetadataProvider, - HttpContext = actionBindingContext.ActionContext.HttpContext, - FallbackToEmptyPrefix = true, - ValueProvider = actionBindingContext.ValueProvider, - }; + var modelBindingContext = GetModelBindingContext(modelMetadata, actionBindingContext); if (await actionBindingContext.ModelBinder.BindModelAsync(modelBindingContext)) { @@ -81,5 +66,25 @@ namespace Microsoft.AspNet.Mvc } } } + + internal static ModelBindingContext GetModelBindingContext(ModelMetadata modelMetadata, ActionBindingContext actionBindingContext) + { + var modelBindingContext = new ModelBindingContext + { + ModelName = modelMetadata.ModelName ?? modelMetadata.PropertyName, + ModelMetadata = modelMetadata, + ModelState = actionBindingContext.ActionContext.ModelState, + ModelBinder = actionBindingContext.ModelBinder, + ValidatorProvider = actionBindingContext.ValidatorProvider, + MetadataProvider = actionBindingContext.MetadataProvider, + HttpContext = actionBindingContext.ActionContext.HttpContext, + + // Fallback only if there is no explicit model name set. + FallbackToEmptyPrefix = modelMetadata.ModelName == null, + ValueProvider = actionBindingContext.ValueProvider, + }; + + return modelBindingContext; + } } } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/BindAttribute.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/BindAttribute.cs new file mode 100644 index 0000000000..0d2dc2ef29 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/BindAttribute.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// This attribute can be used on action parameters and types, to indicate model level metadata. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class BindAttribute : Attribute, IModelNameProvider, IModelPropertyBindingInfo + { + /// + /// Comma separated set of properties which are to be excluded during model binding. + /// + public string Exclude { get; set; } = string.Empty; + + /// + /// Comma separated set of properties which are to be included during model binding. + /// + public string Include { get; set; } = string.Empty; + + // This property is exposed for back compat reasons. + /// + /// Allows a user to specify a particular prefix to match during model binding. + /// + public string Prefix { get; set; } + + /// + /// Represents the model name used during model binding. + /// + string IModelNameProvider.Name + { + get + { + return Prefix; + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs index 5a88d51bd2..021ed83fe3 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs @@ -17,8 +17,27 @@ namespace Microsoft.AspNet.Mvc.ModelBinding { ModelBindingHelper.ValidateBindingContext(bindingContext); - if (!CanBindType(bindingContext.ModelType) || - !await bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName)) + if (!CanBindType(bindingContext.ModelType)) + { + return false; + } + + var topLevelObject = bindingContext.ModelMetadata.ContainerType == null; + var isThereAnExplicitAlias = bindingContext.ModelMetadata.ModelName != null; + + + // The first check is necessary because if we fallback to empty prefix, we do not want to depend + // on a value provider to provide a value for empty prefix. + var containsPrefix = (bindingContext.ModelName == string.Empty && topLevelObject) || + await bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName); + + // Always create the model if + // 1. It is a top level object and the model name is empty. + // 2. There is a value provider which can provide value for the model name. + // 3. There is an explicit alias provided by the user and it is a top level object. + // The reson we depend on explicit alias is that otherwise we want the FallToEmptyPrefix codepath + // to kick in so that empty prefix values could be bound. + if (!containsPrefix && !(isThereAnExplicitAlias && topLevelObject)) { return false; } @@ -149,6 +168,9 @@ namespace Microsoft.AspNet.Mvc.ModelBinding var validationInfo = GetPropertyValidationInfo(bindingContext); return bindingContext.ModelMetadata.Properties .Where(propertyMetadata => + IsPropertyAllowed(propertyMetadata.PropertyName, + bindingContext.ModelMetadata.IncludedProperties, + bindingContext.ModelMetadata.ExcludedProperties) && (validationInfo.RequiredProperties.Contains(propertyMetadata.PropertyName) || !validationInfo.SkipProperties.Contains(propertyMetadata.PropertyName)) && CanUpdateProperty(propertyMetadata)); @@ -349,6 +371,21 @@ namespace Microsoft.AspNet.Mvc.ModelBinding return addedError; } + private static bool IsPropertyAllowed(string propertyName, + IReadOnlyList includeProperties, + IReadOnlyList excludeProperties) + { + // We allow a property to be bound if its both in the include list AND not in the exclude list. + // An empty exclude list implies no properties are disallowed. + var includeProperty = (includeProperties != null) && + includeProperties.Contains(propertyName, StringComparer.OrdinalIgnoreCase); + + var excludeProperty = (excludeProperties != null) && + excludeProperties.Contains(propertyName, StringComparer.OrdinalIgnoreCase); + + return includeProperty && !excludeProperty; + } + internal sealed class PropertyValidationInfo { public PropertyValidationInfo() diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/IModelNameProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/IModelNameProvider.cs new file mode 100644 index 0000000000..a169073522 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/IModelNameProvider.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. + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Represents an entity which can provide model name as metadata. + /// + public interface IModelNameProvider + { + /// + /// Model name. + /// + string Name { get; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/IModelPropertyBindingInfo.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/IModelPropertyBindingInfo.cs new file mode 100644 index 0000000000..349716a0ef --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/IModelPropertyBindingInfo.cs @@ -0,0 +1,21 @@ +// 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 +{ + /// + /// Represents an entity which has binding information for a model. + /// + public interface IModelPropertyBindingInfo + { + /// + /// Comma separated set of properties which are to be excluded during model binding. + /// + string Exclude { get; } + + /// + /// Comma separated set of properties which are to be included during model binding. + /// + string Include { get; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs index 1e1508e767..20603049d4 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs @@ -85,7 +85,6 @@ namespace Microsoft.AspNet.Mvc.ModelBinding // Override for applying the prototype + modelAccess to yield the final metadata protected abstract TModelMetadata CreateMetadataFromPrototype(TModelMetadata prototype, Func modelAccessor); - private ModelMetadata GetMetadataForParameterCore(Func modelAccessor, string parameterName, ParameterInfo parameter) @@ -94,10 +93,61 @@ namespace Microsoft.AspNet.Mvc.ModelBinding CreateParameterInfo(parameter.ParameterType, parameter.GetCustomAttributes(), parameterName); - var typePrototype = GetTypeInformation(parameter.ParameterType).Prototype; + + var typeInfo = GetTypeInformation(parameter.ParameterType); + UpdateMetadataWithTypeInfo(parameterInfo.Prototype, typeInfo); + return CreateMetadataFromPrototype(parameterInfo.Prototype, modelAccessor); } + private void UpdateMetadataWithTypeInfo(ModelMetadata parameterPrototype, TypeInformation typeInfo) + { + // If both are empty + // Include everything. + // If none are empty + // Include common. + // If nothing common + // Dont include anything. + if (typeInfo.Prototype.IncludedProperties == null || typeInfo.Prototype.IncludedProperties.Count == 0) + { + if (parameterPrototype.IncludedProperties == null || parameterPrototype.IncludedProperties.Count == 0) + { + parameterPrototype.IncludedProperties = typeInfo.Properties + .Select(property => property.Key) + .ToList(); + } + } + else + { + if (parameterPrototype.IncludedProperties == null || parameterPrototype.IncludedProperties.Count == 0) + { + parameterPrototype.IncludedProperties = typeInfo.Prototype.IncludedProperties; + } + else + { + parameterPrototype.IncludedProperties = parameterPrototype.IncludedProperties + .Intersect(typeInfo.Prototype.IncludedProperties, + StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + if (typeInfo.Prototype.ExcludedProperties != null) + { + if (parameterPrototype.ExcludedProperties == null || parameterPrototype.ExcludedProperties.Count == 0) + { + parameterPrototype.ExcludedProperties = typeInfo.Prototype.ExcludedProperties; + } + else + { + parameterPrototype.ExcludedProperties = parameterPrototype.ExcludedProperties + .Union(typeInfo.Prototype.ExcludedProperties, + StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + // Ignore the ModelName specified at Type level. (This is to be compatible with MVC). + } + private IEnumerable GetMetadataForPropertiesCore(object container, Type containerType) { var typeInfo = GetTypeInformation(containerType); @@ -162,8 +212,21 @@ namespace Microsoft.AspNet.Mvc.ModelBinding properties.Add(propertyHelper.Name, CreatePropertyInformation(type, propertyHelper)); } } + info.Properties = properties; + if (info.Prototype != null) + { + // Update the included properties so that the properties are not ignored while binding. + if (info.Prototype.IncludedProperties == null || + info.Prototype.IncludedProperties.Count == 0) + { + // Mark all properties as included. + info.Prototype.IncludedProperties = + info.Properties.Select(property => property.Key).ToList(); + } + } + return info; } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs index 64171d9f57..4e6c422b82 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs @@ -34,6 +34,12 @@ namespace Microsoft.AspNet.Mvc.ModelBinding new CachedDataAnnotationsMetadataAttributes(attributes)) { Marker = attributes.OfType().FirstOrDefault(); + + var modelNameProvider = attributes.OfType().FirstOrDefault(); + ModelName = modelNameProvider?.Name; + + var bindAttribute = attributes.OfType().FirstOrDefault(); + ReadSettingsFromBindAttribute(bindAttribute); } protected override bool ComputeConvertEmptyStringToNull() @@ -265,5 +271,29 @@ namespace Microsoft.AspNet.Mvc.ModelBinding modelType.FullName, displayColumnAttribute.DisplayColumn)); } } + + private void ReadSettingsFromBindAttribute(BindAttribute bindAttribute) + { + if (bindAttribute == null) + { + return; + } + + ExcludedProperties = SplitString(bindAttribute.Exclude).ToList(); + IncludedProperties = SplitString(bindAttribute.Include).ToList(); + } + + private static IEnumerable SplitString(string original) + { + if (string.IsNullOrEmpty(original)) + { + return new string[0]; + } + + var split = original.Split(',') + .Select(piece => piece.Trim()) + .Where(trimmed => !string.IsNullOrEmpty(trimmed)); + return split; + } } } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs index e98a6c2b74..910d2bcdcc 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs @@ -56,6 +56,9 @@ namespace Microsoft.AspNet.Mvc.ModelBinding CacheKey = prototype.CacheKey; PrototypeCache = prototype.PrototypeCache; Marker = prototype.Marker; + IncludedProperties = prototype.IncludedProperties; + ExcludedProperties = prototype.ExcludedProperties; + ModelName = prototype.ModelName; _isComplexType = prototype.IsComplexType; _isComplexTypeComputed = true; } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs index 8fd70d4950..abdebfe3b5 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs @@ -43,11 +43,25 @@ namespace Microsoft.AspNet.Mvc.ModelBinding _modelAccessor = modelAccessor; _modelType = modelType; _propertyName = propertyName; - _convertEmptyStringToNull = true; _isRequired = !modelType.AllowsNullValue(); } + /// + /// Represents the name of a model if specified explicitly using . + /// + public string ModelName { get; set; } + + /// + /// Properties which are marked as Included for this model. + /// + public IReadOnlyList IncludedProperties { get; set; } + + /// + /// Properties which are marked as Excluded for this model. + /// + public IReadOnlyList ExcludedProperties { get; set; } + /// /// Gets or sets a binder marker for this model. /// diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionInvokerTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionInvokerTest.cs index 008c1240ab..a9a57548ce 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionInvokerTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionInvokerTest.cs @@ -1402,8 +1402,7 @@ namespace Microsoft.AspNet.Mvc actionDescriptor, inputFormattersProvider.Object, new DefaultControllerActionArgumentBinder( - actionBindingContextProvider.Object, - new DefaultBodyModelValidator())); + actionBindingContextProvider.Object)); // Act await invoker.InvokeAsync(); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ParameterBinding/ControllerActionArgumentBinderTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ParameterBinding/ControllerActionArgumentBinderTests.cs index 45ecefafcf..c6bde2d6cb 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ParameterBinding/ControllerActionArgumentBinderTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ParameterBinding/ControllerActionArgumentBinderTests.cs @@ -14,6 +14,103 @@ namespace Microsoft.AspNet.Mvc.Core.Test { public class ControllerActionArgumentBinderTests { + public class MySimpleModel + { + } + + [Bind(Prefix = "TypePrefix")] + public class MySimpleModelWithTypeBasedBind + { + } + + public void ParameterHasFieldPrefix([Bind(Prefix = "bar")] string foo) + { + } + + public void ParameterHasEmptyFieldPrefix([Bind(Prefix = "")] MySimpleModel foo, + [Bind(Prefix = "")] MySimpleModelWithTypeBasedBind foo1) + { + } + + public void ParameterHasPrefixAndComplexType([Bind(Prefix = "bar")] MySimpleModel foo, + [Bind(Prefix = "bar")] MySimpleModelWithTypeBasedBind foo1) + { + } + + public void ParameterHasEmptyBindAttribute([Bind] MySimpleModel foo, + [Bind] MySimpleModelWithTypeBasedBind foo1) + { + } + + [Theory] + [InlineData("ParameterHasFieldPrefix", false, "bar")] + [InlineData("ParameterHasEmptyFieldPrefix", false, "")] + [InlineData("ParameterHasPrefixAndComplexType", false, "bar")] + [InlineData("ParameterHasEmptyBindAttribute", true, "foo")] + public void GetModelBindingContext_ModelBindingContextIsSetWithModelName_ForParameters( + string actionMethodName, bool expectedFallToEmptyPrefix, string expectedModelName) + { + // Arrange + var type = typeof(ControllerActionArgumentBinderTests); + var methodInfo = type.GetMethod(actionMethodName); + var actionContext = new ActionContext(new RouteContext(Mock.Of()), + Mock.Of()); + + var metadataProvider = new DataAnnotationsModelMetadataProvider(); + var modelMetadata = metadataProvider.GetMetadataForParameter(modelAccessor: null, + methodInfo: methodInfo, + parameterName: "foo"); + + + var actionBindingContext = new ActionBindingContext(actionContext, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + // Act + var context = DefaultControllerActionArgumentBinder + .GetModelBindingContext(modelMetadata, actionBindingContext); + + // Assert + Assert.Equal(expectedFallToEmptyPrefix, context.FallbackToEmptyPrefix); + Assert.Equal(expectedModelName, context.ModelName); + } + + [Theory] + [InlineData("ParameterHasEmptyFieldPrefix", false, "")] + [InlineData("ParameterHasPrefixAndComplexType", false, "bar")] + [InlineData("ParameterHasEmptyBindAttribute", true, "foo1")] + public void GetModelBindingContext_ModelBindingContextIsNotSet_ForTypes( + string actionMethodName, bool expectedFallToEmptyPrefix, string expectedModelName) + { + // Arrange + var type = typeof(ControllerActionArgumentBinderTests); + var methodInfo = type.GetMethod(actionMethodName); + var actionContext = new ActionContext(new RouteContext(Mock.Of()), + Mock.Of()); + + var metadataProvider = new DataAnnotationsModelMetadataProvider(); + var modelMetadata = metadataProvider.GetMetadataForParameter(modelAccessor: null, + methodInfo: methodInfo, + parameterName: "foo1"); + + + var actionBindingContext = new ActionBindingContext(actionContext, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + // Act + var context = DefaultControllerActionArgumentBinder + .GetModelBindingContext(modelMetadata, actionBindingContext); + + // Assert + Assert.Equal(expectedFallToEmptyPrefix, context.FallbackToEmptyPrefix); + Assert.Equal(expectedModelName, context.ModelName); + } + [Fact] public async Task Parameters_WithMultipleFromBody_Throw() { @@ -53,7 +150,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test .Returns(Task.FromResult(bindingContext)); var invoker = new DefaultControllerActionArgumentBinder( - actionBindingContextProvider.Object, Mock.Of()); + actionBindingContextProvider.Object); // Act var ex = await Assert.ThrowsAsync( @@ -101,7 +198,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test .Returns(Task.FromResult(bindingContext)); var invoker = new DefaultControllerActionArgumentBinder( - actionBindingContextProvider.Object, Mock.Of()); + actionBindingContextProvider.Object); // Act var result = await invoker.GetActionArgumentsAsync(actionContext); @@ -153,7 +250,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test .Returns(Task.FromResult(bindingContext)); var invoker = new DefaultControllerActionArgumentBinder( - actionBindingContextProvider.Object, Mock.Of()); + actionBindingContextProvider.Object); // Act var result = await invoker.GetActionArgumentsAsync(actionContext); diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs index f1d72fe24e..93a8f9b0ad 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs @@ -235,5 +235,126 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal("The Field2 field is required.", json["model.Field2"]); Assert.Equal("The Field3 field is required.", json["model.Field3"]); } + + [Fact] + public async Task BindAttribute_AppliesAtBothParameterAndTypeLevelTogether_BlacklistedAtEitherLevelIsNotBound() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetStringAsync("http://localhost/BindAttribute/" + + "BindAtParamterLevelAndBindAtTypeLevelAreBothEvaluated_BlackListingAtEitherLevelDoesNotBind" + + "?param1.IncludedExplicitlyAtTypeLevel=someValue¶m2.ExcludedExplicitlyAtTypeLevel=someValue"); + + // Assert + var json = JsonConvert.DeserializeObject>(response); + Assert.Equal(2, json.Count); + Assert.Null(json["param1.IncludedExplicitlyAtTypeLevel"]); + Assert.Null(json["param2.ExcludedExplicitlyAtTypeLevel"]); + } + + [Fact] + public async Task BindAttribute_AppliesAtBothParameterAndTypeLevelTogether_WhitelistedAtBothLevelsIsBound() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetStringAsync("http://localhost/BindAttribute/" + + "BindAtParamterLevelAndBindAtTypeLevelAreBothEvaluated_WhiteListingAtBothLevelBinds" + + "?param1.IncludedExplicitlyAtTypeLevel=someValue¶m2.ExcludedExplicitlyAtTypeLevel=someValue"); + + // Assert + var json = JsonConvert.DeserializeObject>(response); + Assert.Equal(1, json.Count); + Assert.Equal("someValue", json["param1.IncludedExplicitlyAtTypeLevel"]); + } + + [Fact] + public async Task BindAttribute_AppliesAtBothParameterAndTypeLevelTogether_WhitelistingAtOneLevelIsNotBound() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetStringAsync("http://localhost/BindAttribute/" + + "BindAtParamterLevelAndBindAtTypeLevelAreBothEvaluated_WhiteListingAtOnlyOneLevelDoesNotBind" + + "?param1.IncludedExplicitlyAtTypeLevel=someValue¶m1.IncludedExplicitlyAtParameterLevel=someValue"); + + // Assert + var json = JsonConvert.DeserializeObject>(response); + Assert.Equal(2, json.Count); + Assert.Null(json["param1.IncludedExplicitlyAtParameterLevel"]); + Assert.Null(json["param1.IncludedExplicitlyAtTypeLevel"]); + } + + [Fact] + public async Task BindAttribute_BindsUsingParameterPrefix() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetStringAsync("http://localhost/BindAttribute/" + + "BindParameterUsingParameterPrefix" + + "?randomPrefix.Value=someValue"); + + // Assert + Assert.Equal("someValue", response); + } + + [Fact] + public async Task BindAttribute_DoesNotUseTypePrefix() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetStringAsync("http://localhost/BindAttribute/" + + "TypePrefixIsNeverUsed" + + "?param.Value=someValue"); + + // Assert + Assert.Equal("someValue", response); + } + + [Fact] + public async Task BindAttribute_FallsBackOnEmptyPrefixIfNoParameterPrefixIsProvided() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetStringAsync("http://localhost/BindAttribute/" + + "TypePrefixIsNeverUsed" + + "?Value=someValue"); + + // Assert + Assert.Equal("someValue", response); + } + + [Fact] + public async Task BindAttribute_DoesNotFallBackOnEmptyPrefixIfParameterPrefixIsProvided() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/BindAttribute/" + + "BindParameterUsingParameterPrefix" + + "?Value=someValue"); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/BindAttributeTests.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/BindAttributeTests.cs new file mode 100644 index 0000000000..674ef826eb --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/BindAttributeTests.cs @@ -0,0 +1,20 @@ +// 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 Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class BindAttributeTest + { + [Fact] + public void PrefixPropertyDefaultsToNull() + { + // Arrange + BindAttribute attr = new BindAttribute(); + + // Act & assert + Assert.Null(attr.Prefix); + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/MutableObjectModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/MutableObjectModelBinderTest.cs index 15781f6247..c4f64a3221 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/MutableObjectModelBinderTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/MutableObjectModelBinderTest.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNet.Testing; using Moq; @@ -58,6 +59,48 @@ namespace Microsoft.AspNet.Mvc.ModelBinding testableBinder.Verify(); } + [Fact] + public async Task BindModel_InitsInstance_ForEmptyModelName() + { + // Arrange + var mockValueProvider = new Mock(); + mockValueProvider.Setup(o => o.ContainsPrefixAsync(It.IsAny())) + .Returns(Task.FromResult(false)); + + var mockDtoBinder = new Mock(); + var bindingContext = new ModelBindingContext + { + ModelMetadata = GetMetadataForObject(new Person()), + ModelName = "", + ValueProvider = mockValueProvider.Object, + ModelBinder = mockDtoBinder.Object, + MetadataProvider = new DataAnnotationsModelMetadataProvider(), + ValidatorProvider = Mock.Of() + }; + + mockDtoBinder + .Setup(o => o.BindModelAsync(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + // just return the DTO unchanged + return Task.FromResult(true); + }); + + var testableBinder = new Mock { CallBase = true }; + testableBinder.Setup(o => o.EnsureModelPublic(bindingContext)).Verifiable(); + testableBinder.Setup(o => o.GetMetadataForPropertiesPublic(bindingContext)) + .Returns(new ModelMetadata[0]).Verifiable(); + + // Act + var retValue = await testableBinder.Object.BindModelAsync(bindingContext); + + // Assert + Assert.True(retValue); + Assert.IsType(bindingContext.Model); + Assert.True(bindingContext.ValidationNode.ValidateAllProperties); + testableBinder.Verify(); + } + [Fact] public void CanUpdateProperty_HasPublicSetter_ReturnsTrue() { @@ -194,7 +237,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding var bindingContext = new ModelBindingContext { ModelMetadata = GetMetadataForType(typeof(PersonWithBindExclusion)), - ValidatorProvider = Mock.Of() + ValidatorProvider = Mock.Of(), + MetadataProvider = new DataAnnotationsModelMetadataProvider() }; var testableBinder = new TestableMutableObjectModelBinder(); @@ -215,7 +259,75 @@ namespace Microsoft.AspNet.Mvc.ModelBinding var bindingContext = new ModelBindingContext { ModelMetadata = GetMetadataForType(typeof(Person)), - ValidatorProvider = Mock.Of() + ValidatorProvider = Mock.Of(), + MetadataProvider = new DataAnnotationsModelMetadataProvider() + }; + + var testableBinder = new TestableMutableObjectModelBinder(); + + // Act + var propertyMetadatas = testableBinder.GetMetadataForPropertiesPublic(bindingContext); + var returnedPropertyNames = propertyMetadatas.Select(o => o.PropertyName).ToArray(); + + // Assert + Assert.Equal(expectedPropertyNames, returnedPropertyNames); + } + + [Bind(Exclude = nameof(Excluded1) + "," + nameof(Excluded2))] + private class TypeWithExcludedPropertiesUsingBindAttribute + { + public int Excluded1 { get; set; } + + public int Excluded2 { get; set; } + + public int IncludedByDefault1 { get; set; } + public int IncludedByDefault2 { get; set; } + } + + [Fact] + public void GetMetadataForProperties_DoesNotReturn_ExcludedProperties() + { + // Arrange + var expectedPropertyNames = new[] { "IncludedByDefault1", "IncludedByDefault2" }; + var bindingContext = new ModelBindingContext + { + ModelMetadata = GetMetadataForType(typeof(TypeWithExcludedPropertiesUsingBindAttribute)), + ValidatorProvider = Mock.Of(), + MetadataProvider = new DataAnnotationsModelMetadataProvider() + }; + + var testableBinder = new TestableMutableObjectModelBinder(); + + // Act + var propertyMetadatas = testableBinder.GetMetadataForPropertiesPublic(bindingContext); + var returnedPropertyNames = propertyMetadatas.Select(o => o.PropertyName).ToArray(); + + // Assert + Assert.Equal(expectedPropertyNames, returnedPropertyNames); + } + + [Bind(Include = nameof(IncludedExplicitly1) + "," + nameof(IncludedExplicitly2))] + private class TypeWithIncludedPropertiesUsingBindAttribute + { + public int ExcludedByDefault1 { get; set; } + + public int ExcludedByDefault2 { get; set; } + + public int IncludedExplicitly1 { get; set; } + + public int IncludedExplicitly2 { get; set; } + } + + [Fact] + public void GetMetadataForProperties_ReturnsOnlyWhiteListedProperties_UsingBindAttributeInclude() + { + // Arrange + var expectedPropertyNames = new[] { "IncludedExplicitly1", "IncludedExplicitly2" }; + var bindingContext = new ModelBindingContext + { + ModelMetadata = GetMetadataForType(typeof(TypeWithIncludedPropertiesUsingBindAttribute)), + ValidatorProvider = Mock.Of(), + MetadataProvider = new DataAnnotationsModelMetadataProvider() }; var testableBinder = new TestableMutableObjectModelBinder(); @@ -812,6 +924,12 @@ namespace Microsoft.AspNet.Mvc.ModelBinding return metadataProvider.GetMetadataForType(null, t); } + private static ModelMetadata GetMetadataForParameter(MethodInfo methodInfo, string parameterName) + { + var metadataProvider = new DataAnnotationsModelMetadataProvider(); + return metadataProvider.GetMetadataForParameter(null, methodInfo, parameterName); + } + private class Person { private DateTime? _dateOfDeath; diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataProviderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataProviderTest.cs index e38375680f..ea45187b78 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataProviderTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataProviderTest.cs @@ -3,12 +3,117 @@ using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Reflection; using Xunit; namespace Microsoft.AspNet.Mvc.ModelBinding { public class CachedDataAnnotationsModelMetadataProviderTest { + [Bind(Include = nameof(IncludedAndExcludedExplicitly1) + "," + nameof(IncludedExplicitly1), + Exclude = nameof(IncludedAndExcludedExplicitly1) + "," + nameof(ExcludedExplicitly1), + Prefix = "TypePrefix")] + private class TypeWithExludedAndIncludedPropertiesUsingBindAttribute + { + public int ExcludedExplicitly1 { get; set; } + + public int IncludedAndExcludedExplicitly1 { get; set; } + + public int IncludedExplicitly1 { get; set; } + + public int NotIncludedOrExcluded { get; set; } + + public void ActionWithBindAttribute( + [Bind(Include = "Property1, Property2,IncludedAndExcludedExplicitly1", + Exclude ="Property3, Property4, IncludedAndExcludedExplicitly1", + Prefix = "ParameterPrefix")] + TypeWithExludedAndIncludedPropertiesUsingBindAttribute param) + { + } + } + + [Fact] + public void DataAnnotationsModelMetadataProvider_ReadsIncludedAndExcludedProperties_ForTypes() + { + // Arrange + var type = typeof(TypeWithExludedAndIncludedPropertiesUsingBindAttribute); + var provider = new DataAnnotationsModelMetadataProvider(); + var expectedIncludedPropertyNames = new[] { "IncludedAndExcludedExplicitly1", "IncludedExplicitly1" }; + var expectedExcludedPropertyNames = new[] { "IncludedAndExcludedExplicitly1", "ExcludedExplicitly1" }; + + // Act + var metadata = provider.GetMetadataForType(null, type); + + // Assert + Assert.Equal(expectedIncludedPropertyNames.ToList(), metadata.IncludedProperties); + Assert.Equal(expectedExcludedPropertyNames.ToList(), metadata.ExcludedProperties); + } + + [Fact] + public void ModelMetadataProvider_ReadsIncludedAndExcludedProperties_BothAtParameterAndTypeLevel_ForParameters() + { + // Arrange + var type = typeof(TypeWithExludedAndIncludedPropertiesUsingBindAttribute); + var methodInfo = type.GetMethod("ActionWithBindAttribute"); + var provider = new DataAnnotationsModelMetadataProvider(); + + // Note it does an intersection for included and a union for excluded. + var expectedIncludedPropertyNames = new[] { "IncludedAndExcludedExplicitly1" }; + var expectedExcludedPropertyNames = new[] { + "Property3", "Property4", "IncludedAndExcludedExplicitly1", "ExcludedExplicitly1" }; + + // Act + var metadata = provider.GetMetadataForParameter(null, methodInfo, "param"); + + // Assert + Assert.Equal(expectedIncludedPropertyNames.ToList(), metadata.IncludedProperties); + Assert.Equal(expectedExcludedPropertyNames.ToList(), metadata.ExcludedProperties); + } + + [Fact] + public void ModelMetadataProvider_ReadsPrefixProperty_OnlyAtParameterLevel_ForParameters() + { + // Arrange + var type = typeof(TypeWithExludedAndIncludedPropertiesUsingBindAttribute); + var methodInfo = type.GetMethod("ActionWithBindAttribute"); + var provider = new DataAnnotationsModelMetadataProvider(); + + // Act + var metadata = provider.GetMetadataForParameter(null, methodInfo, "param"); + + // Assert + Assert.Equal("ParameterPrefix", metadata.ModelName); + } + + [Fact] + public void DataAnnotationsModelMetadataProvider_ReadsModelNameProperty_ForTypes() + { + // Arrange + var type = typeof(TypeWithExludedAndIncludedPropertiesUsingBindAttribute); + var provider = new DataAnnotationsModelMetadataProvider(); + + // Act + var metadata = provider.GetMetadataForType(null, type); + + // Assert + Assert.Equal("TypePrefix", metadata.ModelName); + } + + [Fact] + public void DataAnnotationsModelMetadataProvider_ReadsModelNameProperty_ForParameters() + { + // Arrange + var type = typeof(TypeWithExludedAndIncludedPropertiesUsingBindAttribute); + var methodInfo = type.GetMethod("ActionWithBindAttribute"); + var provider = new DataAnnotationsModelMetadataProvider(); + + // Act + var metadata = provider.GetMetadataForParameter(null, methodInfo, "param"); + + // Assert + Assert.Equal("ParameterPrefix", metadata.ModelName); + } + [Fact] public void DataAnnotationsModelMetadataProvider_ReadsScaffoldColumnAttribute_ForShowForDisplay() { diff --git a/test/WebSites/ModelBindingWebSite/Controllers/BindAttributeController.cs b/test/WebSites/ModelBindingWebSite/Controllers/BindAttributeController.cs new file mode 100644 index 0000000000..ea2f9bc96e --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Controllers/BindAttributeController.cs @@ -0,0 +1,91 @@ +// 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 Microsoft.AspNet.Mvc; + +namespace ModelBindingWebSite.Controllers +{ + public class BindAttributeController : Controller + { + public Dictionary + BindAtParamterLevelAndBindAtTypeLevelAreBothEvaluated_BlackListingAtEitherLevelDoesNotBind( + [Bind(Exclude = "IncludedExplicitlyAtTypeLevel")] TypeWithIncludedPropertyAtBindAttribute param1, + [Bind(Include = "ExcludedExplicitlyAtTypeLevel")] TypeWithExcludedPropertyAtBindAttribute param2) + { + return new Dictionary() + { + // The first one should not be included because the parameter level bind attribute filters it out. + { "param1.IncludedExplicitlyAtTypeLevel", param1.IncludedExplicitlyAtTypeLevel }, + + // The second one should not be included because the type level bind attribute filters it out. + { "param2.ExcludedExplicitlyAtTypeLevel", param2.ExcludedExplicitlyAtTypeLevel }, + }; + } + + public Dictionary + BindAtParamterLevelAndBindAtTypeLevelAreBothEvaluated_WhiteListingAtBothLevelBinds( + [Bind(Include = "IncludedExplicitlyAtTypeLevel")] TypeWithIncludedPropertyAtBindAttribute param1) + { + return new Dictionary() + { + // The since this is included at both level it is bound. + { "param1.IncludedExplicitlyAtTypeLevel", param1.IncludedExplicitlyAtTypeLevel }, + }; + } + + public Dictionary + BindAtParamterLevelAndBindAtTypeLevelAreBothEvaluated_WhiteListingAtOnlyOneLevelDoesNotBind( + [Bind(Include = "IncludedExplicitlyAtParameterLevel")] + TypeWithIncludedPropertyAtParameterAndTypeUsingBindAttribute param1) + { + return new Dictionary() + { + // The since this is included at only type level it is not bound. + { "param1.IncludedExplicitlyAtParameterLevel", param1.IncludedExplicitlyAtParameterLevel }, + { "param1.IncludedExplicitlyAtTypeLevel", param1.IncludedExplicitlyAtTypeLevel }, + }; + } + + public string BindParameterUsingParameterPrefix([Bind(Prefix = "randomPrefix")] ParameterPrefix param) + { + return param.Value; + } + + // This will use param to try to bind and not the value specified at TypePrefix. + public string TypePrefixIsNeverUsed([Bind] TypePrefix param) + { + return param.Value; + } + } + + [Bind(Prefix = "TypePrefix")] + public class TypePrefix + { + public string Value { get; set; } + } + + public class ParameterPrefix + { + public string Value { get; set; } + } + + [Bind(Include = nameof(IncludedExplicitlyAtTypeLevel))] + public class TypeWithIncludedPropertyAtParameterAndTypeUsingBindAttribute + { + public string IncludedExplicitlyAtTypeLevel { get; set; } + public string IncludedExplicitlyAtParameterLevel { get; set; } + } + + [Bind(Include = nameof(IncludedExplicitlyAtTypeLevel))] + public class TypeWithIncludedPropertyAtBindAttribute + { + public string IncludedExplicitlyAtTypeLevel { get; set; } + } + + [Bind(Exclude = nameof(ExcludedExplicitlyAtTypeLevel))] + public class TypeWithExcludedPropertyAtBindAttribute + { + public string ExcludedExplicitlyAtTypeLevel { get; set; } + } +} \ No newline at end of file