Introduction
This last weekend, I probably spent a good 6 or 7 hours trying to figure out how to upload a document to an ASP.NET Core application. While there are quite a few examples out there, they didn't work for what I wanted to do, and even worse, there is a certain amount of magical incantation that is required which nobody actually takes the time to explain. And I mean nobody. I found one obscure response on StackOverflow that led to resolving one issue, another on the Mozilla site for FormData
. After hours of "why does this work in Postman but not on my simple website?" I finally have a working solution.
So the point of this short article is to describe the magical incantations so you don't have to go through the pain that I did. Maybe it's obvious to you, but an actual working solution simply doesn't exist, until now.
So what was my problem? As in, what the heck is your problem, Marc?
The Problem
The usual way to upload a document is with the form
tag and an accompanying submit
button. The form
tag requires an action
attribute with the URL to the upload endpoint. That's fine and dandy, and not what I wanted.
Why not? Because I didn't want to fuss with the action
attribute and instead I wanted to use the XMLHttpRequest
wrapped in a Promise
so I could handle the response (in my case, the ID of the uploaded document) and also catch exceptions. Furthermore, the standard form submit does a redirect, which while this can be stopped by returning NoContent()
, that's a freaking kludge. Of course, you don't need a Submit button, you can have a separate button that calls form.submit()
and that's all great too. Except I also wanted to add key-value pairs that weren't necessarily part of the form
packet, and yeah, the kludges I found there involved having hidden input
elements or creating the entire form
element and its children on the fly. Oh wow. Gotta love the workarounds people come up with!
The Solution
The solution is of course ridiculously simple once one figures out the secret sauce.
Secret Sauce Ingredient #1: IFormFile
So .NET Core has this interface IFormFile
that you can use to stream the document onto the client. Cool. But you can't just arbitrarily write the endpoint like this:
public async Task<object> UploadDocument(IFormFile fileToUpload)
Secret Sauce Ingredient #2: The IFormFile Parameter Name
The parameter name MUST match the name
attribute value in your HTML! So if your HTML looks like this:
<input type="file" name="file" />
Your endpoint must use file
as the parameter name:
public async Task<object> UploadDocument(IFormFile file)
"file" matches "file".
You can also do something like this:
public async Task<object> UploadDocument(DocumentUpload docInfo)
Where, in the class DocumentUpload
you have this:
public class DocumentUpload
{
public IFormFile File { get; set; }
}
Here, "File" matches "file". Great!
And there are variations for multiple files, like List<IFormFile>
that are supported too.
Secret Sauce Ingredient #3: The FromForm Parameter Attribute
The above examples won't work! That's because we need the C# attribute FromForm
, so this is how you correctly write the endpoint (using the class version):
public async Task<object> UploadDocument([FromForm] DocumentUpload docInfo)
Secret Sauce Ingredient #4: Instantiate FormData with the form Element
So not obvious that on the client side, we need to do this:
let formData = new FormData(form);
where form
comes from code like this: document.getElementById("uploadForm");
Annoyingly, I came across many examples where people said this would work:
let formData = new FormData();
formData.append("file", valueFromInputElement);
This doesn't work!!!
Source Code
So here's the full source code.
The Client Side
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Upload Demo</title>
</head>
<body>
<style>
html.wait, html.wait * {
cursor: wait !important;
}
</style>
<form id="uploadForm">
<div>
<input type="file" name="file" />
</div>
<div style="margin-top: 10px">
<input name="description" placeholder="Description" />
</div>
</form>
<button onclick="doUpload();" style="margin-top:10px">Upload</button>
<script>
function doUpload() {
let form = document.getElementById("uploadForm");
Upload("http://localhost:60192/UploadDocument", form, { clientDate: Date() })
.then(xhr => alert(xhr.response))
.catch(xhr => alert(xhr.statusText));
}
async function Upload(url, form, extraData) {
waitCursor();
let xhr = new XMLHttpRequest();
return new Promise((resolve, reject) => {
xhr.onreadystatechange = () => {
if (xhr.readyState == 4) {
if (xhr.status >= 200 && xhr.status < 300) {
readyCursor();
resolve(xhr);
} else {
readyCursor();
reject(xhr);
}
}
};
xhr.open("POST", url, true);
let formData = new FormData(form);
Object.entries(extraData).forEach(([key, value]) => formData.append(key, value));
xhr.send(formData);
});
}
function waitCursor() {
document.getElementsByTagName("html")[0].classList.add("wait");
}
function readyCursor() {
document.getElementsByTagName("html")[0].classList.remove("wait");
}
</script>
</body>
</html>
Things to note:
- I've hardcoded
"http://localhost:60192/UploadDocument"
, you might need to change the port. - Notice
formData.append(key, value));
which is where I'm appending key-value pairs that aren't part of the form
. - There's no Submit button, instead there's a separate
Upload
button.
Like I said, simple!
The Server Side
I wrote the code in VS2019, so we're using .NET Core 3.1, so let's cover a couple tweaks first.
CORS
Sigh. Adding the ability to allow cross-domain posts is necessary because the ASP.NET Core server isn't serving the page, I just load that directly into Chrome. So the "origin" of the request is not coming from the "server." To the Startup
class, I added the AddCors
service.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddCors(options => {
options.AddPolicy("CorsPolicy",
builder => builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});
}
and applied it in:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseCors("CorsPolicy");
which must be done before the app
calls. Seriously. I read the explanation relating to the middleware pipeline, but I've got to say, WTF? Why is there an initialization order issue?
The Controller Code
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace UploadDemo.Controllers
{
public class DocumentUpload
{
public string Description { get; set; }
public IFormFile File { get; set; }
public string ClientDate { get; set; }
}
[ApiController]
[Route("")]
public class UploadController : ControllerBase
{
public UploadController()
{
}
[HttpGet]
public ActionResult<string> Hello()
{
return "Hello World!";
}
[HttpPost]
[Route("UploadDocument")]
public async Task<object> UploadDocument([FromForm] DocumentUpload docInfo)
{
IFormFile iff = docInfo.File;
string fn = iff.FileName;
var tempFilename = $@"c:\temp\{fn}";
using (var fileStream = new FileStream(tempFilename, FileMode.Create))
{
await iff.CopyToAsync(fileStream);
}
return Ok($"File {fn} uploaded.
Description = {docInfo.Description} on {docInfo.ClientDate}");
}
}
}
This of note:
- Notice the controller route is
""
as I don't care about a path fragment in the URL. - I'm assuming you have c:\temp folder. This is a demo, after all!
Running the Code
Run the ASP.NET Core application. It'll start up a browser instance:
Thrilling. Ignore it. Don't close it, just ignore it.
Next, open the "index.html" file that's in the project folder and you should see:
Choose a file, type in a description, and hit the "Upload" button, and you should see an alert that looks like this -- of course, the response will be different because you typed in something different than me:
And you should notice in your temp folder the file you uploaded:
Of course, not THAT file. But I pretty much looked like that after figuring out all the secret sauce!
Conclusion
So there you go. You now know the secret sauce, the magical incantations, the hand-waving that made this work, and you have a download that demonstrates it working!
History
- 20th January, 2020: Initial version