Handling multipart requests with JSON and file uploads in ASP.NET Core

Suppose we’re writing an API for a blog. Our "create post" endpoint should receive the title, body, tags and an image to display at the top of the post. This raises a question: how do we send the image? There are at least 3 options:

  • Embed the image bytes as base64 in the JSON payload, e.g.

    {
        "title": "My first blog post",
        "body": "This is going to be the best blog EVER!!!!",
        "tags": [ "first post", "hello" ],
        "image": "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="
    }
    

    This works fine, but it’s probably not a very good idea to embed an arbitrarily long blob in JSON, because it could use a lot of memory if the image is very large.

  • Send the JSON and image as separate requests. Easy, but what if we want the image to be mandatory? There’s no guarantee that the client will send the image in a second request, so our post object will be in an invalid state.

  • Send the JSON and image as a multipart request.

The last approach seems the most appropriate; unfortunately it’s also the most difficult to support… There is no built-in support for this scenario in ASP.NET Core. There is some support for the multipart/form-data content type, though; for instance, we can bind a model to a multipart request body, like this:

public class MyRequestModel
{
    [Required]
    public string Title { get; set; }
    [Required]
    public string Body { get; set; }
    [Required]
    public IFormFile Image { get; set; }
}

public IActionResult Post([FromForm] MyRequestModel request)
{
    ...
}

But if we do this, it means that each property maps to a different part of the request; we’re completely giving up on JSON.

There’s also a MultipartReader class that we can use to manually decode the request, but it means we have to give up model binding and automatic model validation entirely.

Custom model binder

Ideally, we’d like to have a request model like this:

public class CreatePostRequestModel
{
    [Required]
    public string Title { get; set; }
    [Required]
    public string Body { get; set; }
    public string[] Tags { get; set; }
    [Required]
    public IFormFile Image { get; set; }
}

Where the Title, Body and Tags properties come from a form field containing JSON and the Image property comes from the uploaded file. In other words, the request would look like this:

POST /api/blog/post HTTP/1.1
Content-Type: multipart/form-data; boundary=AaB03x
 
--AaB03x
Content-Disposition: form-data; name="json"
Content-Type: application/json
 
{
    "title": "My first blog post",
    "body": "This is going to be the best blog EVER!!!!",
    "tags": [ "first post", "hello" ]
}
--AaB03x
Content-Disposition: form-data; name="image"; filename="image.jpg"
Content-Type: image/jpeg
 
(... content of the image.jpg file ...)
--AaB03x

Fortunately, ASP.NET Core is very flexible, and we can actually make this work, by writing a custom model binder.

Here it is:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;

namespace TestMultipart.ModelBinding
{
    public class JsonWithFilesFormDataModelBinder : IModelBinder
    {
        private readonly IOptions<MvcJsonOptions> _jsonOptions;
        private readonly FormFileModelBinder _formFileModelBinder;

        public JsonWithFilesFormDataModelBinder(IOptions<MvcJsonOptions> jsonOptions, ILoggerFactory loggerFactory)
        {
            _jsonOptions = jsonOptions;
            _formFileModelBinder = new FormFileModelBinder(loggerFactory);
        }

        public async Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
                throw new ArgumentNullException(nameof(bindingContext));

            // Retrieve the form part containing the JSON
            var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.FieldName);
            if (valueResult == ValueProviderResult.None)
            {
                // The JSON was not found
                var message = bindingContext.ModelMetadata.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(bindingContext.FieldName);
                bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, message);
                return;
            }

            var rawValue = valueResult.FirstValue;

            // Deserialize the JSON
            var model = JsonConvert.DeserializeObject(rawValue, bindingContext.ModelType, _jsonOptions.Value.SerializerSettings);

            // Now, bind each of the IFormFile properties from the other form parts
            foreach (var property in bindingContext.ModelMetadata.Properties)
            {
                if (property.ModelType != typeof(IFormFile))
                    continue;

                var fieldName = property.BinderModelName ?? property.PropertyName;
                var modelName = fieldName;
                var propertyModel = property.PropertyGetter(bindingContext.Model);
                ModelBindingResult propertyResult;
                using (bindingContext.EnterNestedScope(property, fieldName, modelName, propertyModel))
                {
                    await _formFileModelBinder.BindModelAsync(bindingContext);
                    propertyResult = bindingContext.Result;
                }

                if (propertyResult.IsModelSet)
                {
                    // The IFormFile was sucessfully bound, assign it to the corresponding property of the model
                    property.PropertySetter(model, propertyResult.Model);
                }
                else if (property.IsBindingRequired)
                {
                    var message = property.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(fieldName);
                    bindingContext.ModelState.TryAddModelError(modelName, message);
                }
            }

            // Set the successfully constructed model as the result of the model binding
            bindingContext.Result = ModelBindingResult.Success(model);
        }
    }
}

To use it, just apply this attribute to the CreatePostRequestModel class above:

[ModelBinder(typeof(JsonWithFilesFormDataModelBinder), Name = "json")]
public class CreatePostRequestModel

This tells ASP.NET Core to use our custom model binder to bind this class. The Name = "json" part tells our binder from which field of the multipart request it should read the JSON (this is the bindingContext.FieldName in the binder code).

Now we just need to pass a CreatePostRequestModel to our controller action, and we’re done:

[HttpPost]
public ActionResult<Post> CreatePost(CreatePostRequestModel post)
{
    ...
}

This approach enables us to have a clean controller code and keep the benefits of model binding and validation. It messes up the Swagger/OpenAPI model though, but hey, you can’t have everything!

43 thoughts on “Handling multipart requests with JSON and file uploads in ASP.NET Core”

    1. What do you mean? You don’t fill it, ASP.NET Core binds it automatically to a file upload in a multipart/form-data request.

  1. Nice post – thanks!

    I’m having an issue though.
    For me, the json value can’t be found:

    var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.FieldName);

    valueResult is None

    What is the structure of the request you make?
    Does the JSON have to be in a field called ‘json’ for this to work??

    [ModelBinder(typeof(JsonWithFilesFormDataModelBinder), Name = “json”)]

    1. Hi Jeff,

      > Does the JSON have to be in a field called ‘json’ for this to work??

      Yes, or change the name in the attribute to match the field name.

      The request should look something like this:

      POST /api/blog/post HTTP/1.1
      Content-Type: multipart/form-data; boundary=AaB03x
       
      --AaB03x
      Content-Disposition: form-data; name="json"
      Content-Type: application/json
       
      {
          "title": "My first blog post",
          "body": "This is going to be the best blog EVER!!!!",
          "tags": [ "first post", "hello" ]
      }
      --AaB03x
      Content-Disposition: form-data; name="image"; filename="image.jpg"
      Content-Type: image/jpeg
       
      (... content of the image.jpg file...)
      --AaB03x
      
  2. Aah I see – the “json” name refers to the form-data name. Makes sense now.
    I didn’t find that part clear from your post – you might wanna tweak it or include the example request in the post?

    Great post – thanks!

      1. Hi,
        Thanks for the prompt reply!
        But I didn’t get where to use `json` field, could you please show me sample request params?

        1. https://i.imgur.com/OBq7Xu7.png

          You shouldn’t set Content-Disposition at the request level, it’s normally set at the form-data field level (it’s done automatically by Postman). Postman doesn’t let you control the Content-Type for form-data fields, so you can’t set it to application/json, but it doesn’t really matter, since the binder explicitly treats it as JSON.

  3. Thanks, this worked for me too and was a more elegant solution to the one we were contemplating.

    I agree about the comment above where it wasn’t quite clear what name=”json” meant but a quick step-through solved that.

  4. Hi Thomas

    First, thanks for this solution, I spent hours before finding it.

    In my case, I would like a CreatePostRequestModel like this

    public class CreatePostRequestModel
    {
    [Required]
    public string Title { get; set; }
    [Required]
    public string Body { get; set; }
    public string[] Tags { get; set; }
    [Required]
    public IList Images { get; set; } // a list of images!!!!!
    }

    What do I need to change in the model binder to accept not only one file but a list of files.

    Thanks

  5. For multiple files:

    Change JsonWithFilesFormDataModelBinder:
    if (property.ModelType != typeof(List))

    Change post model:
    public List Files { get; set; }

    1. if (property.ModelType != typeof(IFormFileCollection))
      continue;

      and

      public IFormFileCollection file { get; set; }

  6. Good Afternoon,

    What happen if instead of using [FromForm] attribute we are using [FromBody]?
    Is is possible to make this solution work? How would you do it?

  7. Hi Thomas,
    Excellent article! The only thing I didn’t get is
    “But if we do this, it means that each property maps to a different part of the request;”. I mean, what’s wrong with that approach? Wouldn’t it be the same result at the end?

    1. Hi Miroslav,
      Yes, in the end it would work as well, but if you care about the shape of your API, that’s probably not what you want. It’s a bit ugly, and not very efficient, because each part of a multipart request has a boundary and headers, so a lot of bytes are wasted.

  8. How would this work in the scenario that the client is strictly HTML5 (no .Net) and the ASP.Net Core service is on a separate server? I’ve yet to see an example work where iFormFile works in that scenario. It seems not to understand the Multi-Part form post in that scenario.

    1. Hi David,

      My solution makes no assumption regarding the client, it can be anything. The fact that the ASP.NET Core backend is on a separate server shouldn’t matter either, assuming your CORS configuration is correct (i.e. allows the frontend to call the backend).

  9. Hello Guys,

    Thanks, It is working fine
    I have a question, Can we add the file nested property of with json? I do not want to get the file from root.

    1. Hi Vikas,
      I’m not sure I understand what you’re saying… the file isn’t in the JSON, it’s in a separate part of the request body.

  10. This works great with one exception: Individual properties on the JSON model don’t seem to be validated. Do you have any suggestions on how it could be enabled?

  11. Hi Thomas,
    I have re-read your article several times but still have the “json value can’t be found:”

    How do you assign a name(json) to the form data. I am using a Razor page that included 3 models, one model that has this custom binding.

    Can you perhaps include the html for your sample?

    1. Hi Johan,

      I didn’t really consider how it would be done from HTML. The idea isn’t to post directly from a form, since the data is posted as JSON. If you want to post directly from a form, don’t use JSON at all, just map the form fields directly to the model.

      1. The form fields are the model’s fields. it is a razor page. I would actually like to post the files and model to an api(the model is serialized to json in that call.) I have ModelState.IsValid = false because once i use the custombinder on the model it becomes null as it fails to find anything called “json” this happens on form submit.
        (var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.FieldName);
        valueResult is null. it seems the form needs to post a json string called “json” containing a sub part of the model (the part not containing the IFormFile fields). it is not clear how to accomplish this without adding javascript to the html page.

        1. it seems the form needs to post a json string called “json” containing a sub part

          No, it’s a part of the multipart request that should be named “json” and contained the serialized fields.

          it is not clear how to accomplish this without adding javascript to the html page

          I don’t think it’s possible. The solution I propose is intended to be used from JS code, where you build the request yourself. If you post the form directly to the API, you have no control over how the request is sent.

  12. Hi Thomas, thanks for this article.
    But how this approach could extend to post a list of objects, like:

    [HttpPost]
    public ActionResult CreatePost(List post)
    {

    }

    1. Hi Renan,

      I don’t know exactly how to make this work… You could try to modify the model binder to handle arrays of IFormFile, I guess. The model would probably need to be adjusted as well.

  13. Hi Thomas Levesque,
    Thanks for the great article.
    Still have no idea how to make the client post with HttpClient.
    I know t

    Right now, I have IFormFile and one Object Model to be sent to Web API.
    Could you write a sample client with HttpClient?
    I know the key is to create MultipartFormDataContent.

    Thanks.

    1. Hi Alvin,

      You could do something like this:

      var content = new MultipartFormDataContent();
      content.Add(new StringContent(json, Encoding.UTF8, "application/json"), "json");
      content.Add(new StreamContent(fileStream), "image");
      var request = new HttpRequestMessage(HttpMethod.Post, "foo/bar")
      {
          Content = content
      };
      
  14. Hi Thomas Levesque,
    I can hit the api Controller with Postman, but still no luck on Web API Call.
    Below is my sample code…

    string filePath = @"G:\DMSFile\Candidate\2019\6\28-Amy Chen-2.pdf";
    AttachmentMetadataModel jsonObject = new AttachmentMetadataModel
    {
                    FileName = "test",
                    FileExtension = ".pdf",
                    Description = "My Description",
                    ModerfiedBy = "James Bond",
                    DocumentId = 17
    };
    var stream = new FileStream(filePath, FileMode.Open);
    var formDataContent = new MultipartFormDataContent();
    formDataContent.Add(new StringContent(JsonConvert.SerializeObject(jsonObject), Encoding.UTF8, "application/json"), "json");
     formDataContent.Add(new StreamContent(stream), "files", "file");
    HttpClient httpCleint = new HttpClient();
    HttpResponseMessage response = await httpCleint.PostAsync(uri, formDataContent);
    // reponse is BadRequest. 
    

    Do I miss something here?
    Thanks.

  15. Hi all,
    Finally making it work.
    For File FormDataContent, we need to add Headers on it.

    So, the sample code is like ….

    // For Json Object
    formDataContent.Add(new StringContent(JsonConvert.SerializeObject(jsonObject), Encoding.UTF8, "application/json"), "json");
    // For File
    HttpContent fileStreamContent = new StreamContent(stream);
    fileStreamContent.Headers.ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("form-data") { Name = "file", FileName = fileName };
    fileStreamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
    formDataContent.Add(fileStreamContent);
    
    HttpClient httpCleint = new HttpClient();
    HttpResponseMessage response = await httpCleint.PostAsync(uri, formDataContent);
    

    Thanks for the great post~~

Leave a Reply to Sean Cancel reply

Your email address will not be published. Required fields are marked *