From f2af66bc31f9c8272b8bf97d84456f2c583db1fe Mon Sep 17 00:00:00 2001 From: Pranav K Date: Wed, 31 Oct 2018 12:24:37 -0700 Subject: [PATCH 1/2] Cleanup InferParameterBindingInfoConvention (#8665) * Cleanup InferParameterBindingInfoConvention * Infer BindingSource for collection parameters as Body. Fixes https://github.com/aspnet/Mvc/issues/8536 * Introduce a compat switch to keep 2.1.x LTS behavior for collection parameters * Do not infer BinderModelName in InferParameterBindingInfoConvention --- .../ModelBinding/ModelMetadata.cs | 5 +- .../ApiBehaviorOptions.cs | 40 +++ .../ApiBehaviorApplicationModelProvider.cs | 22 +- .../InferParameterBindingInfoConvention.cs | 110 ++----- .../Internal/ApiBehaviorOptionsSetup.cs | 1 + ...ApiBehaviorApplicationModelProviderTest.cs | 16 +- ...InferParameterBindingInfoConventionTest.cs | 277 ++++++------------ .../ApiBehaviorTest.cs | 6 +- .../CompatibilitySwitchIntegrationTest.cs | 4 + 9 files changed, 181 insertions(+), 300 deletions(-) diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs index 8701d4f4a3..05f5f4cdfe 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs @@ -349,8 +349,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// Gets a value indicating whether is a complex type. /// /// - /// A complex type is defined as a which has a - /// that can convert from . + /// A complex type is defined as a without a that can convert + /// from . Most POCO and types are therefore complex. Most, if + /// not all, BCL value types are simple types. /// public bool IsComplexType { get; private set; } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs index b2b2c2cd6f..26a796130c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs @@ -17,6 +17,7 @@ namespace Microsoft.AspNetCore.Mvc { private readonly CompatibilitySwitch _suppressMapClientErrors; private readonly CompatibilitySwitch _suppressUseValidationProblemDetailsForInvalidModelStateResponses; + private readonly CompatibilitySwitch _allowInferringBindingSourceForCollectionTypesAsFromQuery; private readonly ICompatibilitySwitch[] _switches; private Func _invalidModelStateResponseFactory; @@ -28,10 +29,12 @@ namespace Microsoft.AspNetCore.Mvc { _suppressMapClientErrors = new CompatibilitySwitch(nameof(SuppressMapClientErrors)); _suppressUseValidationProblemDetailsForInvalidModelStateResponses = new CompatibilitySwitch(nameof(SuppressUseValidationProblemDetailsForInvalidModelStateResponses)); + _allowInferringBindingSourceForCollectionTypesAsFromQuery = new CompatibilitySwitch(nameof(AllowInferringBindingSourceForCollectionTypesAsFromQuery)); _switches = new[] { _suppressMapClientErrors, _suppressUseValidationProblemDetailsForInvalidModelStateResponses, + _allowInferringBindingSourceForCollectionTypesAsFromQuery }; } @@ -151,6 +154,43 @@ namespace Microsoft.AspNetCore.Mvc set => _suppressUseValidationProblemDetailsForInvalidModelStateResponses.Value = value; } + /// + /// Gets or sets a value that determines if for collection types + /// (). + /// + /// When , the binding source for collection types is inferred as . + /// Otherwise is inferred. + /// + /// + /// + /// The default value is if the version is + /// or later; otherwise. + /// + /// + /// + /// This property is associated with a compatibility switch and can provide a different behavior depending on + /// the configured compatibility version for the application. See for + /// guidance and examples of setting the application's compatibility version. + /// + /// + /// Configuring the desired value of the compatibility switch by calling this property's setter takes + /// precedence over the value implied by the application's . + /// + /// + /// If the application's compatibility version is set to or + /// lower then this setting will have the value unless explicitly configured. + /// + /// + /// If the application's compatibility version is set to or + /// higher then this setting will have the value unless explicitly configured. + /// + /// + public bool AllowInferringBindingSourceForCollectionTypesAsFromQuery + { + get => _allowInferringBindingSourceForCollectionTypesAsFromQuery.Value; + set => _allowInferringBindingSourceForCollectionTypesAsFromQuery.Value = value; + } + /// /// Gets a map of HTTP status codes to . Configured values /// are used to transform to an diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiBehaviorApplicationModelProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiBehaviorApplicationModelProvider.cs index 62831919de..35a415297b 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiBehaviorApplicationModelProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiBehaviorApplicationModelProvider.cs @@ -48,14 +48,15 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels var defaultErrorTypeAttribute = new ProducesErrorResponseTypeAttribute(defaultErrorType); ActionModelConventions.Add(new ApiConventionApplicationModelConvention(defaultErrorTypeAttribute)); - var inferParameterBindingInfoConvention = new InferParameterBindingInfoConvention(modelMetadataProvider) + if (!options.SuppressInferBindingSourcesForParameters) { - SuppressInferBindingSourcesForParameters = options.SuppressInferBindingSourcesForParameters - }; - ControllerModelConventions = new List - { - inferParameterBindingInfoConvention, - }; + var convention = new InferParameterBindingInfoConvention(modelMetadataProvider) + { + AllowInferringBindingSourceForCollectionTypesAsFromQuery = options.AllowInferringBindingSourceForCollectionTypesAsFromQuery, + }; + + ActionModelConventions.Add(convention); + } } /// @@ -66,8 +67,6 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels public List ActionModelConventions { get; } - public List ControllerModelConventions { get; } - public void OnProvidersExecuted(ApplicationModelProviderContext context) { } @@ -91,11 +90,6 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels convention.Apply(action); } } - - foreach (var convention in ControllerModelConventions) - { - convention.Apply(controller); - } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InferParameterBindingInfoConvention.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InferParameterBindingInfoConvention.cs index 15721bd2fa..9a3839308a 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InferParameterBindingInfoConvention.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InferParameterBindingInfoConvention.cs @@ -2,9 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Linq; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing.Template; @@ -13,13 +11,18 @@ using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc.ApplicationModels { /// - /// A that - /// - /// infers binding sources for parameters - /// for bound properties and parameters. - /// + /// An that infers for parameters. /// - public class InferParameterBindingInfoConvention : IControllerModelConvention + /// + /// The goal of this covention is to make intuitive and easy to document inferences. The rules are: + /// + /// A previously specified is never overwritten. + /// A complex type parameter () is assigned . + /// Parameter with a name that appears as a route value in ANY route template is assigned . + /// All other parameters are . + /// + /// + public class InferParameterBindingInfoConvention : IActionModelConvention { private readonly IModelMetadataProvider _modelMetadataProvider; @@ -29,41 +32,27 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels _modelMetadataProvider = modelMetadataProvider ?? throw new ArgumentNullException(nameof(modelMetadataProvider)); } - /// - /// Gets or sets a value that determines if model binding sources are inferred for action parameters on controllers is suppressed. - /// - public bool SuppressInferBindingSourcesForParameters { get; set; } + internal bool AllowInferringBindingSourceForCollectionTypesAsFromQuery { get; set; } - protected virtual bool ShouldApply(ControllerModel controller) => true; + protected virtual bool ShouldApply(ActionModel action) => true; - public void Apply(ControllerModel controller) + public void Apply(ActionModel action) { - if (controller == null) + if (action == null) { - throw new ArgumentNullException(nameof(controller)); + throw new ArgumentNullException(nameof(action)); } - if (!ShouldApply(controller)) + if (!ShouldApply(action)) { return; } - InferBoundPropertyModelPrefixes(controller); - - foreach (var action in controller.Actions) - { - InferParameterBindingSources(action); - InferParameterModelPrefixes(action); - } + InferParameterBindingSources(action); } internal void InferParameterBindingSources(ActionModel action) { - if (SuppressInferBindingSourcesForParameters) - { - return; - } - for (var i = 0; i < action.Parameters.Count; i++) { var parameter = action.Parameters[i]; @@ -95,56 +84,17 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels // Internal for unit testing. internal BindingSource InferBindingSourceForParameter(ParameterModel parameter) { + if (IsComplexTypeParameter(parameter)) + { + return BindingSource.Body; + } + if (ParameterExistsInAnyRoute(parameter.Action, parameter.ParameterName)) { return BindingSource.Path; } - var bindingSource = IsComplexTypeParameter(parameter) ? - BindingSource.Body : - BindingSource.Query; - - return bindingSource; - } - - // For any complex types that are bound from value providers, set the prefix - // to the empty prefix by default. This makes binding much more predictable - // and describable via ApiExplorer - internal void InferBoundPropertyModelPrefixes(ControllerModel controllerModel) - { - foreach (var property in controllerModel.ControllerProperties) - { - if (property.BindingInfo != null && - property.BindingInfo.BinderModelName == null && - property.BindingInfo.BindingSource != null && - !property.BindingInfo.BindingSource.IsGreedy && - !IsFormFile(property.ParameterType)) - { - var metadata = _modelMetadataProvider.GetMetadataForProperty( - controllerModel.ControllerType, - property.PropertyInfo.Name); - if (metadata.IsComplexType && !metadata.IsCollectionType) - { - property.BindingInfo.BinderModelName = string.Empty; - } - } - } - } - - internal void InferParameterModelPrefixes(ActionModel action) - { - foreach (var parameter in action.Parameters) - { - var bindingInfo = parameter.BindingInfo; - if (bindingInfo?.BindingSource != null && - bindingInfo.BinderModelName == null && - !bindingInfo.BindingSource.IsGreedy && - !IsFormFile(parameter.ParameterType) && - IsComplexTypeParameter(parameter)) - { - parameter.BindingInfo.BinderModelName = string.Empty; - } - } + return BindingSource.Query; } private bool ParameterExistsInAnyRoute(ActionModel action, string parameterName) @@ -171,13 +121,13 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels // No need for information from attributes on the parameter. Just use its type. var metadata = _modelMetadataProvider .GetMetadataForType(parameter.ParameterInfo.ParameterType); - return metadata.IsComplexType && !metadata.IsCollectionType; - } - private static bool IsFormFile(Type parameterType) - { - return typeof(IFormFile).IsAssignableFrom(parameterType) || - typeof(IEnumerable).IsAssignableFrom(parameterType); + if (AllowInferringBindingSourceForCollectionTypesAsFromQuery && metadata.IsCollectionType) + { + return false; + } + + return metadata.IsComplexType; } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs index bddaaa2478..02ae3bc30d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs @@ -35,6 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { dictionary[nameof(ApiBehaviorOptions.SuppressMapClientErrors)] = true; dictionary[nameof(ApiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses)] = true; + dictionary[nameof(ApiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery)] = true; } return dictionary; diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs index 1d00e52dc2..12c4984cf5 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs @@ -98,15 +98,8 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels { var convention = Assert.IsType(c); Assert.Equal(typeof(ProblemDetails), convention.DefaultErrorResponseType.Type); - }); - - Assert.Collection( - provider.ControllerModelConventions, - c => - { - var convention = Assert.IsType(c); - Assert.False(convention.SuppressInferBindingSourcesForParameters); - }); + }, + c => Assert.IsType(c)); } [Fact] @@ -140,14 +133,13 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels } [Fact] - public void Constructor_SetsSuppressInferBindingSourcesForParametersIsSet() + public void Constructor_DoesNotAddInferParameterBindingInfoConvention_IfSuppressInferBindingSourcesForParametersIsSet() { // Arrange var provider = GetProvider(new ApiBehaviorOptions { SuppressInferBindingSourcesForParameters = true }); // Act & Assert - var convention = Assert.Single(provider.ControllerModelConventions.OfType()); - Assert.True(convention.SuppressInferBindingSourcesForParameters); + Assert.Empty(provider.ActionModelConventions.OfType()); } [Fact] diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InferParameterBindingInfoConventionTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InferParameterBindingInfoConventionTest.cs index 5f27a66df8..8b82f3d6ad 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InferParameterBindingInfoConventionTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InferParameterBindingInfoConventionTest.cs @@ -28,7 +28,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels var action = GetActionModel(typeof(ParameterWithBindingInfo), actionName); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameterModel = Assert.Single(action.Parameters); @@ -126,38 +126,6 @@ Environment.NewLine + "int b"; }); } - [Fact] - public void InferParameterBindingSources_DoesNotInferSources_IfSuppressInferBindingSourcesForParametersIsSet() - { - // Arrange - var actionName = nameof(ParameterBindingController.ComplexTypeModelWithCancellationToken); - var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var convention = GetConvention(modelMetadataProvider); - var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); - - convention.SuppressInferBindingSourcesForParameters = true; - - // Act - convention.InferParameterBindingSources(action); - - // Assert - Assert.Collection( - action.Parameters, - parameter => - { - Assert.Equal("model", parameter.Name); - Assert.Null(parameter.BindingInfo); - }, - parameter => - { - Assert.Equal("cancellationToken", parameter.Name); - - var bindingInfo = parameter.BindingInfo; - Assert.NotNull(bindingInfo); - Assert.Equal(BindingSource.Special, bindingInfo.BindingSource); - }); - } - [Fact] public void Apply_PreservesBindingInfo_WhenInferringFor_ParameterWithModelBinder_AndExplicitName() { @@ -168,7 +136,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ModelBinderOnParameterController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -189,7 +157,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ModelBinderOnParameterController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -210,7 +178,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ModelBinderOnParameterController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -267,10 +235,10 @@ Environment.NewLine + "int b"; } [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInAbsoluteRoute() + public void InferBindingSourceForParameter_ReturnsBody_ForComplexTypeParameterThatAppearsInRoute() { // Arrange - var actionName = nameof(ParameterBindingController.AbsoluteRoute); + var actionName = nameof(ParameterBindingController.ComplexTypeInRoute); var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); var convention = GetConvention(); @@ -278,7 +246,7 @@ Environment.NewLine + "int b"; var result = convention.InferBindingSourceForParameter(parameter); // Assert - Assert.Same(BindingSource.Path, result); + Assert.Same(BindingSource.Body, result); } [Fact] @@ -472,7 +440,7 @@ Environment.NewLine + "int b"; } [Fact] - public void InferBindingSourceForParameter_ReturnsBodyForSimpleTypes() + public void InferBindingSourceForParameter_ReturnsQueryForSimpleTypes() { // Arrange var actionName = nameof(ParameterBindingController.SimpleTypeModel); @@ -486,6 +454,68 @@ Environment.NewLine + "int b"; Assert.Same(BindingSource.Query, result); } + [Fact] + public void InferBindingSourceForParameter_ReturnsBodyForCollectionOfSimpleTypes() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfSimpleTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Body, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsQueryForCollectionOfSimpleTypes_WhenAllowInferringBindingSourceForCollectionTypesAsFromQueryIsSet() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfSimpleTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + convention.AllowInferringBindingSourceForCollectionTypesAsFromQuery = true; + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Query, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsBodyForCollectionOfComplexTypes() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfComplexTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Body, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsQueryForCollectionOfComplexTypes_WhenAllowInferringBindingSourceForCollectionTypesAsFromQueryIsSet() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfComplexTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + convention.AllowInferringBindingSourceForCollectionTypesAsFromQuery = true; + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Query, result); + } + [Fact] public void PreservesBindingSourceInference_ForFromQueryParameter_WithDefaultName() { @@ -496,7 +526,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -517,7 +547,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -538,7 +568,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -558,7 +588,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -579,7 +609,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -600,7 +630,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -621,7 +651,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -642,7 +672,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -663,7 +693,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -684,7 +714,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -704,7 +734,7 @@ Environment.NewLine + "int b"; var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -727,7 +757,7 @@ Environment.NewLine + "int b"; var convention = GetConvention(); // Act - convention.Apply(action.Controller); + convention.Apply(action); // Assert var parameter = Assert.Single(action.Parameters); @@ -740,132 +770,6 @@ Environment.NewLine + "int b"; Assert.Null(bindingInfo.BinderModelName); } - [Fact] - public void InferBoundPropertyModelPrefixes_SetsModelPrefix_ForComplexTypeFromValueProvider() - { - // Arrange - var controller = GetControllerModel(typeof(ControllerWithBoundProperty)); - var convention = GetConvention(); - - // Act - convention.InferBoundPropertyModelPrefixes(controller); - - // Assert - var property = Assert.Single(controller.ControllerProperties, p => p.Name == nameof(ControllerWithBoundProperty.TestProperty)); - Assert.Equal(string.Empty, property.BindingInfo.BinderModelName); - } - - [Fact] - public void InferBoundPropertyModelPrefixes_SetsModelPrefix_ForCollectionTypeFromValueProvider() - { - // Arrange - var controller = GetControllerModel(typeof(ControllerWithBoundCollectionProperty)); - var convention = GetConvention(); - - // Act - convention.InferBoundPropertyModelPrefixes(controller); - - // Assert - var property = Assert.Single(controller.ControllerProperties); - Assert.Null(property.BindingInfo.BinderModelName); - } - - [Fact] - public void InferParameterModelPrefixes_SetsModelPrefix_ForComplexTypeFromValueProvider() - { - // Arrange - var action = GetActionModel(typeof(ControllerWithBoundProperty), nameof(ControllerWithBoundProperty.SomeAction)); - var convention = GetConvention(); - - // Act - convention.InferParameterModelPrefixes(action); - - // Assert - var parameter = Assert.Single(action.Parameters); - Assert.Equal(string.Empty, parameter.BindingInfo.BinderModelName); - } - - [Fact] - public void InferParameterModelPrefixes_DoesNotSetModelPrefix_ForFormFileParametersAnnotatedWithFromForm() - { - // Arrange - var action = GetActionModel( - typeof(ParameterBindingController), - nameof(ParameterBindingController.FromFormFormFileParameters), - TestModelMetadataProvider.CreateDefaultProvider()); - var convention = GetConvention(); - - // Act - convention.InferParameterModelPrefixes(action); - - // Assert - Assert.Collection( - action.Parameters, - parameter => - { - Assert.Equal("p1", parameter.Name); - Assert.Null(parameter.BindingInfo.BinderModelName); - }, - parameter => - { - Assert.Equal("p2", parameter.Name); - Assert.Null(parameter.BindingInfo.BinderModelName); - }, - parameter => - { - Assert.Equal("p3", parameter.Name); - Assert.Null(parameter.BindingInfo.BinderModelName); - }); - } - - [Fact] - public void InferParameterModelPrefixes_DoesNotSetModelPrefix_ForFormFileParameters() - { - // Arrange - var action = GetActionModel( - typeof(ParameterBindingController), - nameof(ParameterBindingController.FormFileParameters), - TestModelMetadataProvider.CreateDefaultProvider()); - var convention = GetConvention(); - - // Act - convention.InferParameterModelPrefixes(action); - - // Assert - Assert.Collection( - action.Parameters, - parameter => - { - Assert.Equal("p1", parameter.Name); - Assert.Null(parameter.BindingInfo.BinderModelName); - }, - parameter => - { - Assert.Equal("p2", parameter.Name); - Assert.Null(parameter.BindingInfo.BinderModelName); - }, - parameter => - { - Assert.Equal("p3", parameter.Name); - Assert.Null(parameter.BindingInfo.BinderModelName); - }); - } - - [Fact] - public void InferBoundPropertyModelPrefixes_DoesNotSetModelPrefix_ForFormFileCollectionPropertiesAnnotatedWithFromForm() - { - // Arrange - var controller = GetControllerModel(typeof(ControllerWithBoundProperty)); - var convention = GetConvention(); - - // Act - convention.InferBoundPropertyModelPrefixes(controller); - - // Assert - var parameter = Assert.Single(controller.ControllerProperties, p => p.Name == nameof(ControllerWithBoundProperty.Files)); - Assert.Null(parameter.BindingInfo.BinderModelName); - } - private static InferParameterBindingInfoConvention GetConvention( IModelMetadataProvider modelMetadataProvider = null) { @@ -887,14 +791,6 @@ Environment.NewLine + "int b"; return context; } - private static ControllerModel GetControllerModel( - Type controllerType, - IModelMetadataProvider modelMetadataProvider = null) - { - var context = GetContext(controllerType, modelMetadataProvider); - return Assert.Single(context.Result.Controllers); - } - private static ActionModel GetActionModel( Type controllerType, string actionName, @@ -930,10 +826,10 @@ Environment.NewLine + "int b"; public IActionResult OptionalRouteToken(int id) => null; [HttpDelete("delete-by-status/{status:int?}")] - public IActionResult ConstrainedRouteToken(object status) => null; + public IActionResult ConstrainedRouteToken(int status) => null; [HttpPut("/absolute-route/{status:int}")] - public IActionResult AbsoluteRoute(object status) => null; + public IActionResult ComplexTypeInRoute(object status) => null; [HttpPost("multiple/{id}")] [HttpPut("multiple/{id}")] @@ -1011,6 +907,10 @@ Environment.NewLine + "int b"; public IActionResult FromFormFormFileParameters([FromForm] IFormFile p1, [FromForm] IFormFile[] p2, [FromForm] IFormFileCollection p3) => null; public IActionResult FormFileParameters(IFormFile p1, IFormFile[] p2, IFormFileCollection p3) => null; + + public IActionResult CollectionOfSimpleTypes(IList parameter) => null; + + public IActionResult CollectionOfComplexTypes(IList parameter) => null; } [ApiController] @@ -1064,7 +964,6 @@ Environment.NewLine + "int b"; public IActionResult ParameterInMultipleRoutes(int id) => null; } - private class TestModel { } [TypeConverter(typeof(ConvertibleFromStringConverter))] diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs index c30dd30750..521db7b904 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs @@ -227,9 +227,9 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadAsAsync(); - Assert.Equal(0, result.ContactId); - Assert.Null(result.Name); - Assert.Null(result.Email); + Assert.Equal(id, result.ContactId); + Assert.Equal(name, result.Name); + Assert.Equal(email, result.Email); } [Fact] diff --git a/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs index e4020042f3..d16e2b85d9 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs @@ -57,6 +57,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.False(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); Assert.False(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); Assert.False(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.True(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); } [Fact] @@ -95,6 +96,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.False(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); Assert.False(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); Assert.False(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.True(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); } [Fact] @@ -133,6 +135,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.True(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); Assert.True(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); Assert.True(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.False(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); } [Fact] @@ -171,6 +174,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.True(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); Assert.True(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); Assert.True(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.False(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); } // This just does the minimum needed to be able to resolve these options. From a6199bbfbab05583f987bae322fb04566841aaea Mon Sep 17 00:00:00 2001 From: Doug Bunting Date: Wed, 31 Oct 2018 14:15:14 -0700 Subject: [PATCH 2/2] Add integration and functional tests of `[BindRequired]` on page properties (#8677) - #7353 --- .../Properties/AssemblyInfo.cs | 1 + .../RazorPagesWithBasePathTest.cs | 76 ++++++++++++----- .../BindPropertyIntegrationTest.cs | 82 ++++++++++++++++++- .../Pages/CustomModelTypeModel.cshtml.cs | 10 ++- 4 files changed, 145 insertions(+), 24 deletions(-) diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs index 458502d802..5f7daf43f9 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs @@ -4,4 +4,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.IntegrationTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs index 3de7a91ebe..4a11189cb2 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; using Xunit; @@ -420,14 +419,16 @@ Hello from /Pages/Shared/"; var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(await getPage.Content.ReadAsStringAsync(), ""); var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(getPage); - var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel"); - message.Content = new FormUrlEncodedContent(new Dictionary + var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel") { - ["__RequestVerificationToken"] = token, - ["ConfirmPassword"] = "", - ["Password"] = "", - ["Email"] = "" - }); + Content = new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["ConfirmPassword"] = "", + ["Password"] = "", + ["Email"] = "" + }) + }; message.Headers.TryAddWithoutValidation("Cookie", $"{cookie.Key}={cookie.Value}"); // Act @@ -443,18 +444,20 @@ Hello from /Pages/Shared/"; public async Task PageConventions_CustomizedModelCanWorkWithModelState() { // Arrange - var getPage = await Client.GetAsync("/CustomModelTypeModel"); + var getPage = await Client.GetAsync("/CustomModelTypeModel?Attempts=0"); var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(await getPage.Content.ReadAsStringAsync(), ""); var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(getPage); - var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel"); - message.Content = new FormUrlEncodedContent(new Dictionary + var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel?Attempts=3") { - ["__RequestVerificationToken"] = token, - ["Email"] = "javi@example.com", - ["Password"] = "Password.12$", - ["ConfirmPassword"] = "Password.12$", - }); + Content = new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["Email"] = "javi@example.com", + ["Password"] = "Password.12$", + ["ConfirmPassword"] = "Password.12$", + }) + }; message.Headers.TryAddWithoutValidation("Cookie", $"{cookie.Key}={cookie.Value}"); // Act @@ -465,6 +468,37 @@ Hello from /Pages/Shared/"; Assert.Equal("/", response.Headers.Location.ToString()); } + [Fact] + public async Task PageConventions_CustomizedModelCanWorkWithModelState_EnforcesBindRequired() + { + // Arrange + var getPage = await Client.GetAsync("/CustomModelTypeModel?Attempts=0"); + var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(await getPage.Content.ReadAsStringAsync(), ""); + var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(getPage); + + var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["Email"] = "javi@example.com", + ["Password"] = "Password.12$", + ["ConfirmPassword"] = "Password.12$", + }) + }; + message.Headers.TryAddWithoutValidation("Cookie", $"{cookie.Key}={cookie.Value}"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseText = await response.Content.ReadAsStringAsync(); + Assert.Contains( + "A value for the 'Attempts' parameter or property was not provided.", + responseText); + } + [Fact] public async Task ValidationAttributes_OnTopLevelProperties() { @@ -642,10 +676,12 @@ Hello from /Pages/Shared/"; var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(response); - var content = new MultipartFormDataContent(); - content.Add(new StringContent("property1-value"), property1); - content.Add(new StringContent("test-value1"), file1, "test1.txt"); - content.Add(new StringContent("test-value2"), file3, "test2.txt"); + var content = new MultipartFormDataContent + { + { new StringContent("property1-value"), property1 }, + { new StringContent("test-value1"), file1, "test1.txt" }, + { new StringContent("test-value2"), file3, "test2.txt" } + }; var request = new HttpRequestMessage(HttpMethod.Post, url) { diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs index 1b42c219f0..d054633f72 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs @@ -1,15 +1,17 @@ // Copyright (c) .NET Foundation. 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; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.Primitives; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Xunit; namespace Microsoft.AspNetCore.Mvc.IntegrationTests @@ -179,6 +181,74 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } } + [Theory] + [InlineData(null, false)] + [InlineData(123, true)] + public async Task BindModelAsync_WithBindPageProperty_EnforcesBindRequired(int? input, bool isValid) + { + // Arrange + var propertyInfo = typeof(TestPage).GetProperty(nameof(TestPage.BindRequiredProperty)); + var propertyDescriptor = new PageBoundPropertyDescriptor + { + BindingInfo = BindingInfo.GetBindingInfo(new[] + { + new FromQueryAttribute { Name = propertyInfo.Name }, + }), + Name = propertyInfo.Name, + ParameterType = propertyInfo.PropertyType, + Property = propertyInfo, + }; + + var typeInfo = typeof(TestPage).GetTypeInfo(); + var actionDescriptor = new CompiledPageActionDescriptor + { + BoundProperties = new[] { propertyDescriptor }, + HandlerTypeInfo = typeInfo, + ModelTypeInfo = typeInfo, + PageTypeInfo = typeInfo, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.Method = "POST"; + if (input.HasValue) + { + request.QueryString = new QueryString($"?{propertyDescriptor.Name}={input.Value}"); + } + }); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + var modelBinderFactory = ModelBindingTestHelper.GetModelBinderFactory(modelMetadataProvider); + var modelMetadata = modelMetadataProvider + .GetMetadataForProperty(typeof(TestPage), propertyDescriptor.Name); + + var pageBinder = PageBinderFactory.CreatePropertyBinder( + parameterBinder, + modelMetadataProvider, + modelBinderFactory, + actionDescriptor); + var pageContext = new PageContext + { + ActionDescriptor = actionDescriptor, + HttpContext = testContext.HttpContext, + RouteData = testContext.RouteData, + ValueProviderFactories = testContext.ValueProviderFactories, + }; + + var page = new TestPage(); + + // Act + await pageBinder(pageContext, page); + + // Assert + Assert.Equal(isValid, pageContext.ModelState.IsValid); + if (isValid) + { + Assert.Equal(input.Value, page.BindRequiredProperty); + } + } + [Theory] [InlineData("RequiredAndStringLengthProp", null, false)] [InlineData("RequiredAndStringLengthProp", "", false)] @@ -231,12 +301,18 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } } - class TestController + private class TestController { [BindNever] public string BindNeverProp { get; set; } [BindRequired] public int BindRequiredProp { get; set; } [Required, StringLength(3)] public string RequiredAndStringLengthProp { get; set; } [DisplayName("My Display Name"), StringLength(3)] public string DisplayNameStringLengthProp { get; set; } } + + private class TestPage : PageModel + { + [BindRequired] + public int BindRequiredProperty { get; set; } + } } } diff --git a/test/WebSites/RazorPagesWebSite/Pages/CustomModelTypeModel.cshtml.cs b/test/WebSites/RazorPagesWebSite/Pages/CustomModelTypeModel.cshtml.cs index e32a2e83c4..e78cce0428 100644 --- a/test/WebSites/RazorPagesWebSite/Pages/CustomModelTypeModel.cshtml.cs +++ b/test/WebSites/RazorPagesWebSite/Pages/CustomModelTypeModel.cshtml.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; @@ -13,6 +14,10 @@ namespace RazorPagesWebSite public string ReturnUrl { get; set; } + [BindRequired] + [FromQuery(Name = nameof(Attempts))] + public int Attempts { get; set; } + public class InputModel { [Required] @@ -69,10 +74,13 @@ namespace RazorPagesWebSite { if (!ModelState.IsValid) { + Attempts++; + RouteData.Values.Add(nameof(Attempts), Attempts); + return Page(); } return Redirect("~/"); } } -} \ No newline at end of file +}