Infer multipart/form-data for FromFile parameters

This commit is contained in:
Pranav K 2017-10-20 10:48:27 -07:00
parent 2e4bc548f5
commit ab4c519dd5
13 changed files with 372 additions and 53 deletions

View File

@ -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 // We're looking for a name ending with Id, but preceded by a lower case letter. This should match
// the normal PascalCase naming conventions. // the normal PascalCase naming conventions.
if (parameter.Name.Length >= 3 && if (parameter.Name.Length >= 3 &&
parameter.Name.EndsWith("Id", StringComparison.Ordinal) && parameter.Name.EndsWith("Id", StringComparison.Ordinal) &&
char.IsLower(parameter.Name, parameter.Name.Length - 3)) char.IsLower(parameter.Name, parameter.Name.Length - 3))
{ {
return true; return true;
@ -90,7 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
yield return CreateProblemResponse(statusCode: 0, isDefaultResponse: true); yield return CreateProblemResponse(statusCode: 0, isDefaultResponse: true);
} }
private ApiResponseType CreateProblemResponse(int statusCode, bool isDefaultResponse = false) private ApiResponseType CreateProblemResponse(int statusCode, bool isDefaultResponse = false)
{ {
return new ApiResponseType return new ApiResponseType

View File

@ -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 // It would be possible here to configure an action with multiple body parameters, in which case you
// could end up with duplicate data. // 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); var contentTypes = GetDeclaredContentTypes(requestMetadataAttributes);
foreach (var format in requestFormats) 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); return string.Join("/", segments);
} }
private IReadOnlyList<ApiRequestFormat> GetRequestFormats( private IReadOnlyList<ApiRequestFormat> GetSupportedFormats(MediaTypeCollection contentTypes, Type type)
IApiRequestMetadataProvider[] requestMetadataAttributes,
Type type)
{ {
var results = new List<ApiRequestFormat>();
// 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) if (contentTypes.Count == 0)
{ {
contentTypes.Add((string)null); contentTypes = new MediaTypeCollection
{
(string)null,
};
} }
var results = new List<ApiRequestFormat>();
foreach (var contentType in contentTypes) foreach (var contentType in contentTypes)
{ {
foreach (var formatter in _inputFormatters) foreach (var formatter in _inputFormatters)
@ -343,6 +352,22 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
return results; 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<ApiResponseType> GetApiResponseTypes( private IReadOnlyList<ApiResponseType> GetApiResponseTypes(
IApiResponseMetadataProvider[] responseMetadataAttributes, IApiResponseMetadataProvider[] responseMetadataAttributes,
Type type) Type type)

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Microsoft.AspNetCore.Mvc namespace Microsoft.AspNetCore.Mvc
@ -39,10 +40,17 @@ namespace Microsoft.AspNetCore.Mvc
/// <para> /// <para>
/// When enabled, the following sources are inferred: /// When enabled, the following sources are inferred:
/// Parameters that appear as route values, are assumed to be bound from the path (<see cref="BindingSource.Path"/>). /// Parameters that appear as route values, are assumed to be bound from the path (<see cref="BindingSource.Path"/>).
/// Parameters of type <see cref="IFormFile"/> and <see cref="IFormFileCollection"/> are assumed to be bound from form.
/// Parameters that are complex (<see cref="ModelMetadata.IsComplexType"/>) are assumed to be bound from the body (<see cref="BindingSource.Body"/>). /// Parameters that are complex (<see cref="ModelMetadata.IsComplexType"/>) are assumed to be bound from the body (<see cref="BindingSource.Body"/>).
/// All other parameters are assumed to be bound from the query. /// All other parameters are assumed to be bound from the query.
/// </para> /// </para>
/// </summary> /// </summary>
public bool SuppressInferBindingSourcesForParameters { get; set; } public bool SuppressInferBindingSourcesForParameters { get; set; }
/// <summary>
/// Gets or sets a value that determines if an <c>multipart/form-data</c> consumes action constraint is added to parameters
/// that are bound from form data.
/// </summary>
public bool SuppressConsumesConstraintForFormFileParameters { get; set; }
} }
} }

View File

@ -78,6 +78,33 @@ namespace Microsoft.AspNetCore.Mvc.Internal
AddInvalidModelStateFilter(actionModel); AddInvalidModelStateFilter(actionModel);
InferParameterBindingSources(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<IConsumesActionConstraint>().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 for unit testing.
internal BindingSource InferBindingSourceForParameter(ParameterModel parameter) internal BindingSource InferBindingSourceForParameter(ParameterModel parameter)
{ {
var parameterType = parameter.ParameterInfo.ParameterType;
if (ParameterExistsInAllRoutes(parameter.Action, parameter.ParameterName)) if (ParameterExistsInAllRoutes(parameter.Action, parameter.ParameterName))
{ {
return BindingSource.Path; return BindingSource.Path;

View File

@ -46,16 +46,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal
return; return;
} }
var constraint = item.Metadata as IActionConstraint; if (item.Metadata is IActionConstraint constraint)
if (constraint != null)
{ {
item.Constraint = constraint; item.Constraint = constraint;
item.IsReusable = true; item.IsReusable = true;
return; return;
} }
var factory = item.Metadata as IActionConstraintFactory; if (item.Metadata is IActionConstraintFactory factory)
if (factory != null)
{ {
item.Constraint = factory.CreateInstance(services); item.Constraint = factory.CreateInstance(services);
item.IsReusable = factory.IsReusable; item.IsReusable = factory.IsReusable;

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationModels;
@ -217,8 +218,18 @@ namespace Microsoft.AspNetCore.Mvc.Internal
var attributes = propertyInfo.GetCustomAttributes(inherit: true); var attributes = propertyInfo.GetCustomAttributes(inherit: true);
var propertyModel = new PropertyModel(propertyInfo, attributes); var propertyModel = new PropertyModel(propertyInfo, attributes);
var bindingInfo = BindingInfo.GetBindingInfo(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; propertyModel.PropertyName = propertyInfo.Name;
return propertyModel; return propertyModel;
@ -429,7 +440,17 @@ namespace Microsoft.AspNetCore.Mvc.Internal
var parameterModel = new ParameterModel(parameterInfo, attributes); var parameterModel = new ParameterModel(parameterInfo, attributes);
var bindingInfo = BindingInfo.GetBindingInfo(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; parameterModel.ParameterName = parameterInfo.Name;
@ -650,5 +671,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal
list.Add(item); list.Add(item);
} }
} }
private static bool IsFormFileType(Type parameterType)
{
return parameterType == typeof(IFormFile) ||
parameterType == typeof(IFormFileCollection) ||
typeof(IEnumerable<IFormFile>).IsAssignableFrom(parameterType);
}
} }
} }

View File

@ -3,7 +3,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
@ -22,10 +21,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
throw new ArgumentNullException(nameof(context)); throw new ArgumentNullException(nameof(context));
} }
// Note: This condition needs to be kept in sync with ApiBehaviorApplicationModelProvider.
var modelType = context.Metadata.ModelType; var modelType = context.Metadata.ModelType;
if (modelType == typeof(IFormFile) || if (modelType == typeof(IFormFile) ||
modelType == typeof(IFormFileCollection) || modelType == typeof(IFormFileCollection) ||
typeof(IEnumerable<IFormFile>).GetTypeInfo().IsAssignableFrom(modelType.GetTypeInfo())) typeof(IEnumerable<IFormFile>).IsAssignableFrom(modelType))
{ {
return new FormFileModelBinder(); return new FormFileModelBinder();
} }

View File

@ -8,6 +8,7 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ApiExplorer;
@ -990,6 +991,31 @@ namespace Microsoft.AspNetCore.Mvc.Description
Assert.Equal(typeof(string), parameter.Type); 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] [Fact]
public void GetApiDescription_ParameterDescription_SourceFromHeader() 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 // This will show up as source = model binding
private void AcceptsProduct_Default([ModelBinder] Product product) 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;
}
} }
} }

View File

@ -5,6 +5,7 @@ using System;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
@ -36,10 +37,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal
{ {
// Arrange // Arrange
var context = GetContext(typeof(TestApiController)); var context = GetContext(typeof(TestApiController));
var options = Options.Create(new ApiBehaviorOptions var options = new ApiBehaviorOptions
{ {
SuppressModelStateInvalidFilter = true, SuppressModelStateInvalidFilter = true,
}); };
var provider = GetProvider(options); var provider = GetProvider(options);
@ -79,10 +80,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal
{ {
// Arrange // Arrange
var context = GetContext(typeof(SimpleController)); var context = GetContext(typeof(SimpleController));
var options = Options.Create(new ApiBehaviorOptions var options = new ApiBehaviorOptions
{ {
SuppressModelStateInvalidFilter = true, SuppressModelStateInvalidFilter = true,
}); };
var provider = GetProvider(options); var provider = GetProvider(options);
@ -107,10 +108,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal
{ {
// Arrange // Arrange
var context = GetContext(typeof(TestApiController)); var context = GetContext(typeof(TestApiController));
var options = Options.Create(new ApiBehaviorOptions var options = new ApiBehaviorOptions
{ {
SuppressModelStateInvalidFilter = true, SuppressModelStateInvalidFilter = true,
}); };
var provider = GetProvider(options); var provider = GetProvider(options);
@ -128,10 +129,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal
// Arrange // Arrange
var context = GetContext(typeof(TestApiController)); var context = GetContext(typeof(TestApiController));
context.Result.Controllers[0].ApiExplorer.IsVisible = false; context.Result.Controllers[0].ApiExplorer.IsVisible = false;
var options = Options.Create(new ApiBehaviorOptions var options = new ApiBehaviorOptions
{ {
SuppressModelStateInvalidFilter = true, SuppressModelStateInvalidFilter = true,
}); };
var provider = GetProvider(options); var provider = GetProvider(options);
@ -397,18 +398,82 @@ namespace Microsoft.AspNetCore.Mvc.Internal
Assert.Same(BindingSource.Query, result); 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<ConsumesAttribute>(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<ConsumesAttribute>(attribute);
Assert.Equal("multipart/form-data", Assert.Single(consumesAttribute.ContentTypes));
}
private static ApiBehaviorApplicationModelProvider GetProvider( private static ApiBehaviorApplicationModelProvider GetProvider(
IOptions<ApiBehaviorOptions> options = null, ApiBehaviorOptions options = null,
IModelMetadataProvider modelMetadataProvider = null) IModelMetadataProvider modelMetadataProvider = null)
{ {
options = options ?? Options.Create(new ApiBehaviorOptions options = options ?? new ApiBehaviorOptions
{ {
InvalidModelStateResponseFactory = _ => null, InvalidModelStateResponseFactory = _ => null,
}); };
var optionsProvider = Options.Create(options);
modelMetadataProvider = modelMetadataProvider ?? new TestModelMetadataProvider(); modelMetadataProvider = modelMetadataProvider ?? new TestModelMetadataProvider();
var loggerFactory = NullLoggerFactory.Instance; var loggerFactory = NullLoggerFactory.Instance;
return new ApiBehaviorApplicationModelProvider(options, modelMetadataProvider, loggerFactory); return new ApiBehaviorApplicationModelProvider(optionsProvider, modelMetadataProvider, loggerFactory);
} }
private static ApplicationModelProviderContext GetContext(Type type) private static ApplicationModelProviderContext GetContext(Type type)
@ -418,11 +483,16 @@ namespace Microsoft.AspNetCore.Mvc.Internal
return context; return context;
} }
private static ParameterModel GetParameterModel(Type controllerType, string actionName) private static ActionModel GetActionModel(Type controllerType, string actionName)
{ {
var context = GetContext(controllerType); var context = GetContext(controllerType);
var controller = Assert.Single(context.Result.Controllers); 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); return Assert.Single(action.Parameters);
} }
@ -487,6 +557,22 @@ namespace Microsoft.AspNetCore.Mvc.Internal
[HttpPut("put-action/{id}")] [HttpPut("put-action/{id}")]
public IActionResult SimpleTypeModel(ConvertibleFromString model) => null; 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] [ApiController]

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
@ -45,13 +46,74 @@ namespace Microsoft.AspNetCore.Mvc.Internal
builder.OnProvidersExecuting(context); builder.OnProvidersExecuting(context);
// Assert // Assert
var model = Assert.Single(context.Result.Controllers); var controllerModel = Assert.Single(context.Result.Controllers);
Assert.Equal(2, model.ControllerProperties.Count); Assert.Collection(
Assert.Equal("Bound", model.ControllerProperties[0].PropertyName); controllerModel.ControllerProperties.OrderBy(p => p.PropertyName),
Assert.Equal(BindingSource.Query, model.ControllerProperties[0].BindingInfo.BindingSource); property =>
Assert.NotNull(model.ControllerProperties[0].Controller); {
var attribute = Assert.Single(model.ControllerProperties[0].Attributes); Assert.Equal(nameof(ModelBinderController.Bound), property.PropertyName);
Assert.IsType<FromQueryAttribute>(attribute); Assert.Equal(BindingSource.Query, property.BindingInfo.BindingSource);
Assert.Same(controllerModel, property.Controller);
var attribute = Assert.Single(property.Attributes);
Assert.IsType<FromQueryAttribute>(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<FromQueryAttribute>(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, // 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 Bound { get; set; }
public string Unbound { 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 public class SomeFiltersController : IAsyncActionFilter, IResultFilter

View File

@ -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<List<ApiExplorerData>>(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) private void AssertProblemDetails(ApiExplorerResponseType response)
{ {
Assert.Equal("Microsoft.AspNetCore.Mvc.ProblemDetails", response.ResponseType); Assert.Equal("Microsoft.AspNetCore.Mvc.ProblemDetails", response.ResponseType);
@ -1172,6 +1185,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
public string RelativePath { get; set; } public string RelativePath { get; set; }
public List<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>(); public List<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>();
public List<ApiExplorerRequestFormat> SupportedRequestFormats { get; } = new List<ApiExplorerRequestFormat>();
} }
// Used to serialize data between client and server // Used to serialize data between client and server
@ -1215,5 +1230,12 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
public string FormatterType { get; set; } public string FormatterType { get; set; }
} }
private class ApiExplorerRequestFormat
{
public string MediaType { get; set; }
public string FormatterType { get; set; }
}
} }
} }

View File

@ -84,6 +84,15 @@ namespace ApiExplorerWebSite
data.ParameterDescriptions.Add(parameterData); 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) foreach (var response in description.SupportedResponseTypes)
{ {
var responseType = new ApiExplorerResponseType() var responseType = new ApiExplorerResponseType()
@ -120,6 +129,8 @@ namespace ApiExplorerWebSite
public string RelativePath { get; set; } public string RelativePath { get; set; }
public List<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>(); public List<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>();
public List<ApiExplorerRequestFormat> SupportedRequestFormats { get; } = new List<ApiExplorerRequestFormat>();
} }
// Used to serialize data between client and server // Used to serialize data between client and server
@ -163,5 +174,12 @@ namespace ApiExplorerWebSite
public string FormatterType { get; set; } public string FormatterType { get; set; }
} }
private class ApiExplorerRequestFormat
{
public string MediaType { get; set; }
public string FormatterType { get; set; }
}
} }
} }

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // 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.Mvc;
using Microsoft.AspNetCore.Http;
namespace ApiExplorerWebSite namespace ApiExplorerWebSite
{ {
@ -22,5 +23,9 @@ namespace ApiExplorerWebSite
public void ActionWithIdSuffixParameter(int personId, string personName) public void ActionWithIdSuffixParameter(int personId, string personName)
{ {
} }
public void ActionWithFormFileCollectionParameter(IFormFileCollection formFile)
{
}
} }
} }