Infer multipart/form-data for FromFile parameters
This commit is contained in:
parent
2e4bc548f5
commit
ab4c519dd5
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue