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
// 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

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
// 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)

View File

@ -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; }
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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();
}

View File

@ -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;
}
}
}

View File

@ -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]

View File

@ -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

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)
{
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; }
}
}
}

View File

@ -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; }
}
}
}

View File

@ -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)
{
}
}
}