Sending files and additional data using HttpClient in .NET Core

How to send a multipart HTTP requests containing files using HttpClient in .NET Core? What to do if the target endpoint requires some additional data? This article describes the solutions for common cases and presents raw HTTP messages that are generated by the HttpClient for multipart requests.

Sending files and additional data using HttpClient in .NET Core

Sending a single file

Say we've got an existing .NET Core app running on localhost:5001 with a single endpoint defined like below. For simplicity, we return the 200 status code.

[ApiController][Route("file")]public class FileController : ControllerBase{    [HttpPost]    public IActionResult Upload([FromForm] IFormFile file)    {        // code responsible for file processing        return Ok();    }}
C#

Ok, now we want to send a request to this endpoint from another app using HttpClient. Because the endpoint’s argument file is decorated with the FromForm attribute it expects a multipart/form-data content type.

Firstly, we initialize the HttpClient. Note that, in real life, it’s not a good practice to create HttpClient on every request. That’s because of the socket exhaustion problem. However, it is out of scope of this article.

The request itself is defined by the HttpRequestMessage object and MultipartFormDataContent attached to it. The MultipartFormDataContent contains a single file stream that we want to send. The "file" is a name of an argument with type IFormFile required by the target endpoint 🌐.

private static async Task UploadSampleFile(){    var client = new HttpClient    {        BaseAddress = new("https://localhost:5001")    };
    await using var stream = System.IO.File.OpenRead("./Test.txt");    using var request = new HttpRequestMessage(HttpMethod.Post, "file");    using var content = new MultipartFormDataContent    {        { new StreamContent(stream), "file", "Test.txt" }    };
    request.Content = content;
    await client.SendAsync(request);}
C#

Below you can see raw HTTP request produced by this code. When we execute our client app, then the file is mapped to the file argument on the endpoint side 👏.

POST https://localhost:5001/file HTTP/1.1Host: localhost:5001traceparent: 00-993857cecaf029429fa329eda04bf984-5241706235957046-00Content-Type: multipart/form-data; boundary="f18c569a-003f-4395-baa0-1fbe71efee38"Content-Length: 178
--f18c569a-003f-4395-baa0-1fbe71efee38Content-Disposition: form-data; name=file; filename=Test.txt; filename*=utf-8''Test.txt
--f18c569a-003f-4395-baa0-1fbe71efee38--
HTTP
sending a single file with http client - single file

Sending a file with additional data

What to do if the target endpoint accepts a single file and some additional fields?

[ApiController][Route("file")]public class FileController : ControllerBase{    [HttpPost]    public IActionResult Upload([FromForm] FileDataDto dto)    {        // code responsible for file processing        return Ok();    }}
C#
public class FileDataDto{    public IFormFile FileToUpload1 { get; set; }    public DataDto Data { get; set; }}
public class DataDto{    public string Name { get; set; }    public string[] Tags { get; set; }    public ChildDataDto ChildData { get; set; }}
public class ChildDataDto{    public string Description { get; set; }}
C#

The key thing on the client side is to prepare a request object that will be correctly mapped by the model binder to FileDataDto. Again, because the endpoint’s DTO is decorated with the FromForm attribute, it expects a multipart request.

This time the MultipartFormDataContent contains a collection of HttpContent objects. We specify StreamContent containing the file’s stream and multiple objects of the type StringContent.

Each StringContent object defines a single property that will be mapped to DataDto in the target endpoint. For instance, in order to populate the Name property on DataDto, we need to specify the whole path to this property as a content’s name. Collection properties like Tags can be populated with multiple StringContent objects with the same name.

private static async Task UploadSampleFile(){    var client = new HttpClient    {        BaseAddress = new("https://localhost:5001")    };
    await using var stream = System.IO.File.OpenRead("./Test.txt");
    var payload = new    {        Name = "payload name",        Tags = new[] { "tag1", "tag2" },        ChildData = new        {            Description = "test description"        }    };
    using var request = new HttpRequestMessage(HttpMethod.Post, "file");
    using var content = new MultipartFormDataContent    {        // file        { new StreamContent(stream), "FileToUpload1", "Test.txt" },
        // payload        { new StringContent(payload.Name), "Data.Name" },        { new StringContent(payload.Tags[0]), "Data.Tags" },        { new StringContent(payload.Tags[1]), "Data.Tags" },        { new StringContent(payload.ChildData.Description), "Data.ChildData.Description" }    };
    request.Content = content;
    await client.SendAsync(request);}
C#

As you can see, this approach is not convenient. You must manually create multiple string contents for each target property. Perhaps it would be worth considering a helper method that converts the payload to a collection of StringContent objects?

Here is the raw HTTP request. As expected, it consists of stream content and multiple string contents. The model binder can populate the target DTO correctly 👍.

POST https://localhost:5001/file HTTP/1.1Host: localhost:5001traceparent: 00-853f01b7762c9746912e3775865d60ba-c43a730c1a434848-00Content-Type: multipart/form-data; boundary="949a4299-4662-4775-94e2-de82e76936a5"Content-Length: 772
--949a4299-4662-4775-94e2-de82e76936a5Content-Disposition: form-data; name=fileToUpload1; filename=Test.txt; filename*=utf-8''Test.txt

--949a4299-4662-4775-94e2-de82e76936a5Content-Type: text/plain; charset=utf-8Content-Disposition: form-data; name=Data.Name
payload name--949a4299-4662-4775-94e2-de82e76936a5Content-Type: text/plain; charset=utf-8Content-Disposition: form-data; name=Data.Tags
tag1--949a4299-4662-4775-94e2-de82e76936a5Content-Type: text/plain; charset=utf-8Content-Disposition: form-data; name=Data.Tags
tag2--949a4299-4662-4775-94e2-de82e76936a5Content-Type: text/plain; charset=utf-8Content-Disposition: form-data; name=Data.ChildData.Description
test description--949a4299-4662-4775-94e2-de82e76936a5--
HTTP
sending files with http client - single file with additional data

The alternative approach – sending additional data as JSON

The target endpoint might be prepared to accept the application/json content type for additional data. It needs custom model binders that deserializes the JSON content to the target type. In this case, the Data property is decorated with the ModelBinder attribute that takes the type of a custom binder.

public class FileDataDto{    public IFormFile FileToUpload1 { get; set; }
    [ModelBinder(BinderType = typeof(FormDataJsonBinder))]    public DataDto Data { get; set; }}
C#
public class FormDataJsonBinder : IModelBinder{    private readonly ILogger<FormDataJsonBinder> _logger;
    public FormDataJsonBinder(ILogger<FormDataJsonBinder> logger)    {        _logger = logger;    }
    public Task BindModelAsync(ModelBindingContext bindingContext)    {        if (bindingContext == null)        {            throw new ArgumentNullException(nameof(bindingContext));        }
        var modelName = bindingContext.ModelName;
        var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
        if (valueProviderResult == ValueProviderResult.None)        {            return Task.CompletedTask;        }
        bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);
        var value = valueProviderResult.FirstValue;
        if (string.IsNullOrEmpty(value))        {            return Task.CompletedTask;        }
        try        {            var result = JsonSerializer.Deserialize(value, bindingContext.ModelType);            bindingContext.Result = ModelBindingResult.Success(result);        }        catch (Exception ex)        {            _logger.LogError(ex, ex.Message);            bindingContext.Result = ModelBindingResult.Failed();        }
        return Task.CompletedTask;    }}
C#

Now, the client itself requires some minor adjustments. Instead of sending individual objects of type StringContent, we send the aggregated one with a serialized payload.

private static async Task UploadSampleFile(){    var client = new HttpClient    {        BaseAddress = new("https://localhost:5001")    };
    await using var stream = System.IO.File.OpenRead("./Test.txt");
    var payload = new    {        Name = "payload name",        Tags = new[] { "tag1", "tag2" },        ChildData = new        {            Description = "test description"        }    };
    using var request = new HttpRequestMessage(HttpMethod.Post, "file");
    using var content = new MultipartFormDataContent    {        // file        { new StreamContent(stream), "FileToUpload1", "Test.txt" },
        // payload        { new StringContent(            JsonSerializer.Serialize(payload),            Encoding.UTF8,            "application/json"),            "Data" },    };
    request.Content = content;
    await client.SendAsync(request);}
C#

Everything is ready 🎉. The HttpClient produces an HTTP message like the one below. The FileDataDto is fully populated.

POST https://localhost:5001/file HTTP/1.1Host: localhost:5001traceparent: 00-7b5c963871ed1c4986ecada82e82c544-b963915470a64149-00Content-Type: multipart/form-data; boundary="f06a5661-47cd-45bd-a90f-69c4e2f88c64"Content-Length: 414
--f06a5661-47cd-45bd-a90f-69c4e2f88c64Content-Disposition: form-data; name=fileToUpload1; filename=Test.txt; filename*=utf-8''Test.txt

--f06a5661-47cd-45bd-a90f-69c4e2f88c64Content-Type: application/json; charset=utf-8Content-Disposition: form-data; name=Data
{"Name":"payload name","Tags":["tag1","tag2"],"ChildData":{"Description":"test description"}}--f06a5661-47cd-45bd-a90f-69c4e2f88c64--
HTTP

Sending multiple files (with additional data)

What if the target endpoint accepts multiple files?

[ApiController][Route("file")]public class FileController : ControllerBase{    [HttpPost]    public IActionResult Upload([FromForm] FileDataDto dto)    {        // code responsible for file processing        return Ok();    }}
C#
public class FileDataDto{    public List<IFormFile> FilesToUpload { get; set; }
    [ModelBinder(BinderType = typeof(FormDataJsonBinder))]    public DataDto Data { get; set; }}
C#

The only thing that we need to do on the client side is to adjust the MultipartFormDataContent by adding another file stream. Note that both contents should have the same name FilesToUpload. I didn't duplicate the whole client's code again so go back if you missed something 😅.

using var content = new MultipartFormDataContent{    // files    { new StreamContent(stream), "FilesToUpload", "Test.txt" },    { new StreamContent(anotherStream), "FilesToUpload", "NewTest.txt" },
    // payload    ...};
C#

Everything works as expected!

POST https://localhost:5001/file HTTP/1.1Host: localhost:5001traceparent: 00-882ae85a7db6a74597e78460578430ba-590a3900255d784c-00Content-Type: multipart/form-data; boundary="d9b747c2-c028-4cc2-b53d-904af4f558f1"Content-Length: 565
--d9b747c2-c028-4cc2-b53d-904af4f558f1Content-Disposition: form-data; name=FilesToUpload; filename=Test.txt; filename*=utf-8''Test.txt

--d9b747c2-c028-4cc2-b53d-904af4f558f1Content-Disposition: form-data; name=FilesToUpload; filename=NewTest.txt; filename*=utf-8''NewTest.txt

--d9b747c2-c028-4cc2-b53d-904af4f558f1Content-Type: application/json; charset=utf-8Content-Disposition: form-data; name=Data
{"Name":"payload name","Tags":["tag1","tag2"],"ChildData":{"Description":"test description"}}--d9b747c2-c028-4cc2-b53d-904af4f558f1--
HTTP
sending files with http client - multiple files with additional data

Conclusion

I hope that this article helped you understand how to utilize the HttpClient to send multipart requests. I tried to cover all scenarios but, in the world of programming, there is an endless number of various cases 😅. Questions or problems? Leave the comment below.

Comments

Add comment
The comments system is based on GitHub Issues API. Once you add your comment to the linked issue on my GitHub repository, it will show up below.
© brokul.dev 2024