Uploading Multiple Files In A Single "Form Post" With HttpClient In Angular 7.2.12
Earlier this week, I was delightfully surprised to learn that you could upload a File using the HttpClient in Angular 7.2.11. Then, serendipitously, Wes Grimes pointed me to an article that he published on the same day regarding Managing File Uploads with NgRx. In his article, I noticed that Wes was using a native Browser class - FormData - in order to upload the file in his demo. After digging into this FormData class a bit, I learned that this class was the means by which we could programmatically post "multipart/form-data" content. In other words, this provided a way to upload multiple files in the same way we used to upload multiple files using an old-school HTML Form tag with enctype="multipart/form-data". In an effort to fully flesh-out my understanding of file-handling in Angular, I wanted to put together a quick multi-file form upload demo in Angular 7.2.12.
Run this demo in my JavaScript Demos project on GitHub (broken).
View this code in my JavaScript Demos project on GitHub.
As an exploration context, I wanted to create a simple "job application" form in which an applicant would have to upload a Resume file and ZIP archive that contains some code samples. Of course, we won't be doing any sort of file-validation in this demo, as that's beside the point. Really, what we want to do is see how to upload a multipart form-data payload that contains one or more files.
On the server-side, I'm going to be using Lucee to process the form-post. For the sake of simplicity, all we're going to do i grab the job-application data along with the two files and then just write them to an upload-directory:
<cfscript>
// CAUTION: Exercise caution when saving user-provided files to a publicly-accessible
// location on a web-server - it could create a remote-code execution vulnerability.
try {
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// When the Lucee server sees a "multipart/form-data" post, it automatically
// parses the data and appends it to the FORM scope. For files, Lucee put's the
// TEMP FILE PATH into the form-field.
param name="form.name" type="string";
param name="form.email" type="string";
param name="form.memo" type="string";
param name="form.resume" type="string" default="";
param name="form.sample" type="string" default="";
uploadDirectory = expandPath( "/uploads/upload-#createUuid()#/" );
directoryCreate( uploadDirectory );
// Save the user-submitted job-application data.
fileWrite(
file = ( uploadDirectory & "meta-data.json" ),
data = serializeJson({
name: form.name,
email: form.email,
memo: form.memo
})
);
// When saving uploaded files in the form-post, the fileUpload() method will copy
// the file-binary from the server's temp directory - ie, getTempDirectory() -
// into the destination folder using the original clientFilename of the file.
// If the user did NOT upload a file, the "resume" form-field will be empty.
if ( len( form.resume ) ) {
fileUpload(
destination = uploadDirectory,
fileField = "resume"
);
}
// If the user did NOT upload a file, the "sample" form-field will be empty.
if ( len( form.sample ) ) {
fileUpload(
destination = uploadDirectory,
fileField = "sample"
);
}
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
cfcontent(
type = "text/plain",
variable = charsetDecode( serializeJson( "OK" ), "utf-8" )
);
} catch ( any error ) {
// For the sake of the demo, just serialize and return the error.
// --
// CAUTION: In a production app, you never want to return the raw error as this
// will expose private information about your application and server.
cfheader( statusCode = 500 );
cfcontent(
type = "application/json",
variable = charsetDecode( serializeJson( error ), "utf-8" )
);
}
</cfscript>
In both Lucee and Adobe ColdFusion, when you upload files as part of a "multipart/form-data" POST, the application server writes the files to the temp directory (as defined by getTempDirectory()) and then stores the resultant temporary file-paths in the file fields of the form post. So, for example, if we were to dump out the Form Scope in the above code, we might see something like this (truncated for display):
form.resume:
/.server/lucee-5.2.8.50/WEB-INF/lucee-web/temp/tmp-3.upload
form.sample:
/.server/lucee-5.2.8.50/WEB-INF/lucee-web/temp/tmp-4.upload
With these temporary file paths stored in the form, we can then use the fileUpload() function to tell the Lucee server to move the file defined in the given form field from the temporary location to the permanent location. As part of this operation, Lucee will rename the file to use the original client-filename associated with the upload.
But, the server-side processing isn't the focus of this exploration - I'm just showing it here in order to set the context for the "multipart/form-data" POST. The real focus is how to prepare and send such a post using the HttpClient service in Angular. So, let's look at front-end Form that the user will consume.
In the following App component, I'm including three text inputs and two file inputs. And, while we can use the NgModel directive to pull text values out of the form, the file inputs pose a bit of a challenge. If we try to use NgModel with a file input, our view-model will end-up containing the file-path of the selected file. In order to get at the underlying FileList, we have to manually bind to the native (changes) event and then explicitly store the ".files" property into our view-model:
<input #inputRef (changes)="( files = inputRef.files )" />
It's a little more work than using [(ngModel)]; but, it's pretty straightforward:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { JobApplicationService } from "./job-application.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<h2>
Job Application
</h2>
<form (submit)="submitApplication()">
<div class="field">
<label class="field__label">
Name:
</label>
<input type="text" name="name" [(ngModel)]="form.name" class="field__input" />
</div>
<div class="field">
<label class="field__label">
Email:
</label>
<input type="text" name="email" [(ngModel)]="form.email" class="field__input" />
</div>
<div class="field">
<label class="field__label">
Cover Letter:
</label>
<textarea name="memo" [(ngModel)]="form.memo" class="field__textarea"></textarea>
</div>
<div class="field">
<label class="field__label">
Resume:
</label>
<input
#resumeRef
type="file"
name="resume"
(change)="( form.resume = resumeRef.files )"
class="field__input"
/>
</div>
<div class="field">
<label class="field__label">
Code Sample:
</label>
<input
#sampleRef
type="file"
name="sample"
(change)="( form.sample = sampleRef.files )"
class="field__input"
/>
</div>
<div class="actions">
<button type="submit" class="actions__primary">
Submit Application
</button>
</div>
</form>
`
})
export class AppComponent {
public form: {
name: string;
email: string;
memo: string;
resume: FileList | null;
sample: FileList | null;
};
private jobApplicationService: JobApplicationService;
// I initialize the app component.
constructor( jobApplicationService: JobApplicationService ) {
this.jobApplicationService = jobApplicationService;
this.form = {
name: "",
email: "",
memo: "",
resume: null,
sample: null
};
}
// ---
// PUBLIC METHODS.
// ---
// I submit the job application form.
public submitApplication() : void {
var name = this.form.name;
var email = this.form.email;
var memo = this.form.memo;
// Dealing with the files requires a tiny bit of elbow-grease. Since NgModel
// won't automatically grab the files from the file-input, we have to use the
// (changes) event to grab them manually. Then, we have to pluck the first
// File Blob from the given FileList.
var resume = ( this.form.resume && this.form.resume.length )
? this.form.resume[ 0 ]
: null
;
var sample = ( this.form.sample && this.form.sample.length )
? this.form.sample[ 0 ]
: null
;
this.jobApplicationService
.submitApplication({
name: name,
email: email,
memo: memo,
resume: resume,
sample: sample
})
.then(
() => {
alert( "Thank you for your interest!" );
},
( error ) => {
alert( "Something went wrong with the form-submission." );
console.warn( "Error submitting job application." );
console.error( error );
}
)
;
}
}
Once the user has selected their files and submitted the form, the App component aggregates the data and passed it off to the JobApplicationService. This service then creates an instance of the FormData class that I mentioned earlier. This is the class that allows us to programmatically create and submit a "Form Post".
And, just as we did with the File Blob in the pervious blog post, we can take this FormData instance and just pass it to the HttpClient service:
// Import the core angular services.
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface JobApplication {
name: string;
email: string;
memo: string;
resume: File | null;
sample: File | null;
}
@Injectable({
providedIn: "root"
})
export class JobApplicationService {
private httpClient: HttpClient;
// I initialize the job-application service.
constructor( httpClient: HttpClient ) {
this.httpClient = httpClient;
}
// ---
// PUBLIC METHODS.
// ---
// I submit the given job application and selected files. Returns a Promise.
public async submitApplication( application: JobApplication ) : Promise<void> {
var formData = new FormData();
// The FormData object provides a way to programmatically submit data that the
// Browser could have natively submitted using a "<form/>" tag. Each entry here
// represents a form-control field.
formData.append( "name", application.name );
formData.append( "email", application.email );
formData.append( "memo", application.memo );
// While the above values are "simple" values, we can add File Blobs to the
// FormData in the exactly same way.
// --
// NOTE: An optional "filename" can be provided for Files. But, for this demo,
// we're going to allow the native filename to be used for the uploads.
( application.resume ) && formData.append( "resume", application.resume );
( application.sample ) && formData.append( "sample", application.sample );
var result = await this.httpClient
.post<void>(
"./api/upload.cfm",
formData
// NOTE: When using a FormData instance to define the request BODY, the
// correct Content-Type will be automatically provided, along with the
// necessary "boundary" option that delimits the field values. If you
// attempt to define the Content-Type explicitly, the "boundary" value
// will be omitted from the post which will prevent the Lucee Server
// parsing the request into the Form scope properly.
// --
// {
// headers: {
// "Content-Type": "multipart/form-data"
// }
// }
)
.toPromise()
;
}
}
As you can see, we use the FormData.prototype.append() method to add both our "simple" form fields and our binary file fields to the same FormData instance. We then take that instance and pass it to the HttpClient.
When I first approached this demo, I tried to explicitly define the "Content-Type" header for the request. When doing this, the underlying HTTP POST lacks a "boundary" token, which is what it used to delimit the form-parts. Without this boundary token, the server doesn't understand where one field starts and another field ends. As such, the "form scope" ends up empty in Lucee.
Thankfully, if we omit the custom "Content-Type" header when providing a FormData instance to the HttpClient service, we get both the appropriate content-type, "multipart/form-data", and the necessary boundary token. We can see this if we fill out the job-application form and then inspect the Request properties in the Chrome dev tools:
As you can see, our HTTP request is going through as a "multipart/form-data" POST with the two user-provided files getting attached as binary payloads.
For years, I believed that file-handling in JavaScript was some kind of magica. But, now that I see how straightforward it is to upload binary files in an Angular application when using the HttpClient service, I feel like a whole new world of possibility has opened up for me.
Want to use code from this post? Check out the license.
Reader Comments
Excellent. I like the fact that:
Applies the correct content-type & requisite boundaries.
Nice one:)
@All,
As I've been exploring file-uploads in Angular, one thing that I kept running into is the fact that using the
[ngModel]
directive withinput[type="file"]
will consume the.value
property in the two-way data binding, not the.files
property. As such, I wanted to see if I could create a customControlValueAccessor
that would consume the.files
in the two-way data-binding:www.bennadel.com/blog/3597-using-ngmodel-with-input-type-file-and-a-custom-controlvalueaccessor-in-angular-7-2-12.htm
I love how powerful and flexible Angular is!
@All,
Something a bit tangentially related, I wanted to play around with the
FileReader
so that I could read-in plain-text content from aFile
that is dropped onto my Angular application:www.bennadel.com/blog/3599-loading-text-file-content-with-filereader-during-a-drag-and-drop-interaction-in-angular-7-2-12.htm
So, not so much about file-uploading, just more broadly about file-handling.
When I don't specify content-type, application/json is set as content-type.
Sorry, I had an interceptor, in which I was setting content type. Please ignore my previous comment.
@Dharmendra,
No worries :D
I have an auth token which needs to be sent in the header, what should be done in this scenario?
@Shekh, I have the same issue, did you find the solution?
@MudBit,
Yeah, If you're trying to POST formdata with headers in NativeScript using Angular's HttpClient, it won't happen. I have used "https://market.nativescript.org/plugins/nativescript-http-formdata" package and I was able to post the data.
@Shekh,
Interesting -- so you're saying that any attempt to send custom HTTP Headers in conjunction with the
FormData
object will fail to work? I'll have to try that out for myself. That seems like strange behavior.@Ben,
I'm specifically talking about NativeScript. Please see this
https://github.com/NativeScript/NativeScript/issues/59 :)
I did a stackoverflow question [here] (https://stackoverflow.com/questions/59144968/angular-2-upload-file-interceptor-issue)
And a stackblitz demo
After few attempts this works, I just change the model but it was pretty similar.
@MudBit,
I don't have much experience with HTTP Interceptors. I am not a big fan of them as I find it difficult to understand the complex underlying mechanism; and, I feel like it puts responsibilities in strange places. I prefer to create a dedicated Client service that interacts with a given API and just adds the headers with its calls (rather than treating all HTTP requests the same). But, that's just a personal preference. I just don't know enough to help with the interceptors.