How can I upload a list of files (images) and json data to ASP.NET Core Web API controller using multipart upload?
I can successfully receive a list of files, upload
I had a similar issue and I solved the problem by using [FromForm]
attribute and FileUploadModelView
in the function as follows:
[HttpPost("Save")]
public async Task<IActionResult> Save([FromForm] ProfileEditViewModel model)
{
return null;
}
I am not sure if you can do the two things in a single step.
How I have achieved this in the past is by uploading the file through ajax and returning the file url back in the response and then pass it along with post request to save the actual record.
I had a similar problem when posting from angular to asp core api.
Chrome: Form Data
------WebKitFormBoundarydowgB6BX0wiwKeOk
Content-Disposition: form-data; name="file1"
undefined
------WebKitFormBoundarydowgB6BX0wiwKeOk
Content-Disposition: form-data; name="file2"
undefined
------WebKitFormBoundarydowgB6BX0wiwKeOk
Content-Disposition: form-data; name="reportData"; filename="blob"
Content-Type: application/json
{"id":2,"report":3,"code":"XX0013","business":"01","name":"Test","description":"Description"}
------WebKitFormBoundarydowgB6BX0wiwKeOk--
Here is how I do it:
I use reportData as an uploaded file data, then I read the file's contents.
[HttpPost]
public async Task<IActionResult> Set([FromForm] IFormFile file1, [FromForm] IFormFile file2, [FromForm] IFormFile reportData)
{
try
{
ReportFormModel.Result result = default;
if (reportData != null)
{
string reportJson = await reportData.ReadFormFileAsync();
ReportFormModel.Params reportParams = reportJson.JsonToObject<ReportFormModel.Params>();
if (reportParams != null)
{
//OK
}
}
return Ok(result);
}
catch (Exception ex)
{
return BadRequest();
}
}
public static class Utilities
{
public static async Task<string> ReadFormFileAsync(this IFormFile file)
{
if (file == null || file.Length == 0)
{
return await Task.FromResult((string)null);
}
using var reader = new StreamReader(file.OpenReadStream());
return await reader.ReadToEndAsync();
}
}
Although this way is not appreciated, but it worked.
Apparently there is no built in way to do what I want. So I ended up writing my own ModelBinder
to handle this situation. I didn't find any official documentation on custom model binding but I used this post as a reference.
Custom ModelBinder
will search for properties decorated with FromJson
attribute and deserialize string that came from multipart request to JSON. I wrap my model inside another class (wrapper) that has model and IFormFile
properties.
IJsonAttribute.cs:
public interface IJsonAttribute
{
object TryConvert(string modelValue, Type targertType, out bool success);
}
FromJsonAttribute.cs:
using Newtonsoft.Json;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class FromJsonAttribute : Attribute, IJsonAttribute
{
public object TryConvert(string modelValue, Type targetType, out bool success)
{
var value = JsonConvert.DeserializeObject(modelValue, targetType);
success = value != null;
return value;
}
}
JsonModelBinderProvider.cs:
public class JsonModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
if (context.Metadata.IsComplexType)
{
var propName = context.Metadata.PropertyName;
var propInfo = context.Metadata.ContainerType?.GetProperty(propName);
if(propName == null || propInfo == null)
return null;
// Look for FromJson attributes
var attribute = propInfo.GetCustomAttributes(typeof(FromJsonAttribute), false).FirstOrDefault();
if (attribute != null)
return new JsonModelBinder(context.Metadata.ModelType, attribute as IJsonAttribute);
}
return null;
}
}
JsonModelBinder.cs:
public class JsonModelBinder : IModelBinder
{
private IJsonAttribute _attribute;
private Type _targetType;
public JsonModelBinder(Type type, IJsonAttribute attribute)
{
if (type == null) throw new ArgumentNullException(nameof(type));
_attribute = attribute as IJsonAttribute;
_targetType = type;
}
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null) throw new ArgumentNullException(nameof(bindingContext));
// Check the value sent in
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult != ValueProviderResult.None)
{
bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
// Attempt to convert the input value
var valueAsString = valueProviderResult.FirstValue;
bool success;
var result = _attribute.TryConvert(valueAsString, _targetType, out success);
if (success)
{
bindingContext.Result = ModelBindingResult.Success(result);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
}
Usage:
public class MyModelWrapper
{
public IList<IFormFile> Files { get; set; }
[FromJson]
public MyModel Model { get; set; } // <-- JSON will be deserialized to this object
}
// Controller action:
public async Task<IActionResult> Upload(MyModelWrapper modelWrapper)
{
}
// Add custom binder provider in Startup.cs ConfigureServices
services.AddMvc(properties =>
{
properties.ModelBinderProviders.Insert(0, new JsonModelBinderProvider());
});
I wanted to do the same using Vue frontend and .net core api. But for some weird reason IFormFile always returned null. So then I had to change it to IFormCollection and got it sorted out. Here is the code for anyone facing the same issue :)
public async Task<IActionResult> Post([FromForm]IFormCollection files)
There is simpler solution, heavily inspired by Andrius' answer. By using
the ModelBinderAttribute
you don't have to specify a model or binder provider. This saves a lot of code. Your controller action would look like this:
public IActionResult Upload(
[ModelBinder(BinderType = typeof(JsonModelBinder))] SomeObject value,
IList<IFormFile> files)
{
// Use serialized json object 'value'
// Use uploaded 'files'
}
Code behind JsonModelBinder
(see GitHub or use NuGet package):
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
public class JsonModelBinder : IModelBinder {
public Task BindModelAsync(ModelBindingContext bindingContext) {
if (bindingContext == null) {
throw new ArgumentNullException(nameof(bindingContext));
}
// Check the value sent in
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult != ValueProviderResult.None) {
bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
// Attempt to convert the input value
var valueAsString = valueProviderResult.FirstValue;
var result = Newtonsoft.Json.JsonConvert.DeserializeObject(valueAsString, bindingContext.ModelType);
if (result != null) {
bindingContext.Result = ModelBindingResult.Success(result);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
}
Here is an example of a raw http request as accepted by the controller action Upload
above.
A multipart/form-data
request is split into multiple parts each separated by the specified boundary=12345
. Each part got a name assigned in its Content-Disposition
-header. With these names default ASP.Net-Core
knows which part is bound to which parameter in the controller action.
Files that are bound to IFormFile
additionally need to specify a filename
as in the second part of the request. Content-Type
is not required.
Another thing to note is that the json parts need to be deserializable into the parameter types as defined in the controller action. So in this case the type SomeObject
should have a property key
of type string
.
POST http://localhost:5000/home/upload HTTP/1.1
Host: localhost:5000
Content-Type: multipart/form-data; boundary=12345
Content-Length: 218
--12345
Content-Disposition: form-data; name="value"
{"key": "value"}
--12345
Content-Disposition: form-data; name="files"; filename="file.txt"
Content-Type: text/plain
This is a simple text file
--12345--
Postman can be used to call the action and test your server side code. This is quite simple and mostly UI driven. Create a new request and select form-data in the Body-Tab. Now you can choose between text and file for each part of the reqeust.