December 15, 2012

Sending additional form data in multipart uploads with ASP.NET Web API

If you’ve used ASP.NET MVC you’ll be used to being able to easily process multipart form data on the server by virtue of the ASP.NET MVC model binder.

Unfortunately things are not quite so simple in ASP.NET Web API. Most of the examples I have seen demonstrate how to upload a file but do not cover how to send additional form data with the request.

The example below (taken from the Fabrik API Client) demonstrates how to upload a file using HttpClient:

public async Task<IEnumerable<MediaUploadResult>> UploadMedia(int siteId, params UploadMediaCommand[] commands)
{
    Ensure.Argument.NotNull(commands, "commands");
    Ensure.Argument.Is(commands.Length > 0, "You must provide at least one file to upload.");

    var formData = new MultipartFormDataContent();

    foreach (var command in commands)
    {
        formData.Add(new StreamContent(command.FileStream), command.FileName, command.FileName);
    }
   
    var request = api.CreateRequest(HttpMethod.Post, api.CreateRequestUri(GetMediaPath(siteId)));
    request.Content = formData;

    var response = await api.HttpClient.SendAsync(request).ConfigureAwait(false);
    return await response.Content.ReadAsAsync<IEnumerable<MediaUploadResult>>().ConfigureAwait(false);
}

The UploadMediaCommand passed to this method contain a Stream object that we’ve obtained from an uploaded file in ASP.NET MVC.

As you can see, we loop through each command (file) and add it to the MultipartFormDataContent. This effectively allows us to perform multiple file uploads at once.

When making some changes to our API recently I realized we needed a way to correlate the files we uploaded with the MediaUploadResult objects sent back in the response. We therefore needed to send a unique identifier for each file included in the multipart form.

Since the framework doesn’t really offer a nice way of adding additional form data to MultiPartFormDataContent, I’ve created a few extension methods below that you can use to easily send additional data with your file uploads.

/// <summary>
/// Extensions for <see cref="System.Net.Http.MultipartFormDataContent"/>.
/// </summary>
public static class MultiPartFormDataContentExtensions
{       
    public static void Add(this MultipartFormDataContent form, HttpContent content, object formValues)
    {        
        Add(form, content, formValues);
    }

    public static void Add(this MultipartFormDataContent form, HttpContent content, string name, object formValues)
    {
        Add(form, content, formValues, name: name);
    }

    public static void Add(this MultipartFormDataContent form, HttpContent content, string name, string fileName, object formValues)
    {
        Add(form, content, formValues, name: name, fileName: fileName);
    }

    private static void Add(this MultipartFormDataContent form, HttpContent content, object formValues, string name = null, string fileName = null)
    {           
        var header = new ContentDispositionHeaderValue("form-data");
        header.Name = name;
        header.FileName = fileName;
        header.FileNameStar = fileName;

        var headerParameters = new HttpRouteValueDictionary(formValues);
        foreach (var parameter in headerParameters)
        {
            header.Parameters.Add(new NameValueHeaderValue(parameter.Key, parameter.Value.ToString()));
        }

        content.Headers.ContentDisposition = header;
        form.Add(content);
    }
}

With these extensions in place I can now update our API client to do the following:

foreach (var command in commands)
{
    formData.Add(
		new StreamContent(command.FileStream), 
		command.FileName, command.FileName,
        new { 
			CorrelationId = command.CorrelationId, 
			PreserveFileName = command.PreserveFileName 
		}
	);
}

This sets the content disposition header like so:

Content-Disposition: form-data; 
	name=CS_touch_icon.png; 
	filename=CS_touch_icon.png; 
	filename*=utf-8''CS_touch_icon.png; 
	CorrelationId=d4ddd5fb-dc14-4e93-9d87-babfaca42353; 
	PreserveFileName=False

On the API, to read we can loop through each file in the upload and access the additional data like so:

foreach (var file in FileData) {
    var contentDisposition = file.Headers.ContentDisposition;
    var correlationId = GetNameHeaderValue(contentDisposition.Parameters, "CorrelationId");
}

Using the following helper method:

private static string GetNameHeaderValue(ICollection<NameValueHeaderValue> headerValues, string name)
{           
    if (headerValues == null)
        return null;

    var nameValueHeader = headerValues.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
    return nameValueHeader != null ? nameValueHeader.Value : null;
}

In case you were interested below is the updated code we are using to process the uploaded files within ASP.NET MVC:

[HttpPost]
public async Task<ActionResult> Settings(SiteSettingsModel model)
{
    await HandleFiles(new[] {
        Tuple.Create<HttpPostedFileBase, Action<string>>(model.LogoFile, uri => model.LogoUri = uri),
        Tuple.Create<HttpPostedFileBase, Action<string>>(model.IconFile, uri => model.IconUri = uri ),
        Tuple.Create<HttpPostedFileBase, Action<string>>(model.FaviconFile, uri => model.FaviconUri = uri)
    });

    await siteClient.UpdateSiteSettings(Customer.CurrentSite, model);

    return RedirectToAction("settings")
        .AndAlert(AlertType.Success, "Success!", "Your site settings were updated successfully.");
}

private async Task HandleFiles(Tuple<HttpPostedFileBase, Action<string>>[] files)
{
    var uploadRequests = (from file in files
                          where file.Item1.IsValid() // ensures a valid file
                          let correlationId = Guid.NewGuid().ToString()
                          select new
                          {
                              CorrelationId = correlationId,
                              Command = file.Item1.ToUploadMediaCommand(correlationId),
                              OnFileUploaded = file.Item2
                          }).ToList();

    if (uploadRequests.Any())
    {
        var results = await mediaClient.UploadMedia(Customer.CurrentSite,
            uploadRequests.Select(u => u.Command).ToArray());

        foreach (var result in results)
        {
            // find the original request using the correlation id
            var request = uploadRequests.FirstOrDefault(r => r.CorrelationId == result.CorrelationId);
            if (request != null)
            {
                request.OnFileUploaded(result.Uri);
            }
        }
    }
}

© 2022 Ben Foster