Introduction
The traditional way of uploading a file is to use a form submission. This approach works extremely well with application implemented with Spring MVC or ASP.NET MVC frameworks. In this tutorial, I will be discussing a different approach of uploading a file. In this tutorial, the file upload can be packed inside a JSON object and handled by a RESTful web service.
This tutorial will begin with an overview of the sample application's architecture. Then, I will discuss the way of creating a file upload HTML component using Bootstrap. Afterwards, we will explore the way AngularJS can be used to manipulate the HTML elements. Believe me, manipulating the HTML elements like JQuery is necessary in this tutorial. And we will get to use $scope.$apply()
. Files from local drive can be read by JavaScript's FileReader
object, which transforms binary into BASE64 encoded text string. This allows us to pack the file content into a JSON object to be sent. On the server side, the request handling method will parse the file content and revert the BASE64 string back into a binary file.
The Overall Architecture
For this tutorial, I have created a sample program to demonstrate the functionalities that would be discussed in this tutorial. This application will be a Spring Boot based application. It hosts a RESTFul web service that can handle only one kind of request - our request for file upload. It will handle the file being uploaded. This method will take a JSON object that has two properties, one is a short file name, and the other is the content of the file. This method will simply save the file to a hardcoded location like: c:\temp\.
The application also contains an index page which is actually a single-page web application implemented using AngularJS. The application has a single input that can load a file from disk. Once file is selected, the application will load the file content and get the file name (just the file name, not the path). It packs the two into a JSON object and sends it to the RESTFul web service.
Here is a screenshot of the sample application:
The hardest parts of this sample application are:
- How to manipulate the HTML elements on the page to select a file from local disk drive.
- How to read the file and transform the file into a
string
so that it can be transported to the backend web service. - On server side, how to read the input file content and save it back as a file.
All these will be explained in the next few sections. When I first started working on the sample application, the very first task I had to do is design an HTML element that can get a file from local disk. Let's see how this is done.
The File Input Component
There is an input control available in HTML that does this. I know. What I need is not this predefined input control. What I need is a readonly text field, and a button grouped together. The button can be clicked and pops up the File Open dialog so it can be used to select a file. The text field will display the file name. With Bootstrap, this is very easy to define:
<div class="input-group">
<input type="file" id="fileUploadField" style="display: none;">
<input type="text" id="fileNameDisplayField" class="form-control"
ng-model="vm.uploadFileName" placeholder="File to Upload" readonly="readonly">
<div class="input-group-addon btn btn-default"
ng-click="vm.clickSelectFile()"><i class="glyphicon glyphicon-file"></i></div>
</div>
In the code snippet above, there are some attributes specific for AngularJS. You can ignore these for now. The outer <div>
element has CSS class "input-group
". It can smash multiple input control into one. With the above code snippet, it will create an input control like this:
Inside the outer <div>
, there are three HTML elements:
- An input field with type of "
file
". This field will be invisible because I am not going to display it. It exists so that I can use it to hold the metadata of the chosen file. And I use its functionality to choose a file. - A text input file that is readonly, can be used to display the file name (without the folder path). I choose it to be readonly because I don't want the user to modify the value. This is just a preference. If you wish, you can make it editable.
- A button with a File icon. It can be click and select a file. The button defined via another
div
using CSS class "input-group-addon
".
Let's circle back, to the question why I don't use the HTML default file controller. If you make it visible by removing the style
attribute, you can see that it is a text field with a button. It is similar to the component I just created. The ugly aspect of this default one is that it is not customizable. And it will not fit with the other Bootstrap controls. It is best to create something that looks like the rest of the components. And, there, you have it.
Now we have the file upload component. It is time to implement the behavior which can select a file and pack it as an JSON object.
AngularJS Code Walkthrough
The single page application is defined in the file app.js. It is located at src\main\resources\static\assets\app\js folder. In order for the Angular application to work, I have to implement several functionalities:
- How to click the button with the file icon to pop up the File Open dialog.
- Once a file is selected, how to save the file metadata and extract the single file name to display in the readonly text field.
- How to load the file content as
BASE64 string
.
What is not really a challenge is sending the BASE64
file content string
to the backend web service. This is done with ngResource
. I will discuss this at the end of this section.
Getting File Metadata from Disk
Since I have to define my own file upload component and the default file upload input is hidden, there has to be a way to link the button click to the default file upload input. Turns out, it is pretty easy to do. The default file upload input has a button. All I need to do is associate my button click to the click of the file upload button click.
In order to do this, I have to use something similar to JQuery. AngularJS has a built-in function called angular.element()
. It can be used to select elements from the page. Before I continue, I just want to say that it is OK to query the DOM elements in the page. My reasons are:
- It is a sample application, to demo a concept.
- It is contained, it is somewhat well designed. And it is maintainable.
- It is the easiest way to accomplish what I need to accomplish.
Back to the design. When a user clicks on the button with the file icon, it will trigger the hidden file upload input to be clicked. Here is how:
vm.clickSelectFile = function () {
angular.element("#fileUploadField").click();
};
On the index.html, the button with the file icon is defined as this:
<div class="input-group-addon btn btn-default"
ng-click="vm.clickSelectFile()"><i class="glyphicon glyphicon-file"></i></div>
As you can see, the directive ng-click
references the function vm.clickSelectFile()
. So when this button is clicked, the JavaScript code above will do a query on the DOM tree and find the hidden input of file upload and invoke its click()
method. When the user does the click, the File Open dialog will popup. After choosing a file and closing this popup, the read-only text field does not display anything. This is expected unless I add more functionalities to the application. The next functionality I had to add is that when the hidden file upload input is assigned a file, it will notify the text field to display the file name, which is discussed next.
Display Selected File Name
As described, choosing a file with the hidden file upload input does not automatically make the text field display the file name. This problem is also easy to solve. All we need to do is to have the hidden file upload input handle the selection change event. During the event handling, it will assign the file name to the angular scope variable which is bound to the text field. The problem that would be apparent is that assigning the value to the scope variable, but it will not automatically refresh the value display on the text field. To fix this, we have to call $scope.$apply()
. This will do the refreshing and make the text appear.
To have the file upload input handle the selection change event, what I had to do is use angular.element()
to query the hidden file upload input and assign an event handling method to it:
angular.element("#fileUploadField").bind("change", function(evt) {
if (evt) {
...
}
});
As you can see, this is the same way one would do with JQuery. Query the element by its id to get a reference, then bind the event handling method to the event. In this case, it is the change event of the element that will be handled.
Next, I need to use the full path file name of the selected file, and return only the file name, not the folder path. For this, I just came up with a simple heuristic. The file name will either be a Windows full path file name with drive letter, and backslashes; or UNIX based full path file name with slashes. It is one way or the other, so here is how I extract the file name:
var fn = evt.target.value;
if (fn && fn.length > 0) {
var idx = fn.lastIndexOf("/");
if (idx >= 0 && idx < fn.length) {
vm.uploadFileName = fn.substring(idx+1);
} else {
idx = fn.lastIndexOf("\\");
if (idx >= 0 && idx < fn.length) {
vm.uploadFileName = fn.substring(idx+1);
}
}
}
$scope.$apply();
The code snippet above first looks for the last slash in the file name. If found, I just take the rest of the file name after this character. If the last slash is not found, then I will try again by finding the last back slash in the file name. If found, then I again will take the rest of the file name after this character. If both characters are not found, nothing is changed for the scope variable. vm.uploadFileName
is the scope variable. Once the file name is assigned to the scope variable, I need to call $scope.$apply()
to make sure the text field would display the file name. Why would I do this? Normally, AngularJS would handle view element to model binding automatically. But, in this case where I explicitly query the element and bind an event handler method, the automatic binding of the data model to view is not set. So to explicitly do the model to view data refresh, just call $scope.$apply()
.
What is next? It is the hardest part of this tutorial. I need to load the file as BASE64
encoded string
. It will be discussed next.
Loading File Content as BASE64 Encoded String
Once a file is selected, all we have is a file name, and maybe some other file related metadata. The next step is to load the data. After some searching, it turns out to be very easy as well. JavaScript provided an object type called FileReader
. There are two cool things about this object type:
- All I have to do is pass the file name to the object's
readAsDataURL()
to do the file loading. - The loading of file is done asynchronously. And when the loading is done, I can provide a callback method that can call the web service to do the actual upload.
Here is the entire source code of the file loading, and upload the file:
vm.doUpload = function () {
vm.uploadSuccessful = false;
var elems = angular.element("#fileUploadField");
if (elems != null && elems.length > 0) {
if (elems[0].files && elems[0].files.length > 0) {
let fr = new FileReader();
fr.onload = function(e) {
if (fr.result && fr.result.length > 0) {
var uploadObj = {
fileName: vm.uploadFileName,
uploadData: fr.result
};
sampleUploadService.uploadImage(uploadObj).then(function(result) {
if (result && result.success === true) {
clearUploadData();
vm.uploadSuccessful = true;
}
}, function(error) {
if (error) {
console.log(error);
}
});
}
};
fr.readAsDataURL(elems[0].files[0]);
} else {
vm.uploadObj.validationSuccess = false;
vm.uploadObj.errorMsg = "No file has been selected for upload.";
}
}
};
There are a couple points regarding the above code that are worthy of mention. First, I need to get the file name (full path) of the selected file. By querying the element of the default upload file input, I will get a reference of it. Then, I will use its property called .files
to get a reference to all the selected files of this input element. The default file upload input can select multiple files at once, this is why an array is used to hold these files instead of an object reference to just a single file. Here is the code snippet:
var elems = angular.element("#fileUploadField");
if (elems != null && elems.length > 0) {
if (elems[0].files && elems[0].files.length > 0) {
...
}
}
Next, I need to create a FileReader
object, and read the file using method readAsDataUrl()
. Here is the code snippet:
let fr = new FileReader();
fr.onload = function(e) {
if (fr.result && fr.result.length > 0) {
...
}
};
fr.readAsDataURL(elems[0].files[0]);
As shown above, I created an object of FileReader
and have variable fr
reference it. Then, I supplied the object with a call back method for its property onload
. The method supplied will call my AngularJS service object to do the file upload. Next, I will discuss how this service object works.
AngularJ Service for File Upload
In my previous article, "AngularJS ngResource Tutorial", I mentioned that upload data will be covered in a future article. This is the future article I have promised. The problem I had in my previous tutorial is that I don't have a way of loading the file as BASE64
encoded string
. Here, I do. And the rest (of uploading a file) is actually pretty easy to do.
Here is the full source code of the service object definition:
(function () {
"use strict";
var mod = angular.module("uploadServiceModule", [ "ngResource" ]);
mod.factory("sampleUploadService", [ "$resource",
function ($resource) {
var svc = {};
var restSvc = $resource(null, null, {
"uploadImage": {
url: "./uploadImage",
method: "post",
isArray: false,
data: {
fileName: "@fileName",
uploadData: "@uploadData"
}
}
});
svc.uploadImage = function (imageUpload) {
return restSvc.uploadImage(imageUpload).$promise;
};
return svc;
}
])
})();
If you have read my previous article, "AngularJS ngResource Tutorial", it is going to be easy for you to understand the above service object defintion. I defined a factory object called "sampleUploadService
". The factory object returns an object that acts as a service, in it, there is just one method called uploadImage()
.
The uploadImage()
method uses ngResource
object to call the backend web service. The ngResource
object which I have defined has just one action method inside, also called "uploadImage
". The action method sees HTTP Post for communication with the back end. The url
property defines the web service url
. And it expects a single object as response, not an array of objects. Finally, the data that is sent to the backend will be a JSON object, which has two properties: one is the file name and the other is BASE64
encoded file content.
Let's get back to the end of the last section, where I was about to post the code snippet of how to use the above AngularJS service (or shall we say factory) to invoke the backend web service. I stop there because I think it is best to show how the AngularJS service is defined first. Now that is revealed, time to see how the AngularJS service can be used. Here it is:
var uploadObj = {
fileName: vm.uploadFileName,
uploadData: fr.result
};
sampleUploadService.uploadImage(uploadObj).then(function(result) {
if (result && result.success === true) {
clearUploadData();
vm.uploadSuccessful = true;
}
}, function(error) {
if (error) {
console.log(error);
}
});
The file content is stored in the property "result
" of the FileReader
object. It is only available after the call of readAsDataURL()
is completed asynchronously. When it is done, the object's onload()
callback will be invoked, where the above code snippet is located. In the above code snippet, I created a new object called uploadObj
. And I assigned the file name without the full path and the file content which is in fr.result
to the properties of the new object.
Finally, I used my service sampleUploadService
and called the uploadImage()
. That is all it took. It is very simple. If you read my previous tutorial, you will know how to handle the event when the HTTP call finishes.
The last thing I want to cover here is the format of the file content. It is not just the simple BASE64
encoded string
. The first part is the media type, start with the prefix "data:
". It is followed by a semicolon. Then it is the encoding type. This will always be "base64
", and followed by a comma. Finally it is the BASE64
encoded string
. Here is a short sample:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZkAAAHVCAIAAACs
XRGOAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjw...
This is the last of the JavaScript code logic. In the next section, I will cover the Java side of code logic.
Spring Boot Web Service
The JavaScript part of code logic is over, time to check out the Spring Boot web service that handles the file upload. First, I would like to show the Java class that represents the upload file object. This class is called UploadObject
. Here it is:
package org.hanbo.boot.rest.models;
public class UploadObject
{
private String fileName;
private String uploadData;
public String getUploadData()
{
return uploadData;
}
public void setUploadData(String uploadData)
{
this.uploadData = uploadData;
}
public String getFileName()
{
return fileName;
}
public void setFileName(String fileName)
{
this.fileName = fileName;
}
}
I also need an object as a response. For this, I created another object type called GenericResponse
. Here it is:
package org.hanbo.boot.rest.models;
public class GenericResponse
{
private String id;
private boolean success;
private String detailMessage;
public String getId()
{
return id;
}
public void setId(String id)
{
this.id = id;
}
public boolean isSuccess()
{
return success;
}
public void setSuccess(boolean success)
{
this.success = success;
}
public String getDetailMessage()
{
return detailMessage;
}
public void setDetailMessage(String detailMessage)
{
this.detailMessage = detailMessage;
}
}
The final part of this tutorial, of the sample program, is the RESTFul controller that handles the file upload. In order to demo the decoding of the file data, I created the request handling method to decode the file content, and save the file to disk.
Here is the full code snippet:
@RequestMapping(value="/uploadImage", method=RequestMethod.POST)
public ResponseEntity<GenericResponse> uploadImage(
@RequestBody
UploadObject uploadObj
)
{
if (uploadObj != null && uploadObj.getUploadData() != null)
{
String uploadData = uploadObj.getUploadData();
if (uploadData.length() > 0)
{
String[] splitData = uploadData.split(";");
if (splitData != null && splitData.length == 2)
{
String mediaType = splitData[0];
System.out.println(mediaType);
if (splitData[1] != null && splitData[1].length() > 0)
{
String[] splitAgain = splitData[1].split(",");
if (splitAgain != null && splitAgain.length == 2)
{
String encodingType = splitAgain[0];
System.out.println(encodingType);
String imageValue = splitAgain[1];
byte[] imageBytes = Base64.decode(imageValue);
System.out.println("File Uploaded has " + imageBytes.length + " bytes");
System.out.println("Wrote to file " + "c:\\temp\\" + uploadObj.getFileName());
File fileToWrite = new File("c:\\temp\\" + uploadObj.getFileName());
try
{
FileUtils.writeByteArrayToFile(fileToWrite, imageBytes);
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
}
}
}
}
GenericResponse resp = new GenericResponse();
UUID randomId = UUID.randomUUID();
resp.setId(randomId.toString().replace("\\-", ""));
resp.setSuccess(true);
resp.setDetailMessage("Upload file is successful.");
ResponseEntity<GenericResponse> retVal = ResponseEntity.ok(resp);
return retVal;
}
In above code snippet, all I'm doing is splitting the string
to get to the BASE64
encoded part, which is the actual file content. Once I got the string
, I use Apache Commons' encoding library to turn the string
into a byte array. Finally, I save the byte array to a file located in folder: C:\temp. The file name is the file name from the request. After saving the file, the method returns back a JSON object of type GenericResponse
to the caller.
How to Test
After downloading the source code, please go through all the static content files and rename all the files *.sj to *.js. I had to rename these files so I can zip them up and send to codeproject.com via email for publishing. The email server scans the zip files and won't allow me to attach the file because JavaScript files are inside. Anyways, you need to rename these files if you want to run them locally.
The sample application is a Spring Boot based application, so it must be built before you can start it as a service application. To build it, you can CD into the base director the sample application. Then run:
mvn clean intsall
The build will succeed. After it is successful, run the following command and start the sample application:
java -jar target\hanbo-ngfileupload-sample-1.0.1.jar
The application will start successfully. When it does, you will see the following output in the commandline console:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.0.5.RELEASE)
2019-07-30 22:56:00.949 INFO 12808 --- [ main] org.hanbo.boot.rest.App
: Starting App v1.0.1 on U3DTEST-PC with PID 12808
(C:\Users\u3dadmin\workspace-mars8\ngUploadSample\target\hanbo-ngfileupload-sample-1.0.1.j
ar started by u3dadmin in C:\Users\u3dadmin\workspace-mars8\ngUploadSample)
2019-07-30 22:56:00.957 INFO 12808 --- [ main] org.hanbo.boot.rest.App
: No active profile set, falling back to default profiles: default
2019-07-30 22:56:01.095 INFO 12808 --- [ main]
ConfigServletWebServerApplicationContext : Refreshing org.springframework.boot.web.servlet.
context.AnnotationConfigServletWebServerApplicationContext@3a4afd8d: startup date
[Tue Jul 30 22:56:01 EDT 2019]; root of context hierarchy
...
...
2019-07-30 22:56:11.195 INFO 12808 --- [ main] org.hanbo.boot.rest.App
: Started App in 11.453 seconds (JVM running for 12.389)
2019-07-30 22:56:42.343 INFO 12808 --- [nio-8080-exec-3] o.a.c.c.C.[Tomcat].[localhost].[/]
: Initializing Spring FrameworkServlet 'dispatcherServlet'
2019-07-30 22:56:42.343 INFO 12808 --- [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet
: FrameworkServlet 'dispatcherServlet': initialization started
2019-07-30 22:56:42.373 INFO 12808 --- [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet
: FrameworkServlet 'dispatcherServlet': initialization completed in 30 ms
To run the web application in the browser, navigate to the following URL:
http://localhost:8080
Once the page loads, it will display the screenshot displayed earlier in this tutorial. All you need to do is click the button with the file icon, the File Open dialog would pop up. Use it to select a file and click OK. You will see the file name without the full folder path would be displayed on the text input. At last, click on the blue button "Upload". When upload succeeds, a green status bar would display on top of the page and show upload succeeded.
Go to the destination folder C:\temp, and find the file that was just uploaded and saved there.
Summary
This is yet another good tutorial completed. Again, I had fun writing this. For this tutorial, my focus is on file upload, specifically on how to perform file upload using AngularJS' ngResource
. As shown, in order to perform the file upload, I had to load the file and convert the content into BASE64
encoded string
(along with the media type and encoding type). Then this can be packed in a JSON object, which can be sent to the web service.
The web service is written as a Spring Boot web application. The single page application is also packaged in the same application. The web service has just one request handling method, which takes the JSON object as input. Once it received the request, it will parse the BASE64
encoded string
, converted into a byte array. Then save the byte array into a file. Even though this sample application does not do anything useful, it demonstrates the upload functionality end to end. It is useful for anyone who needs this.
I hope you enjoyed this tutorial. I certainly had fun writing this. Anyways, enjoy this and I hope it is helpful.
History
- 7/31/2019 - Initial draft