Monday, February 6, 2012

Implementing HTTP File Upload with ASP.NET MVC


Implementing HTTP File Upload with ASP.NET MVC including Tests and Mocks

A number of folks have asked me how to "implement the ASP.NET File Upload Control" except using ASP.NET MVC. This is a really interesting question for a number of reasons and a great opportunity to explore some fundamentals.

First, ASP.NET MVC is different since we don't get to use ASP.NET Server Controls as we're used to them. There's no "server controls" in the way that we're used to them.

Second, it'd be important to write Unit Tests for something like File Upload, and since ASP.NET MVC tries to be Unit Test friendly, it's an interesting problem to do tests. Why is it interesting? Well, ASP.NET MVC sits on top of ASP.NET. That means ASP.NET MVC didn't do any special work for File Upload support. It uses whatever stuff is built into ASP.NET itself. This may or not be helpful or interesting or even easy to test.

It seems then, that this is a good exercise in understanding a number of things:
  • HTTP and How File Upload works via HTTP
  • What ASP.NET offers for to catch File Uploads
  • How to Mock things that aren't really Mock Friendly
  • And ultimately, How to do File Upload with ASP.NET MVC
Here we go.

HTTP and How File Upload works via HTTP

It's always better, for me, to understand WHY and HOW something is happening. If you say "just because" or "whatever, you just add that, and it works" then I think that's sad. For some reason while many folks understand FORM POSTs and generally how form data is passed up to the server, when a file is transferred many just conclude it's magic.

Why do we have to add enctype="multipart/form=data" on our forms that include file uploads? Because the form will now be POSTed in multiple parts.
If you have a form like this:
1
2
3
4
5
6
7
<form action="/home/uploadfiles" method="post" enctype="multipart/form-data">
    <label for="file">Filename:</label>
    <input type="file" name="file" id="file" />
    <input type="submit" name="submit" value="Submit" />
</form>
The resulting Form POST will look like this (slightly simplified):
POST /home/uploadfiles HTTP/1.1
Content-Type: multipart/form-data; boundary=---------------------------7d81b516112482
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; WOW64)
Content-Length: 324

-----------------------------7d81b516112482
Content-Disposition: form-data; name="file"; filename="\\SERVER\Users\Scott\Documents\test.txt"
Content-Type: text/plain

foo
-----------------------------7d81b516112482
Content-Disposition: form-data; name="submit"

Submit
-----------------------------7d81b516112482--
Notice a few things about this POST. First, notice the content-type and boundary="" and how the boundary is used later, as exactly that, a boundary between the multiple parts. See how the first part shows that I uploaded a single file, of type text/plain. You can interpolate from this how you'd expect multiple files to show up if they were all POSTed at once.

And of course, look at how different this would look if it were just a basic form POST without the enctype="multipart/form=data" included:
 
POST /home/uploadfiles HTTP/1.1
Content-Type: application/x-www-form-urlencoded
UA-CPU: x86
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; WOW64)
Content-Length: 13

submit=Submit

See how the content type is different? This is a regular, typical form POST. Perhaps atypical in that it includes only a Submit button!

The point is, when folks add a ASP.NET FileUpload Control to their designer, it's useful to remember that you're buying into an abstraction over something. In this case, you're using a control that promises to hide the whole multipart MIME way of looking at things, and that's totally cool.
Back To Basics Tip
Know what your library is hiding from you and why you chose it.
As an aside, if you looked at an email of yours with multiple attached files, it would look VERY similar to the body of the first HTTP message as multipart MIME encoding is found everywhere, as is common with most good ideas.

What ASP.NET offers for to catch File Uploads

The FileUpload control is just a control that sits on top of a bunch of support for FileUploads in ASP.NET, starting with the classes Request.Files and HttpPostedFile. Those are the things that actually do the hold on to the parsed Files from an HTTP Request. You can use them to get a hold of a stream (a bunch of bytes in memory that are the file) or just save the file.

Since we can't use ASP.NET Server Controls in ASP.NET MVC, we'll use these classes instead. Here's how you usually grab all the files from an upload and save them:
1
2
3
4
5
6
7
8
9
10
foreach (string file in Request.Files)
{
   HttpPostedFile hpf = Request.Files[file] as HttpPostedFile;
   if (hpf.ContentLength == 0)
      continue;
   string savedFileName = Path.Combine(
      AppDomain.CurrentDomain.BaseDirectory,
      Path.GetFileName(hpf.FileName));
   hpf.SaveAs(savedFileName);
}
Of course, you might want to change the directory and filename, maybe check the mimeType to allow only certain kinds of files, or check the length to limit your uploads, but this is the general idea.

Note that Request.Files has been around since 1.x and isn't a strongly typed collection of anything, so the GetEnumerator() of .Files that we're using in the foreach returns strings that are then used as keys into the Files[] indexer. It's a little wonky as it's old.

However, don't let me get ahead of myself, let's write the tests first!

How to Mock things that aren't really Mock Friendly

After creating a new ASP.NET MVC Project and making sure to select a test framework, I'll drop into a Controller Test and make a new TestMethod that kind of looks like I expect my method to be used.
?
1
2
3
4
5
6
7
8
9
10
11
[TestMethod]
public void FakeUploadFiles()
{
   HomeController controller = new HomeController();
   ViewResult result = controller.UploadFiles() as ViewResult;
   var uploadedResult = result.ViewData.Model as List<ViewDataUploadFilesResult>;
   Assert.AreEqual(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "foo.doc"), uploadedResult[0].Name);
   Assert.AreEqual(8192, uploadedResult[0].Length);
}
This is incomplete, though, as I'm writing the tests before I the implementation exists. I need to think about how this should be implemented, and as I learn what should be mocked, I need to go back and forth between the tests and the implementation.

If we tried to compile this test, it won't, until I add a few types and methods. Once it actually compiles, but before I write the method itself, I'll want to see it FAIL. If you get a test to PASS on the first try, you don't really know yet if it CAN fail. Making it fail first proves that it's broken. Then you get to fix it.
Back To Basics Tip
Remember, in TDD, if it ain't broke, you don't get to fix it.
image
There's a bit of a chicken and the egg because it's unclear what will need to be mocked out until I start the implementation. However, this draft method above generally says what I want to do. I want to my controller to have a method called UploadFiles() that will grab the uploaded files from Request.Files, save them, then put a type in the ViewData saying which files were saved and how large they were.

Ok, take a breath. The following code may look freaky, but it's really cool actually. You can use any Mock Framework you like, but I like Moq for it's fluency.

We're having to "mock" things because we need to lie to our controller, who's expecting an HTTP Post, remember? It's going to go and spin through Request.Files and try to save each file. Since we want to test this without the web server or web browser, we'll want to tell the Moq framework about our expectations.
Back To Basics Tip
Be careful to mock context and assert outputs but don't mock away the whole test!
I've commented the code to explain...
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[TestMethod]
public void FakeUploadFiles()
{
   //We'll need mocks (fake) of Context, Request and a fake PostedFile
   var request = new Mock<HttpRequestBase>();
   var context = new Mock<HttpContextBase>();
   var postedfile = new Mock<HttpPostedFileBase>();
   //Someone is going to ask for Request.File and we'll need a mock (fake) of that.
   var postedfilesKeyCollection = new Mock<HttpFileCollectionBase>();
   var fakeFileKeys = new List<string>() { "file" };
   //OK, Mock Framework! Expect if someone asks for .Request, you should return the Mock!
   context.Expect(ctx => ctx.Request).Returns(request.Object);
   //OK, Mock Framework! Expect if someone asks for .Files, you should return the Mock with fake keys!
   request.Expect(req => req.Files).Returns(postedfilesKeyCollection.Object);
    
   //OK, Mock Framework! Expect if someone starts foreach'ing their way over .Files, give them the fake strings instead!
   postedfilesKeyCollection.Expect(keys => keys.GetEnumerator()).Returns(fakeFileKeys.GetEnumerator());
   //OK, Mock Framework! Expect if someone asks for file you give them the fake!
   postedfilesKeyCollection.Expect(keys => keys["file"]).Returns(postedfile.Object);
   //OK, Mock Framework! Give back these values when asked, and I will want to Verify that these things happened
   postedfile.Expect(f => f.ContentLength).Returns(8192).Verifiable();
   postedfile.Expect(f => f.FileName).Returns("foo.doc").Verifiable();
   //OK, Mock Framework! Someone is going to call SaveAs, but only once!
   postedfile.Expect(f => f.SaveAs(It.IsAny<string>())).AtMostOnce().Verifiable();
    
   HomeController controller = new HomeController();
   //Set the controller's context to the mock! (fake)
   controller.ControllerContext = new ControllerContext(context.Object, new RouteData(), controller);
   //DO IT!
   ViewResult result = controller.UploadFiles() as ViewResult;
   //Now, go make sure that the Controller did its job
   var uploadedResult = result.ViewData.Model as List<ViewDataUploadFilesResult>;
   Assert.AreEqual(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "foo.doc"), uploadedResult[0].Name);
   Assert.AreEqual(8192, uploadedResult[0].Length);
   postedfile.Verify();
}

How to do File Upload with ASP.NET MVC

Now, what is the least amount of code in our Controller do we need to write to make this test pass? Here we get to use the Request.Files method that ASP.NET (not ASP.NET MVC) has had for years, and use it as advertised. It works in the tests and it works in production.
Important Note: We have to use the HttpPostedFileBase class, rather than the HttpPostedFile because every Request, Response, HttpContext and all related ASP.NET intrinsic abstractions are one layer farther way in ASP.NET MVC. If you get an HttpRequest in ASP.NET, then in ASP.NET MVC at runtime...
  • you'll get an HttpRequestWrapper while running under a Webserver
  • you'll get a dynamically generated derived Mock of an HttpRequestBase while running outside a Webserver (like inside a test) when you've made your own ControllerContext.
In each case, the instances you'll get are both (ultimately) of type HttpRequestBase, but it's this extra layer of abstraction that makes ASP.NET MVC easy to test and ASP.NET WebForms less so. I hope these Wrappers will be included in a future release of WebForms. The fact that they live in the System.Web.Abstractions.dll and not System.Web.Mvc.Abstractions.dll tells me someone has their eye on that particular ball.
At any rate, here's the Controller that takes File Upload requests:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class ViewDataUploadFilesResult
{
   public string Name { get; set; }
   public int Length { get; set; }
}
public class HomeController : Controller
{
   public ActionResult UploadFiles()
   {
      var r = new List<ViewDataUploadFilesResult>();
      foreach (string file in Request.Files)
      {
         HttpPostedFileBase hpf = Request.Files[file] as HttpPostedFileBase;
         if (hpf.ContentLength == 0)
            continue;
         string savedFileName = Path.Combine(
            AppDomain.CurrentDomain.BaseDirectory,
            Path.GetFileName(hpf.FileName));
         hpf.SaveAs(savedFileName);
         r.Add(new ViewDataUploadFilesResult()
            { Name = savedFileName,
              Length = hpf.ContentLength });
      }
      return View("UploadedFiles",r);
   }
}
At the bottom where I ask for the "UploadedFiles" view, and I pass in my list of ViewDataUploadFilesResults. This will appea in the ViewData.Model property. The View then displays them, and that's ALL the View does.

?
1
2
3
4
5
<ul>
<% foreach (ViewDataUploadFilesResult v in this.ViewData.Model)  { %>
       <%=String.Format("<li>Uploaded: {0} totalling {1} bytes.</li>",v.Name,v.Length) %>
<%   } %>   
</ul>

Conclusion

I always encourage people to take the little bit of time to use Fiddler or SysInternals or look at your call stack or just to take a breath and remind oneself, "so how is this supposed to work?" Otherwise, one is just cargo-cult programming.

This post was a long answer to the question "How do I do FileUpload with ASP.NET MVC?" but I feel better having written in this way.

No comments:

Post a Comment