3

I'm working on a project in .NET 4 and Web API 2, adding a file upload field to an already-implemented controller. I've found that Web API doesn't support multipart/form-data POST requests by default, and I need to write my own formatter class to handle them. Fine.

Ideally, what I'd like to do is use the existing formatter to populate the model, then add the file data before returning the object. This file upload field is being attached to six separate models, all of which are very complex (classes containing lists of classes, enums, guids, etc.). I've run into a few snags...

I tried implementing it manually using the source code for FormUrlEncodedMediaTypeFormatter.cs as an example. I found that it constructs a list of KeyValue pairs for each field (which I can easily do), then parses them using FormUrlEncodedJson.Parse(). I can't use FormUrlEncodedJson, because it's (for some reason?) marked Internal.

I started implementing my own parser, but when I hit about line 50, I thought to myself: I must be doing something wrong. There must be some way to populate the object with one of the existing Formatters, right? Surely they didn't expect us to write a new formatter for every single model or, even worse, writing our own more-fragile version of FormUrlEncodedJson.Parse()?

What am I missing here? I'm stumped.

// Multipart/form-data formatter adapted from: 
// http://stackoverflow.com/questions/17924655/how-create-multipartformformatter-for-asp-net-4-5-web-api
public class MultipartFormFormatter : FormUrlEncodedMediaTypeFormatter
{
    private const string StringMultipartMediaType = "multipart/form-data";
    //private const string StringApplicationMediaType = "application/octet-stream";

    public MultipartFormFormatter()
    {
        this.SupportedMediaTypes.Add(new MediaTypeHeaderValue(StringMultipartMediaType));
        //this.SupportedMediaTypes.Add(new MediaTypeHeaderValue(StringApplicationMediaType));
    }

    public override bool CanReadType(Type type)
    {
        return true;
    }

    public override bool CanWriteType(Type type)
    {
        return false;
    }

    public override async Task<object> ReadFromStreamAsync(Type type, Stream readStream, HttpContent content, IFormatterLogger formatterLogger)
    {
        var parts = await content.ReadAsMultipartAsync();
        var obj = Activator.CreateInstance(type);
        var propertiesFromObj = obj.GetType().GetRuntimeProperties().ToList();

        // *****
        // * Populate obj using FormUrlEncodedJson.Parse()? How do I do this?
        // *****

        foreach (var property in propertiesFromObj.Where(x => x.PropertyType == typeof(AttachedDocument)))
        {
            var file = parts.Contents.FirstOrDefault(x => x.Headers.ContentDisposition.Name.Contains(property.Name));

            if (file == null || file.Headers.ContentLength <= 0) continue;

            try
            {
                var fileModel = new AttachedDocument()
                {
                    ServerFilePath = ReadToTempFile(file.ReadAsStreamAsync().Result),
                };
                property.SetValue(obj, fileModel);
            }
            catch (Exception e)
            {
                // TODO: proper error handling
            }
        }

        return obj;
    }

    /// <summary>
    /// Reads a file from the stream and writes it to a temporary directory
    /// </summary>
    /// <param name="input"></param>
    /// <returns>The path of the written temporary file</returns>
    private string ReadToTempFile(Stream input)
    {
        var fileName = Path.GetTempFileName();
        var fileInfo = new FileInfo(fileName);
        fileInfo.Attributes = FileAttributes.Temporary;

        var buffer = new byte[16 * 1024];
        using (var writer = File.OpenWrite(fileName))
        {
            int read;
            while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
            {
                writer.Write(buffer, 0, read);
            }
        }

        return fileName;
    }
}

EDIT: after stewing on this for way too many hours, I've come to the conclusion that what I want to do is basically impossible. After talking to my boss, we've decided the best alternative is to make a second controller that accepts a file and then associates it to the rest of the form data, and put the onus on the front-end developers to do much more work to support that scenario.

I've extremely disappointed in the designers of Web API for making such a common use-case so difficult (if at all possible!) to pull off.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131

1 Answers1

0

It actually does support MultiPart/FormPost data:

It's all about using the HttpContext of the Web API controller, and on the request you will have the files collection filled, and when data is being posted as well, you can access the data.

Below is an example that I use to upload a profile picture and the data object to go with it:

[Route("UploadUserImage", Name = "UploadUserImage")]
[HttpPost]
public async Task<dynamic> PostUploadUserImage(UserInfo userInformation)
{
    foreach (string fileKey in HttpContext.Current.Request.Files.Keys)
    {
        HttpPostedFile file = HttpContext.Current.Request.Files[fileKey];
        if (file.ContentLength <= 0)
            continue; //Skip unused file controls.

        //The resizing settings can specify any of 30 commands.. See http://imageresizing.net for details.
        //Destination paths can have variables like <guid> and <ext>, or
        //even a santizied version of the original filename, like <filename:A-Za-z0-9>
        ImageResizer.ImageJob i = new ImageResizer.ImageJob(file, "~/image-uploads/<guid>.<ext>", new ImageResizer.ResizeSettings(
                    "width=2000;height=2000;format=jpg;mode=max"));
        i.CreateParentDirectory = true; //Auto-create the uploads directory.
        i.Build();

        var fileNameArray = i.FinalPath.Split(@"\".ToCharArray());
        var fileName = fileNameArray[fileNameArray.Length - 1];

        userInformation.profilePictureUrl = String.Format("/services/image-uploads/{0}",fileName);

        return userInformation;
    }
    return null;
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Robbie Tapping
  • 2,516
  • 1
  • 17
  • 18
  • My experience shows that when sending a multipart/form-data ContentType (without my own formatter), I get a HTTP 415 status. I also found several articles when Googling this problem saying "multipart/form-data support is coming soon" or "will be in the next version". I'll give your code a try and report back. – James Schend Jan 15 '15 at 15:42
  • 1
    Sorry, there must be some disconnect between my code and your example, because when I attempt to post multipart/form-data without a custom formatter, WebAPI returns a HTTP 415 before it executes the Controller. – James Schend Jan 15 '15 at 15:52
  • 1
    For example, I've tried following this example: http://forums.asp.net/t/1777847.aspx?MVC4+Beta+Web+API+and+multipart+form+data but HttpContext.Current is no longer available to Formatters, and so that code example no longer works. (Also IKeyValueModel no longer exists and I can't find what replaced it.) Note that at the bottom, the author says multipart support should be coming in the next version. – James Schend Jan 15 '15 at 15:59