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(); }}
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);}
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--
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(); }}
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; }}
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);}
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--
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; }}
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; }}
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);}
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--
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(); }}
public class FileDataDto{ public List<IFormFile> FilesToUpload { get; set; }
[ModelBinder(BinderType = typeof(FormDataJsonBinder))] public DataDto Data { get; set; }}
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 ...};
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--
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.