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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<ApiRequestFormat> GetRequestFormats(
|
||||
IApiRequestMetadataProvider[] requestMetadataAttributes,
|
||||
Type type)
|
||||
private IReadOnlyList<ApiRequestFormat> GetSupportedFormats(MediaTypeCollection contentTypes, 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)
|
||||
{
|
||||
contentTypes.Add((string)null);
|
||||
contentTypes = new MediaTypeCollection
|
||||
{
|
||||
(string)null,
|
||||
};
|
||||
}
|
||||
|
||||
var results = new List<ApiRequestFormat>();
|
||||
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<ApiResponseType> GetApiResponseTypes(
|
||||
IApiResponseMetadataProvider[] responseMetadataAttributes,
|
||||
Type type)
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// <para>
|
||||
/// 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 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"/>).
|
||||
/// All other parameters are assumed to be bound from the query.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
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 BindingSource InferBindingSourceForParameter(ParameterModel parameter)
|
||||
{
|
||||
var parameterType = parameter.ParameterInfo.ParameterType;
|
||||
if (ParameterExistsInAllRoutes(parameter.Action, parameter.ParameterName))
|
||||
{
|
||||
return BindingSource.Path;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<IFormFile>).IsAssignableFrom(parameterType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IFormFile>).GetTypeInfo().IsAssignableFrom(modelType.GetTypeInfo()))
|
||||
typeof(IEnumerable<IFormFile>).IsAssignableFrom(modelType))
|
||||
{
|
||||
return new FormFileModelBinder();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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(
|
||||
IOptions<ApiBehaviorOptions> 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]
|
||||
|
|
|
|||
|
|
@ -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<FromQueryAttribute>(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<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,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
Assert.Equal("Microsoft.AspNetCore.Mvc.ProblemDetails", response.ResponseType);
|
||||
|
|
@ -1172,6 +1185,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
|
|||
public string RelativePath { get; set; }
|
||||
|
||||
public List<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>();
|
||||
|
||||
public List<ApiExplorerRequestFormat> SupportedRequestFormats { get; } = new List<ApiExplorerRequestFormat>();
|
||||
}
|
||||
|
||||
// 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>();
|
||||
|
||||
public List<ApiExplorerRequestFormat> SupportedRequestFormats { get; } = new List<ApiExplorerRequestFormat>();
|
||||
}
|
||||
|
||||
// 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue