diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiBehaviorApiDescriptionProvider.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiBehaviorApiDescriptionProvider.cs index 83874b6733..0296189e6b 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiBehaviorApiDescriptionProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiBehaviorApiDescriptionProvider.cs @@ -65,8 +65,8 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer // We're looking for a name ending with Id, but preceded by a lower case letter. This should match // the normal PascalCase naming conventions. - if (parameter.Name.Length >= 3 && - parameter.Name.EndsWith("Id", StringComparison.Ordinal) && + if (parameter.Name.Length >= 3 && + parameter.Name.EndsWith("Id", StringComparison.Ordinal) && char.IsLower(parameter.Name, parameter.Name.Length - 3)) { return true; @@ -90,7 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer yield return CreateProblemResponse(statusCode: 0, isDefaultResponse: true); } - + private ApiResponseType CreateProblemResponse(int statusCode, bool isDefaultResponse = false) { return new ApiResponseType diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs index 202d63b07e..23e8bb6187 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs @@ -122,12 +122,32 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer // It would be possible here to configure an action with multiple body parameters, in which case you // could end up with duplicate data. - foreach (var parameter in apiDescription.ParameterDescriptions.Where(p => p.Source == BindingSource.Body)) + if (apiDescription.ParameterDescriptions.Count > 0) { - var requestFormats = GetRequestFormats(requestMetadataAttributes, parameter.Type); - foreach (var format in requestFormats) + var contentTypes = GetDeclaredContentTypes(requestMetadataAttributes); + foreach (var parameter in apiDescription.ParameterDescriptions) { - apiDescription.SupportedRequestFormats.Add(format); + if (parameter.Source == BindingSource.Body) + { + // For request body bound parameters, determine the content types supported + // by input formatters. + var requestFormats = GetSupportedFormats(contentTypes, parameter.Type); + foreach (var format in requestFormats) + { + apiDescription.SupportedRequestFormats.Add(format); + } + } + else if (parameter.Source == BindingSource.FormFile) + { + // Add all declared media types since FormFiles do not get processed by formatters. + foreach (var contentType in contentTypes) + { + apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat + { + MediaType = contentType, + }); + } + } } } @@ -295,28 +315,17 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer return string.Join("/", segments); } - private IReadOnlyList GetRequestFormats( - IApiRequestMetadataProvider[] requestMetadataAttributes, - Type type) + private IReadOnlyList GetSupportedFormats(MediaTypeCollection contentTypes, Type type) { - var results = new List(); - - // Walk through all 'filter' attributes in order, and allow each one to see or override - // the results of the previous ones. This is similar to the execution path for content-negotiation. - var contentTypes = new MediaTypeCollection(); - if (requestMetadataAttributes != null) - { - foreach (var metadataAttribute in requestMetadataAttributes) - { - metadataAttribute.SetContentTypes(contentTypes); - } - } - if (contentTypes.Count == 0) { - contentTypes.Add((string)null); + contentTypes = new MediaTypeCollection + { + (string)null, + }; } + var results = new List(); foreach (var contentType in contentTypes) { foreach (var formatter in _inputFormatters) @@ -343,6 +352,22 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer return results; } + private static MediaTypeCollection GetDeclaredContentTypes(IApiRequestMetadataProvider[] requestMetadataAttributes) + { + // Walk through all 'filter' attributes in order, and allow each one to see or override + // the results of the previous ones. This is similar to the execution path for content-negotiation. + var contentTypes = new MediaTypeCollection(); + if (requestMetadataAttributes != null) + { + foreach (var metadataAttribute in requestMetadataAttributes) + { + metadataAttribute.SetContentTypes(contentTypes); + } + } + + return contentTypes; + } + private IReadOnlyList GetApiResponseTypes( IApiResponseMetadataProvider[] responseMetadataAttributes, Type type) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs index ec1ec4a678..65c64586ce 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -39,10 +40,17 @@ namespace Microsoft.AspNetCore.Mvc /// /// When enabled, the following sources are inferred: /// Parameters that appear as route values, are assumed to be bound from the path (). + /// Parameters of type and are assumed to be bound from form. /// Parameters that are complex () are assumed to be bound from the body (). /// All other parameters are assumed to be bound from the query. /// /// public bool SuppressInferBindingSourcesForParameters { get; set; } + + /// + /// Gets or sets a value that determines if an multipart/form-data consumes action constraint is added to parameters + /// that are bound from form data. + /// + public bool SuppressConsumesConstraintForFormFileParameters { get; set; } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs index 4733d85c3c..2d6fcf9dff 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs @@ -78,6 +78,33 @@ namespace Microsoft.AspNetCore.Mvc.Internal AddInvalidModelStateFilter(actionModel); InferParameterBindingSources(actionModel); + + AddMultipartFormDataConsumesAttribute(actionModel); + } + } + } + + // Internal for unit testing + internal void AddMultipartFormDataConsumesAttribute(ActionModel actionModel) + { + if (_apiBehaviorOptions.SuppressConsumesConstraintForFormFileParameters) + { + return; + } + + // Add a ConsumesAttribute if the request does not explicitly specify one. + if (actionModel.Filters.OfType().Any()) + { + return; + } + + foreach (var parameter in actionModel.Parameters) + { + var bindingSource = parameter.BindingInfo?.BindingSource; + if (bindingSource == BindingSource.FormFile) + { + // If an action accepts files, it must accept multipart/form-data. + actionModel.Filters.Add(new ConsumesAttribute("multipart/form-data")); } } } @@ -152,6 +179,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Internal for unit testing. internal BindingSource InferBindingSourceForParameter(ParameterModel parameter) { + var parameterType = parameter.ParameterInfo.ParameterType; if (ParameterExistsInAllRoutes(parameter.Action, parameter.ParameterName)) { return BindingSource.Path; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultActionConstraintProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultActionConstraintProvider.cs index f79a2fa835..571f3ebebf 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultActionConstraintProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultActionConstraintProvider.cs @@ -46,16 +46,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal return; } - var constraint = item.Metadata as IActionConstraint; - if (constraint != null) + if (item.Metadata is IActionConstraint constraint) { item.Constraint = constraint; item.IsReusable = true; return; } - var factory = item.Metadata as IActionConstraintFactory; - if (factory != null) + if (item.Metadata is IActionConstraintFactory factory) { item.Constraint = factory.CreateInstance(services); item.IsReusable = factory.IsReusable; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs index de190cca16..b610bbd8f2 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ApplicationModels; @@ -217,8 +218,18 @@ namespace Microsoft.AspNetCore.Mvc.Internal var attributes = propertyInfo.GetCustomAttributes(inherit: true); var propertyModel = new PropertyModel(propertyInfo, attributes); var bindingInfo = BindingInfo.GetBindingInfo(attributes); + if (bindingInfo != null) + { + propertyModel.BindingInfo = bindingInfo; + } + else if (IsFormFileType(propertyInfo.PropertyType)) + { + propertyModel.BindingInfo = new BindingInfo + { + BindingSource = BindingSource.FormFile, + }; + } - propertyModel.BindingInfo = bindingInfo; propertyModel.PropertyName = propertyInfo.Name; return propertyModel; @@ -429,7 +440,17 @@ namespace Microsoft.AspNetCore.Mvc.Internal var parameterModel = new ParameterModel(parameterInfo, attributes); var bindingInfo = BindingInfo.GetBindingInfo(attributes); - parameterModel.BindingInfo = bindingInfo; + if (bindingInfo != null) + { + parameterModel.BindingInfo = bindingInfo; + } + else if (IsFormFileType(parameterInfo.ParameterType)) + { + parameterModel.BindingInfo = new BindingInfo + { + BindingSource = BindingSource.FormFile, + }; + } parameterModel.ParameterName = parameterInfo.Name; @@ -650,5 +671,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal list.Add(item); } } + + private static bool IsFormFileType(Type parameterType) + { + return parameterType == typeof(IFormFile) || + parameterType == typeof(IFormFileCollection) || + typeof(IEnumerable).IsAssignableFrom(parameterType); + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinderProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinderProvider.cs index 1b181f6fb4..3d1510a7d9 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinderProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinderProvider.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Reflection; using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders @@ -22,10 +21,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders throw new ArgumentNullException(nameof(context)); } + // Note: This condition needs to be kept in sync with ApiBehaviorApplicationModelProvider. var modelType = context.Metadata.ModelType; if (modelType == typeof(IFormFile) || modelType == typeof(IFormFileCollection) || - typeof(IEnumerable).GetTypeInfo().IsAssignableFrom(modelType.GetTypeInfo())) + typeof(IEnumerable).IsAssignableFrom(modelType)) { return new FormFileModelBinder(); } diff --git a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs index 5cbf480a3c..04a1cf9d50 100644 --- a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -990,6 +991,31 @@ namespace Microsoft.AspNetCore.Mvc.Description Assert.Equal(typeof(string), parameter.Type); } + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromFormFile() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsFormFile)); + action.FilterDescriptors = new[] + { + new FilterDescriptor(new ConsumesAttribute("multipart/form-data"), FilterScope.Action), + }; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameters = description.ParameterDescriptions; + var parameter = Assert.Single(parameters); + Assert.Same(BindingSource.FormFile, parameter.Source); + + var requestFormat = Assert.Single(description.SupportedRequestFormats); + Assert.Equal("multipart/form-data", requestFormat.MediaType); + Assert.Null(requestFormat.Formatter); + } + [Fact] public void GetApiDescription_ParameterDescription_SourceFromHeader() { @@ -1534,6 +1560,10 @@ namespace Microsoft.AspNetCore.Mvc.Description { } + private void AcceptsFormFile([FromFormFile] IFormFile formFile) + { + } + // This will show up as source = model binding private void AcceptsProduct_Default([ModelBinder] Product product) { @@ -1856,5 +1886,10 @@ namespace Microsoft.AspNetCore.Mvc.Description { } + + private class FromFormFileAttribute : Attribute, IBindingSourceMetadata + { + public BindingSource BindingSource => BindingSource.FormFile; + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs index 44de9332f7..aeb6179718 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs @@ -5,6 +5,7 @@ using System; using System.ComponentModel; using System.Linq; using System.Reflection; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -36,10 +37,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal { // Arrange var context = GetContext(typeof(TestApiController)); - var options = Options.Create(new ApiBehaviorOptions + var options = new ApiBehaviorOptions { SuppressModelStateInvalidFilter = true, - }); + }; var provider = GetProvider(options); @@ -79,10 +80,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal { // Arrange var context = GetContext(typeof(SimpleController)); - var options = Options.Create(new ApiBehaviorOptions + var options = new ApiBehaviorOptions { SuppressModelStateInvalidFilter = true, - }); + }; var provider = GetProvider(options); @@ -107,10 +108,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal { // Arrange var context = GetContext(typeof(TestApiController)); - var options = Options.Create(new ApiBehaviorOptions + var options = new ApiBehaviorOptions { SuppressModelStateInvalidFilter = true, - }); + }; var provider = GetProvider(options); @@ -128,10 +129,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Arrange var context = GetContext(typeof(TestApiController)); context.Result.Controllers[0].ApiExplorer.IsVisible = false; - var options = Options.Create(new ApiBehaviorOptions + var options = new ApiBehaviorOptions { SuppressModelStateInvalidFilter = true, - }); + }; var provider = GetProvider(options); @@ -397,18 +398,82 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Same(BindingSource.Query, result); } + [Fact] + public void AddMultipartFormDataConsumesAttribute_NoOpsIfBehaviorIsDisabled() + { + // Arrange + var actionName = nameof(ParameterBindingController.FromFormParameter); + var action = GetActionModel(typeof(ParameterBindingController), actionName); + var options = new ApiBehaviorOptions + { + SuppressConsumesConstraintForFormFileParameters = true, + InvalidModelStateResponseFactory = _ => null, + }; + var provider = GetProvider(options); + + // Act + provider.AddMultipartFormDataConsumesAttribute(action); + + // Assert + Assert.Empty(action.Filters); + } + + [Fact] + public void AddMultipartFormDataConsumesAttribute_NoOpsIfConsumesConstraintIsAlreadyPresent() + { + // Arrange + var actionName = nameof(ParameterBindingController.ActionWithConsumesAttribute); + var action = GetActionModel(typeof(ParameterBindingController), actionName); + var options = new ApiBehaviorOptions + { + SuppressConsumesConstraintForFormFileParameters = true, + InvalidModelStateResponseFactory = _ => null, + }; + var provider = GetProvider(options); + + // Act + provider.AddMultipartFormDataConsumesAttribute(action); + + // Assert + var attribute = Assert.Single(action.Filters); + var consumesAttribute = Assert.IsType(attribute); + Assert.Equal("application/json", Assert.Single(consumesAttribute.ContentTypes)); + } + + [Fact] + public void AddMultipartFormDataConsumesAttribute_AddsConsumesAttribute_WhenActionHasFromFormFileParameter() + { + // Arrange + var actionName = nameof(ParameterBindingController.FormFileParameter); + var action = GetActionModel(typeof(ParameterBindingController), actionName); + action.Parameters[0].BindingInfo = new BindingInfo + { + BindingSource = BindingSource.FormFile, + }; + var provider = GetProvider(); + + // Act + provider.AddMultipartFormDataConsumesAttribute(action); + + // Assert + var attribute = Assert.Single(action.Filters); + var consumesAttribute = Assert.IsType(attribute); + Assert.Equal("multipart/form-data", Assert.Single(consumesAttribute.ContentTypes)); + } + private static ApiBehaviorApplicationModelProvider GetProvider( - IOptions options = null, + ApiBehaviorOptions options = null, IModelMetadataProvider modelMetadataProvider = null) { - options = options ?? Options.Create(new ApiBehaviorOptions + options = options ?? new ApiBehaviorOptions { InvalidModelStateResponseFactory = _ => null, - }); + }; + var optionsProvider = Options.Create(options); modelMetadataProvider = modelMetadataProvider ?? new TestModelMetadataProvider(); var loggerFactory = NullLoggerFactory.Instance; - return new ApiBehaviorApplicationModelProvider(options, modelMetadataProvider, loggerFactory); + return new ApiBehaviorApplicationModelProvider(optionsProvider, modelMetadataProvider, loggerFactory); } private static ApplicationModelProviderContext GetContext(Type type) @@ -418,11 +483,16 @@ namespace Microsoft.AspNetCore.Mvc.Internal return context; } - private static ParameterModel GetParameterModel(Type controllerType, string actionName) + private static ActionModel GetActionModel(Type controllerType, string actionName) { var context = GetContext(controllerType); var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, m => m.ActionName == actionName); + return Assert.Single(controller.Actions, m => m.ActionName == actionName); + } + + private static ParameterModel GetParameterModel(Type controllerType, string actionName) + { + var action = GetActionModel(controllerType, actionName); return Assert.Single(action.Parameters); } @@ -487,6 +557,22 @@ namespace Microsoft.AspNetCore.Mvc.Internal [HttpPut("put-action/{id}")] public IActionResult SimpleTypeModel(ConvertibleFromString model) => null; + + [HttpPost("form-file")] + public IActionResult FormFileParameter(IFormFile formFile) => null; + + [HttpPost("form-file-collection")] + public IActionResult FormFileCollectionParameter(IFormFileCollection formFiles) => null; + + [HttpPost("form-file-sequence")] + public IActionResult FormFileSequenceParameter(IFormFile[] formFiles) => null; + + [HttpPost] + public IActionResult FromFormParameter([FromForm] string parameter) => null; + + [HttpPost] + [Consumes("application/json")] + public IActionResult ActionWithConsumesAttribute([FromForm] string parameter) => null; } [ApiController] diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs index 90cb48800f..5605b698ac 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -45,13 +46,74 @@ namespace Microsoft.AspNetCore.Mvc.Internal builder.OnProvidersExecuting(context); // Assert - var model = Assert.Single(context.Result.Controllers); - Assert.Equal(2, model.ControllerProperties.Count); - Assert.Equal("Bound", model.ControllerProperties[0].PropertyName); - Assert.Equal(BindingSource.Query, model.ControllerProperties[0].BindingInfo.BindingSource); - Assert.NotNull(model.ControllerProperties[0].Controller); - var attribute = Assert.Single(model.ControllerProperties[0].Attributes); - Assert.IsType(attribute); + var controllerModel = Assert.Single(context.Result.Controllers); + Assert.Collection( + controllerModel.ControllerProperties.OrderBy(p => p.PropertyName), + property => + { + Assert.Equal(nameof(ModelBinderController.Bound), property.PropertyName); + Assert.Equal(BindingSource.Query, property.BindingInfo.BindingSource); + Assert.Same(controllerModel, property.Controller); + + var attribute = Assert.Single(property.Attributes); + Assert.IsType(attribute); + }, + property => + { + Assert.Equal(nameof(ModelBinderController.FormFile), property.PropertyName); + Assert.Equal(BindingSource.FormFile, property.BindingInfo.BindingSource); + Assert.Same(controllerModel, property.Controller); + + Assert.Empty(property.Attributes); + }, + property => + { + Assert.Equal(nameof(ModelBinderController.Unbound), property.PropertyName); + Assert.Null(property.BindingInfo); + Assert.Same(controllerModel, property.Controller); + }); + } + + [Fact] + public void OnProvidersExecuting_AddsBindingSources_ForActionParameters() + { + // Arrange + var builder = new TestApplicationModelProvider(); + var typeInfo = typeof(ModelBinderController).GetTypeInfo(); + + var context = new ApplicationModelProviderContext(new[] { typeInfo }); + + // Act + builder.OnProvidersExecuting(context); + + // Assert + var controllerModel = Assert.Single(context.Result.Controllers); + var action = Assert.Single(controllerModel.Actions); + Assert.Collection( + action.Parameters, + parameter => + { + Assert.Equal("fromQuery", parameter.ParameterName); + Assert.Equal(BindingSource.Query, parameter.BindingInfo.BindingSource); + Assert.Same(action, parameter.Action); + + var attribute = Assert.Single(parameter.Attributes); + Assert.IsType(attribute); + }, + parameter => + { + Assert.Equal("formFileCollection", parameter.ParameterName); + Assert.Equal(BindingSource.FormFile, parameter.BindingInfo.BindingSource); + Assert.Same(action, parameter.Action); + + Assert.Empty(parameter.Attributes); + }, + parameter => + { + Assert.Equal("unbound", parameter.ParameterName); + Assert.Null(parameter.BindingInfo); + Assert.Same(action, parameter.Action); + }); } // This class has a filter attribute, but doesn't implement any filter interfaces, @@ -1297,6 +1359,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal public string Bound { get; set; } public string Unbound { get; set; } + + public IFormFile FormFile { get; set; } + + public IActionResult PostAction([FromQuery] string fromQuery, IFormFileCollection formFileCollection, string unbound) => null; } public class SomeFiltersController : IAsyncActionFilter, IResultFilter diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index c7a3f034a0..d80f301ca1 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -1144,6 +1144,19 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests }); } + [Fact] + public async Task ApiBehavior_AddsMultipartFormDataConsumesConstraint_ForActionsWithFormFileParameters() + { + // Act + var body = await Client.GetStringAsync("ApiExplorerApiController/ActionWithFormFileCollectionParameter"); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var requestFormat = Assert.Single(description.SupportedRequestFormats); + Assert.Equal("multipart/form-data", requestFormat.MediaType); + } + private void AssertProblemDetails(ApiExplorerResponseType response) { Assert.Equal("Microsoft.AspNetCore.Mvc.ProblemDetails", response.ResponseType); @@ -1172,6 +1185,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public string RelativePath { get; set; } public List SupportedResponseTypes { get; } = new List(); + + public List SupportedRequestFormats { get; } = new List(); } // Used to serialize data between client and server @@ -1215,5 +1230,12 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public string FormatterType { get; set; } } + + private class ApiExplorerRequestFormat + { + public string MediaType { get; set; } + + public string FormatterType { get; set; } + } } } \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs index dbe476a3dd..fdf64194eb 100644 --- a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs +++ b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs @@ -84,6 +84,15 @@ namespace ApiExplorerWebSite data.ParameterDescriptions.Add(parameterData); } + foreach (var request in description.SupportedRequestFormats) + { + data.SupportedRequestFormats.Add(new ApiExplorerRequestFormat + { + FormatterType = request.Formatter?.GetType().FullName, + MediaType = request.MediaType, + }); + } + foreach (var response in description.SupportedResponseTypes) { var responseType = new ApiExplorerResponseType() @@ -120,6 +129,8 @@ namespace ApiExplorerWebSite public string RelativePath { get; set; } public List SupportedResponseTypes { get; } = new List(); + + public List SupportedRequestFormats { get; } = new List(); } // Used to serialize data between client and server @@ -163,5 +174,12 @@ namespace ApiExplorerWebSite public string FormatterType { get; set; } } + + private class ApiExplorerRequestFormat + { + public string MediaType { get; set; } + + public string FormatterType { get; set; } + } } } \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs index da1cadaa64..d45f0bb07a 100644 --- a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; namespace ApiExplorerWebSite { @@ -22,5 +23,9 @@ namespace ApiExplorerWebSite public void ActionWithIdSuffixParameter(int personId, string personName) { } + + public void ActionWithFormFileCollectionParameter(IFormFileCollection formFile) + { + } } } \ No newline at end of file