Upload/Download Files using HttpClient

C# .NET language

In this new post, I show you how to upload/download files using HttpClient in C# and .NET Core. Creating a new version of the Markdown Editor component for Blazor, I face some issues with the file upload. So, I was working to find a solution and now I can tell you how to do it.

First, I will take a look at how to send multipart MIME data to a Web API using HttpClient. We will create two applications to demonstrate the data transfer between the client side and the server side. The server-side app is an ASP.NET Core web project, which includes a Web API controller for uploading and downloading files. The client-side app is a Console project, which contains a Typed HttpClient to send HTTP requests for file uploading and/or downloading.

When an application needs to talk to another system, it is quite common that the application sends data to and receives data from the other system using HttpClient in the back-end. Based on this article in Microsoft Docs, it is straightforward to send HTTP requests and receive HTTP responses. However, most of tutorials and blog posts don’t talk much about sending FormData with a file object and a collection of key/value pairs using HttpClient. This blog post intends to provide the missing guide.

The source code of this post is on GitHub. Please leave your comment at the end of this post or in the forum.

Web API for Uploading a File with FormData

This API action method follows the example in an article in Microsoft Docs. The implementation is lengthy, but the code logic demonstrates several checks to meet the security criteria. First, I’m going to create a Web API project with .NET6 and I create the controller ImageController. So, I create a HttpPost function for Upload.

[HttpPost]
[DisableFormValueModelBinding]
public async Task<IActionResult> Upload()
{
    if (!Request.ContentType.IsMultipartContentType())
    {
        ModelState.AddModelError("File", "The request couldn't be processed (Error 1).");
        _logger.LogWarning($"The request content type [{Request.ContentType}] is invalid.");
        return BadRequest(ModelState);
    }

    var formModel = new CustomFormModel();

    var boundary = MediaTypeHeaderValue.Parse(Request.ContentType).GetBoundary(
        new FormOptions().MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);
    var section = await reader.ReadNextSectionAsync();

    string trustedFileNameForFileStorage = String.Empty;

    while (section != null)
    {
        var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(
            section.ContentDisposition, 
            out var contentDisposition);

        if (hasContentDispositionHeader)
        {
            if (contentDisposition.IsFileDisposition())
            {
                // Don't trust the file name sent by the client.
                // To display the file name, HTML-encode the value.
                var trustedFileNameForDisplay = 
                        WebUtility.HtmlEncode(contentDisposition.FileName.Value);
                trustedFileNameForFileStorage = 
                        Path.GetFileNameWithoutExtension(Path.GetRandomFileName()) +
                        Path.GetExtension(trustedFileNameForDisplay);

                var streamedFileContent = await FileHelpers.ProcessStreamedFile(section, 
                    contentDisposition, ModelState, _permittedExtensions, _fileSizeLimit);

                if (!ModelState.IsValid)
                    return BadRequest(ModelState);

                var trustedFilePath = Path.Combine(_targetFolderPath, trustedFileNameForFileStorage);
                using (var targetStream = System.IO.File.Create(trustedFilePath))
                {
                    await targetStream.WriteAsync(streamedFileContent);
                    formModel.TrustedFilePath = trustedFilePath;
                    formModel.TrustedFileName = trustedFileNameForDisplay;
                    _logger.LogInformation($"Uploaded file '{trustedFileNameForDisplay}'" + 
                                           $" saved to '{_targetFolderPath}' " + 
                                           $"as {trustedFileNameForFileStorage}");
                }
            }
            else if (contentDisposition.IsFormDisposition())
            {
                var content = new StreamReader(section.Body).ReadToEnd();
                if (contentDisposition.Name == "userId" && int.TryParse(content, out var useId))
                    formModel.UserId = useId;

                if (contentDisposition.Name == "comment")
                    formModel.Comment = content;

                if (contentDisposition.Name == "isPrimary" && 
                        bool.TryParse(content, out var isPrimary))
                    formModel.IsPrimary = isPrimary;
            }
        }

        // Drain any remaining section body that hasn't been consumed and
        // read the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    if (!string.IsNullOrEmpty(trustedFileNameForFileStorage))
    {
        string host = $"{_httpContextAccessor.HttpContext.Request.Scheme}://" + 
                $"{_httpContextAccessor.HttpContext.Request.Host.Value}";
        int index = FileHelpers.GetExtensionId(Path.GetExtension(trustedFileNameForFileStorage));
        return Ok($"{host}/api/files/{index}/" + 
            $"{Path.GetFileNameWithoutExtension(trustedFileNameForFileStorage)}");
    }
    else
        return BadRequest("It wasn't possible to upload the file");
}

Code explained

I know, it is a lot of code. First, I check if the request has a multipart and if not, I raise an error. To work on the multipart, I created a helper. MultipartRequestHelper has 2 functions:

  • IsMultipartContentType: check the content type and verify if the is a multipart
  • GetBoundary: check the Boundary in the ContentType and it don’t exceed the length limit

Then, I create a new instance of CustomFormModel: this class is only for demo purpose to save some data from the POST request such as the user Id, a comment or a Boolean value. I don’t use this data but it is interesting to understand how to read them. In the client side, I will show you how to send this information.

Now, the procedure is starting to read each section of the multipart request. The section could contain a file or form data. If there is a file section, I’m going to read the file and return a byte[] (array of bytes) using the FileHelpers. If it is a form section, I read its body and try to match the name with a known variable.

At the end, if the file is saved on the file system and then trustedFileNameForFileStorage is not null, the API returns the full URL of the new uploaded image. To obtain the base URL of the API, I use HttpContextAccessor. So, I have the Scheme of the API (fo example HTTPS) and the host (for example localhost:4100). For using the HttpContextAccessor remember to add in the Startup.cs the dependency

services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

Then, I like to have a nice URL to share and for this reason I replace the extension with a number and then the new name of the file like that

https://localhost:44391/api/files/1/ys45k0ai

Maybe this URL is not very user-friendly, so, I like to move the Download function in the HomeController. So, the resulted URL is quite easy to read and write

https://localhost:44391/1/ys45k0ai

If you are thinking why I want to have a nice URL, the answer is easy. I’m working on the new version of the Markdown Editor for Blazor. So, I want to have a functionality to upload file and display a nice URL. I’ll keep you update about it.

MultipartRequestExtensions

public static class MultipartRequestExtensions
{
    // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
    // The spec at https://tools.ietf.org/html/rfc2046#section-5.1 states that 70 characters
    // is a reasonable limit.
    public static string GetBoundary(this MediaTypeHeaderValue contentType, int lengthLimit)
    {
        var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value;

        if (string.IsNullOrWhiteSpace(boundary))
            throw new InvalidDataException("Missing content-type boundary.");

        if (boundary.Length > lengthLimit)
          throw new InvalidDataException($"Multipart boundary length limit {lengthLimit} exceeded.");

        return boundary;
    }

    public static bool IsMultipartContentType(this string contentType)
    {
        return !string.IsNullOrEmpty(contentType)
               && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
    }
}

Send Multipart FormData using HttpClient

So, we need to use an HTTP POST method to send content to a server-side resource. The tricky part is constructing the HTTP request body content because we need to combine the file data and a collection of key/value pairs in one FormData object. The following code snippet shows an example solution.

public async Task<string> UploadFile(string filePath)
{
    _logger.LogInformation($"Uploading a text file [{filePath}].");
    if (string.IsNullOrWhiteSpace(filePath))
        throw new ArgumentNullException(nameof(filePath));

    if (!File.Exists(filePath))
        throw new FileNotFoundException($"File [{filePath}] not found.");

    using var form = new MultipartFormDataContent();
    using var fileContent = new ByteArrayContent(await File.ReadAllBytesAsync(filePath));
    fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data");
            
    form.Add(fileContent, "file", Path.GetFileName(filePath));
    form.Add(new StringContent("enrico"), "userId");
    form.Add(new StringContent("this is a comments"), "comment");
    form.Add(new StringContent("true"), "isPrimary");

    var response = await _httpClient.PostAsync($"{_url}/api/files", form);
    response.EnsureSuccessStatusCode();

    var result = await response.Content.ReadAsStringAsync();
    _logger.LogInformation("Uploading is complete.");
    _logger.LogInformation($"API Response: {result}\n\n");

    return result;
}

So, the method UploadFile(string filePath) first validates the physical file. Then line 10 instantiates a MultipartFormDataContent object, which is the request content sent to the server-side app.

Then, lines 11 and 11 create a ByteArrayContent object from the file content, and sets the ContentType header to be “multipart/form-data”Note: When a file is included in a form, the enctype attribute should always be “multipart/form-data”, which specifies that the form will be sent as a multipart MIME message. If the ContentType is not set, then it will default to be application/json, which is not what we want here.

Line 14 adds the file content to the form object, and sets the key to be “file”. The key can be different when multiple files are included in a form.

Lines 15 to 15 are examples of adding key/value pairs to the MultipartFormDataContent object. The values can only be represented as strings, and the server-side app will have to parse them into correct data types.

Line 19 sends the HTTP POST request when the request content is ready. Line 22 receives the HTTP response that contains the URL of the uploaded image if the upload has success.

Demo Application calls the API to upload an image - Upload/Download Files Using HttpClient
Demo Application calls the API to upload an image

Check the uploaded type

It is important to check what kind of file the procedure is going to save on the file system to avoid attach of any kind. For this reason, in the FileHelpers I want to check the header of the uploading file to be sure is a genuine file. So, for that, we can read the header of a file. If you see the code of this helper, you see this definition:

private static readonly Dictionary<string, List<byte[]>> FileSignature = 
            new Dictionary<string, List<byte[]>>
{
    { ".gif", new List<byte[]> {
        new byte[] { 0x47, 0x49, 0x46, 0x38 } }
    },
    { ".png", new List<byte[]> {
        new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } }
    }
}

So, the procedure receives from the FormData the image in byte[]. Then, it is easy to check the first characters for a specific extension. If the file starts with the expected signature, the file is valid.

How to know the signature of an extension? There is an amazing website for that: File Signature Database. If you search for gif extension, the website gives the right signature

Signature for GIF on File Signature website - Upload/Download Files Using HttpClient
Signature for GIF on File Signature website

Then, I added in the FileSignature a new Dictionary with the list of bytes for a specific file type, such as GIF that has a signature 47 49 46 38.

Wrap up

In conclusion, this is a good example how to upload/download Files using HttpClient and the full source code is on GitHub. Also, very soon, I will release a new component for Blazor that implement a Markdown Editor to replace the simple and current one. Stay tuned!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.