I'm trying to create a controller method that capable to accept a file and a DTO in the same request. I need to follow this exact structure for the proper OpenAPI generation with nested types. (similar to this https://swagger.io/docs/specification/describing-request-body/multipart-requests/).
I simplified the example, however I hope the idea is clear. In the real project "SimpleDTO" class contains multiple properties of different types including enums and user types)
public class SimpleDTO
{
public string Property1 {get;set;}
public string Property2 {get;set;}
...
}
public class RequestWrapper
{
public IFormFile File {get;set;}
public SimpleDTO? SimpleDto {get;set;}
}
[Post]
public async Task<IActionResult> TestMethod([FromForm]RequestWrapper dto)
{
...
return Ok();
}
Then I tested this code with the Postman and got the status 400. If I replace the "SimpleDto" property type with something simple (e.g. int) it works.
I tried to apply custom model binders to the "dto" controller method parameter and to the SimpleDTO class with no result. I suppose I did it the wrong way.
I expect to get the following OpenAPI specification (and the client generated from the specification should work with the method mentioned above):
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
SimpleDto:
$ref: '#/components/schemas/SimpleDTO'
responses:
'200':
description: Success
I have an alternative solution, which is a bit tricky and I would like to avoid it if possible. The idea is to use "SimpleDTO" as a method parameter and get file from the request body, then add file info to the OpenAPI specification using OperationFiltres.
So, I added custom IOperationFilter for the OpenApi and got rid of the RequestWrapper class.
public class SimpleDTO
{
public string Property1 {get;set;}
public string Property2 {get;set;}
}
Controller method:
[Post]
public async Task<IActionResult> TestMethod(IFormFile file, [FromForm]SimpleDTO dto)
{
...
return Ok();
}
Here, I partially replace generation of OpenApi specification with my own implementation using IOperationFilter:
public class OpenApiSpecificationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var formParameters = context.MethodInfo.GetParameters()
.Where(p => p.GetCustomAttributes(typeof(FromFormAttribute), false).Length > 0
|| p.ParameterType == typeof(IFormFile)
|| p.ParameterType == typeof(IFormFileCollection)
|| p.ParameterType == typeof(List<IFormFile>));
if (!formParameters.Any())
return;
operation.RequestBody.Content.Clear();
var schema = new OpenApiSchema();
foreach (var p in formParameters)
{
if (p.ParameterType == typeof(IFormFile))
{
schema.Properties.Add(p.Name, new OpenApiSchema()
{
Type = "string",
Format = "binary"
});
}
else if (p.ParameterType == typeof(IFormCollection) || p.ParameterType == typeof(List<IFormFile>))
{
schema.Properties.Add(p.Name, new OpenApiSchema()
{
Type = "array",
Items = new OpenApiSchema()
{
Type = "string",
Format = "binary"
}
});
}
else
{
var reference = context.SchemaGenerator.GenerateSchema(p.ParameterType, context.SchemaRepository);
schema.Properties.Add(p.Name, reference);
}
}
operation.RequestBody.Content.Add("multipart/form-data", new OpenApiMediaType()
{
Schema = schema
});
}
}
Program.cs:
builder.Services.AddSwaggerGen(options =>
{
options.OperationFilter<OpenApiSpecificationFilter>();
}
Test request:
curl --location 'https://localhost:[port]/testMethod' \
--form 'file=@"testFile.jpeg"' \
--form 'dto="{
\"Property1\": \"test value 1\",
\"Property2\": \"test value 2\",
}"'