Skip to content
This repository was archived by the owner on Dec 14, 2018. It is now read-only.

Commit ab4c519

Browse files
committed
Infer multipart/form-data for FromFile parameters
1 parent 2e4bc54 commit ab4c519

File tree

13 files changed

+372
-53
lines changed

13 files changed

+372
-53
lines changed

src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiBehaviorApiDescriptionProvider.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ public bool IsIdParameter(ParameterDescriptor parameter)
6565

6666
// We're looking for a name ending with Id, but preceded by a lower case letter. This should match
6767
// the normal PascalCase naming conventions.
68-
if (parameter.Name.Length >= 3 &&
69-
parameter.Name.EndsWith("Id", StringComparison.Ordinal) &&
68+
if (parameter.Name.Length >= 3 &&
69+
parameter.Name.EndsWith("Id", StringComparison.Ordinal) &&
7070
char.IsLower(parameter.Name, parameter.Name.Length - 3))
7171
{
7272
return true;
@@ -90,7 +90,7 @@ public IEnumerable<ApiResponseType> CreateProblemResponseTypes(ApiDescription de
9090

9191
yield return CreateProblemResponse(statusCode: 0, isDefaultResponse: true);
9292
}
93-
93+
9494
private ApiResponseType CreateProblemResponse(int statusCode, bool isDefaultResponse = false)
9595
{
9696
return new ApiResponseType

src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,32 @@ private ApiDescription CreateApiDescription(
122122

123123
// It would be possible here to configure an action with multiple body parameters, in which case you
124124
// could end up with duplicate data.
125-
foreach (var parameter in apiDescription.ParameterDescriptions.Where(p => p.Source == BindingSource.Body))
125+
if (apiDescription.ParameterDescriptions.Count > 0)
126126
{
127-
var requestFormats = GetRequestFormats(requestMetadataAttributes, parameter.Type);
128-
foreach (var format in requestFormats)
127+
var contentTypes = GetDeclaredContentTypes(requestMetadataAttributes);
128+
foreach (var parameter in apiDescription.ParameterDescriptions)
129129
{
130-
apiDescription.SupportedRequestFormats.Add(format);
130+
if (parameter.Source == BindingSource.Body)
131+
{
132+
// For request body bound parameters, determine the content types supported
133+
// by input formatters.
134+
var requestFormats = GetSupportedFormats(contentTypes, parameter.Type);
135+
foreach (var format in requestFormats)
136+
{
137+
apiDescription.SupportedRequestFormats.Add(format);
138+
}
139+
}
140+
else if (parameter.Source == BindingSource.FormFile)
141+
{
142+
// Add all declared media types since FormFiles do not get processed by formatters.
143+
foreach (var contentType in contentTypes)
144+
{
145+
apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat
146+
{
147+
MediaType = contentType,
148+
});
149+
}
150+
}
131151
}
132152
}
133153

@@ -295,28 +315,17 @@ private string GetRelativePath(RouteTemplate parsedTemplate)
295315
return string.Join("/", segments);
296316
}
297317

298-
private IReadOnlyList<ApiRequestFormat> GetRequestFormats(
299-
IApiRequestMetadataProvider[] requestMetadataAttributes,
300-
Type type)
318+
private IReadOnlyList<ApiRequestFormat> GetSupportedFormats(MediaTypeCollection contentTypes, Type type)
301319
{
302-
var results = new List<ApiRequestFormat>();
303-
304-
// Walk through all 'filter' attributes in order, and allow each one to see or override
305-
// the results of the previous ones. This is similar to the execution path for content-negotiation.
306-
var contentTypes = new MediaTypeCollection();
307-
if (requestMetadataAttributes != null)
308-
{
309-
foreach (var metadataAttribute in requestMetadataAttributes)
310-
{
311-
metadataAttribute.SetContentTypes(contentTypes);
312-
}
313-
}
314-
315320
if (contentTypes.Count == 0)
316321
{
317-
contentTypes.Add((string)null);
322+
contentTypes = new MediaTypeCollection
323+
{
324+
(string)null,
325+
};
318326
}
319327

328+
var results = new List<ApiRequestFormat>();
320329
foreach (var contentType in contentTypes)
321330
{
322331
foreach (var formatter in _inputFormatters)
@@ -343,6 +352,22 @@ private IReadOnlyList<ApiRequestFormat> GetRequestFormats(
343352
return results;
344353
}
345354

355+
private static MediaTypeCollection GetDeclaredContentTypes(IApiRequestMetadataProvider[] requestMetadataAttributes)
356+
{
357+
// Walk through all 'filter' attributes in order, and allow each one to see or override
358+
// the results of the previous ones. This is similar to the execution path for content-negotiation.
359+
var contentTypes = new MediaTypeCollection();
360+
if (requestMetadataAttributes != null)
361+
{
362+
foreach (var metadataAttribute in requestMetadataAttributes)
363+
{
364+
metadataAttribute.SetContentTypes(contentTypes);
365+
}
366+
}
367+
368+
return contentTypes;
369+
}
370+
346371
private IReadOnlyList<ApiResponseType> GetApiResponseTypes(
347372
IApiResponseMetadataProvider[] responseMetadataAttributes,
348373
Type type)

src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using Microsoft.AspNetCore.Http;
56
using Microsoft.AspNetCore.Mvc.ModelBinding;
67

78
namespace Microsoft.AspNetCore.Mvc
@@ -39,10 +40,17 @@ public Func<ActionContext, IActionResult> InvalidModelStateResponseFactory
3940
/// <para>
4041
/// When enabled, the following sources are inferred:
4142
/// Parameters that appear as route values, are assumed to be bound from the path (<see cref="BindingSource.Path"/>).
43+
/// Parameters of type <see cref="IFormFile"/> and <see cref="IFormFileCollection"/> are assumed to be bound from form.
4244
/// Parameters that are complex (<see cref="ModelMetadata.IsComplexType"/>) are assumed to be bound from the body (<see cref="BindingSource.Body"/>).
4345
/// All other parameters are assumed to be bound from the query.
4446
/// </para>
4547
/// </summary>
4648
public bool SuppressInferBindingSourcesForParameters { get; set; }
49+
50+
/// <summary>
51+
/// Gets or sets a value that determines if an <c>multipart/form-data</c> consumes action constraint is added to parameters
52+
/// that are bound from form data.
53+
/// </summary>
54+
public bool SuppressConsumesConstraintForFormFileParameters { get; set; }
4755
}
4856
}

src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,33 @@ public void OnProvidersExecuting(ApplicationModelProviderContext context)
7878
AddInvalidModelStateFilter(actionModel);
7979

8080
InferParameterBindingSources(actionModel);
81+
82+
AddMultipartFormDataConsumesAttribute(actionModel);
83+
}
84+
}
85+
}
86+
87+
// Internal for unit testing
88+
internal void AddMultipartFormDataConsumesAttribute(ActionModel actionModel)
89+
{
90+
if (_apiBehaviorOptions.SuppressConsumesConstraintForFormFileParameters)
91+
{
92+
return;
93+
}
94+
95+
// Add a ConsumesAttribute if the request does not explicitly specify one.
96+
if (actionModel.Filters.OfType<IConsumesActionConstraint>().Any())
97+
{
98+
return;
99+
}
100+
101+
foreach (var parameter in actionModel.Parameters)
102+
{
103+
var bindingSource = parameter.BindingInfo?.BindingSource;
104+
if (bindingSource == BindingSource.FormFile)
105+
{
106+
// If an action accepts files, it must accept multipart/form-data.
107+
actionModel.Filters.Add(new ConsumesAttribute("multipart/form-data"));
81108
}
82109
}
83110
}
@@ -152,6 +179,7 @@ private void InferParameterBindingSources(ActionModel actionModel)
152179
// Internal for unit testing.
153180
internal BindingSource InferBindingSourceForParameter(ParameterModel parameter)
154181
{
182+
var parameterType = parameter.ParameterInfo.ParameterType;
155183
if (ParameterExistsInAllRoutes(parameter.Action, parameter.ParameterName))
156184
{
157185
return BindingSource.Path;

src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultActionConstraintProvider.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,14 @@ private void ProvideConstraint(ActionConstraintItem item, IServiceProvider servi
4646
return;
4747
}
4848

49-
var constraint = item.Metadata as IActionConstraint;
50-
if (constraint != null)
49+
if (item.Metadata is IActionConstraint constraint)
5150
{
5251
item.Constraint = constraint;
5352
item.IsReusable = true;
5453
return;
5554
}
5655

57-
var factory = item.Metadata as IActionConstraintFactory;
58-
if (factory != null)
56+
if (item.Metadata is IActionConstraintFactory factory)
5957
{
6058
item.Constraint = factory.CreateInstance(services);
6159
item.IsReusable = factory.IsReusable;

src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Linq;
77
using System.Reflection;
8+
using Microsoft.AspNetCore.Http;
89
using Microsoft.AspNetCore.Mvc.ActionConstraints;
910
using Microsoft.AspNetCore.Mvc.ApiExplorer;
1011
using Microsoft.AspNetCore.Mvc.ApplicationModels;
@@ -217,8 +218,18 @@ protected virtual PropertyModel CreatePropertyModel(PropertyInfo propertyInfo)
217218
var attributes = propertyInfo.GetCustomAttributes(inherit: true);
218219
var propertyModel = new PropertyModel(propertyInfo, attributes);
219220
var bindingInfo = BindingInfo.GetBindingInfo(attributes);
221+
if (bindingInfo != null)
222+
{
223+
propertyModel.BindingInfo = bindingInfo;
224+
}
225+
else if (IsFormFileType(propertyInfo.PropertyType))
226+
{
227+
propertyModel.BindingInfo = new BindingInfo
228+
{
229+
BindingSource = BindingSource.FormFile,
230+
};
231+
}
220232

221-
propertyModel.BindingInfo = bindingInfo;
222233
propertyModel.PropertyName = propertyInfo.Name;
223234

224235
return propertyModel;
@@ -429,7 +440,17 @@ protected virtual ParameterModel CreateParameterModel(ParameterInfo parameterInf
429440
var parameterModel = new ParameterModel(parameterInfo, attributes);
430441

431442
var bindingInfo = BindingInfo.GetBindingInfo(attributes);
432-
parameterModel.BindingInfo = bindingInfo;
443+
if (bindingInfo != null)
444+
{
445+
parameterModel.BindingInfo = bindingInfo;
446+
}
447+
else if (IsFormFileType(parameterInfo.ParameterType))
448+
{
449+
parameterModel.BindingInfo = new BindingInfo
450+
{
451+
BindingSource = BindingSource.FormFile,
452+
};
453+
}
433454

434455
parameterModel.ParameterName = parameterInfo.Name;
435456

@@ -650,5 +671,12 @@ private static void AddRange<T>(IList<T> list, IEnumerable<T> items)
650671
list.Add(item);
651672
}
652673
}
674+
675+
private static bool IsFormFileType(Type parameterType)
676+
{
677+
return parameterType == typeof(IFormFile) ||
678+
parameterType == typeof(IFormFileCollection) ||
679+
typeof(IEnumerable<IFormFile>).IsAssignableFrom(parameterType);
680+
}
653681
}
654682
}

src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinderProvider.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System;
55
using System.Collections.Generic;
6-
using System.Reflection;
76
using Microsoft.AspNetCore.Http;
87

98
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
@@ -22,10 +21,11 @@ public IModelBinder GetBinder(ModelBinderProviderContext context)
2221
throw new ArgumentNullException(nameof(context));
2322
}
2423

24+
// Note: This condition needs to be kept in sync with ApiBehaviorApplicationModelProvider.
2525
var modelType = context.Metadata.ModelType;
2626
if (modelType == typeof(IFormFile) ||
2727
modelType == typeof(IFormFileCollection) ||
28-
typeof(IEnumerable<IFormFile>).GetTypeInfo().IsAssignableFrom(modelType.GetTypeInfo()))
28+
typeof(IEnumerable<IFormFile>).IsAssignableFrom(modelType))
2929
{
3030
return new FormFileModelBinder();
3131
}

test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Reflection;
99
using System.Text;
1010
using System.Threading.Tasks;
11+
using Microsoft.AspNetCore.Http;
1112
using Microsoft.AspNetCore.Mvc.Abstractions;
1213
using Microsoft.AspNetCore.Mvc.ActionConstraints;
1314
using Microsoft.AspNetCore.Mvc.ApiExplorer;
@@ -990,6 +991,31 @@ public void GetApiDescription_ParameterDescription_SourceFromForm()
990991
Assert.Equal(typeof(string), parameter.Type);
991992
}
992993

994+
[Fact]
995+
public void GetApiDescription_ParameterDescription_SourceFromFormFile()
996+
{
997+
// Arrange
998+
var action = CreateActionDescriptor(nameof(AcceptsFormFile));
999+
action.FilterDescriptors = new[]
1000+
{
1001+
new FilterDescriptor(new ConsumesAttribute("multipart/form-data"), FilterScope.Action),
1002+
};
1003+
1004+
// Act
1005+
var descriptions = GetApiDescriptions(action);
1006+
1007+
// Assert
1008+
var description = Assert.Single(descriptions);
1009+
1010+
var parameters = description.ParameterDescriptions;
1011+
var parameter = Assert.Single(parameters);
1012+
Assert.Same(BindingSource.FormFile, parameter.Source);
1013+
1014+
var requestFormat = Assert.Single(description.SupportedRequestFormats);
1015+
Assert.Equal("multipart/form-data", requestFormat.MediaType);
1016+
Assert.Null(requestFormat.Formatter);
1017+
}
1018+
9931019
[Fact]
9941020
public void GetApiDescription_ParameterDescription_SourceFromHeader()
9951021
{
@@ -1534,6 +1560,10 @@ private void AcceptsProduct_Form([FromForm] Product product)
15341560
{
15351561
}
15361562

1563+
private void AcceptsFormFile([FromFormFile] IFormFile formFile)
1564+
{
1565+
}
1566+
15371567
// This will show up as source = model binding
15381568
private void AcceptsProduct_Default([ModelBinder] Product product)
15391569
{
@@ -1856,5 +1886,10 @@ private interface ITestService
18561886
{
18571887

18581888
}
1889+
1890+
private class FromFormFileAttribute : Attribute, IBindingSourceMetadata
1891+
{
1892+
public BindingSource BindingSource => BindingSource.FormFile;
1893+
}
18591894
}
18601895
}

0 commit comments

Comments
 (0)