Jecelyn Yeen
This tutorial is out of date and no longer maintained.
File uploading is a common feature that almost every website needs. We will go through step by step on how to handle single and multiple file(s) uploads with Hapi, save it to the database (LokiJS), and retrieve the saved file for viewing.
The complete source code is available here: https://github.com/chybie/file-upload-hapi.
We will be using TypeScript throughout this tutorial.
I am using Yarn for package management. However, you can use npm if you like.
Run this command to install required dependencies
- // run this for yarn
- yarn add hapi boom lokijs uuid del
-
- // or using npm
- npm install hapi boom lokijs uuid del --save
Since we are using TypeScript, we need to install typings files in order to have an auto-complete function (IntelliSense) during development.
- // run this for yarn
- yarn add typescript @types/hapi @types/boom @types/lokijs @types/uuid @types/del --dev
-
- // or using npm
- npm install typescript @types/hapi @types/boom @types/lokijs @types/uuid @types/del --save-dev
A couple of setup steps to go before we start.
Add a typescript configuration file. To know more about TypSscript configuration, visit https://www.typescriptlang.org/docs/handbook/tsconfig-json.html.
// tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es6",
"noImplicitAny": false,
"sourceMap": true,
"outDir": "dist"
}
}
dist
folder.target
as es6
.Add the following scripts.
// package.json
{
...
"scripts": {
"prestart": "tsc",
"start": "node dist/index.js"
}
...
}
Later on, we can run yarn start
or npm start
to start our application.
yarn start
, it will trigger prestart
script first. The command tsc
will read the tsconfig.json
file and compile all typescript
files to javascript
in dist
folder.dist/index.js
.Let’s start creating our Hapi server.
// index.ts
import * as Hapi from 'hapi';
import * as Boom from 'boom';
import * as path from 'path'
import * as fs from 'fs';
import * as Loki from 'lokijs';
// setup
const DB_NAME = 'db.json';
const COLLECTION_NAME = 'images';
const UPLOAD_PATH = 'uploads';
const fileOptions = { dest: `${UPLOAD_PATH}/` };
const db = new Loki(`${UPLOAD_PATH}/${DB_NAME}`, { persistenceMethod: 'fs' });
// create folder for upload if not exist
if (!fs.existsSync(UPLOAD_PATH)) fs.mkdirSync(UPLOAD_PATH);
// app
const app = new Hapi.Server();
app.connection({
port: 3001, host: 'localhost',
routes: { cors: true }
});
// start our app
app.start((err) => {
if (err) {
throw err;
}
console.log(`Server running at: ${app.info.uri}`);
});
The code is pretty expressive itself. We set the connection port to 3001
, allow Cross-Origin Resource Sharing (CORS), and start the server.
Let’s create our first route. We will create a route to allow users to upload their profile avatar.
// index.ts
...
import {
loadCollection, uploader
} from './utils';
...
app.route({
method: 'POST',
path: '/profile',
config: {
payload: {
output: 'stream',
allow: 'multipart/form-data' // important
}
},
handler: async function (request, reply) {
try {
const data = request.payload;
const file = data['avatar']; // accept a field call avatar
// save the file
const fileDetails = await uploader(file, fileOptions);
// save data to database
const col = await loadCollection(COLLECTION_NAME, db);
const result = col.insert(fileDetails);
db.saveDatabase();
// return result
reply({ id: result.$loki, fileName: result.filename, originalName: result.originalname });
} catch (err) {
// error handling
reply(Boom.badRequest(err.message, err));
}
}
});
multipart/form-data
and receive the data as a stream.avatar
for file upload.uploader
function (we will create it soon) to save the input file.images
table / collection (we will create loadCollection
next) and create a new record.A generic function to retrieve a LokiJS collection if exists, or create a new one if it doesn’t.
// utils.ts
import * as del from 'del';
import * as Loki from 'lokijs';
import * as fs from 'fs';
import * as uuid from 'uuid;
const loadCollection = function (colName, db: Loki): Promise<LokiCollection<any>> {
return new Promise(resolve => {
db.loadDatabase({}, () => {
const _collection = db.getCollection(colName) || db.addCollection(colName);
resolve(_collection);
})
});
}
export { loadCollection }
Our uploader will handle single file upload and multiple file upload (will create later).
// utils.ts
...
const uploader = function (file: any, options: FileUploaderOption) {
if (!file) throw new Error('no file(s)');
return _fileHandler(file, options);
}
const _fileHandler = function (file: any, options: FileUploaderOption) {
if (!file) throw new Error('no file');
const orignalname = file.hapi.filename;
const filename = uuid.v1();
const path = `${options.dest}${filename}`;
const fileStream = fs.createWriteStream(path);
return new Promise((resolve, reject) => {
file.on('error', function (err) {
reject(err);
});
file.pipe(fileStream);
file.on('end', function (err) {
const fileDetails: FileDetails = {
fieldname: file.hapi.name,
originalname: file.hapi.filename,
filename,
mimetype: file.hapi.headers['content-type'],
destination: `${options.dest}`,
path,
size: fs.statSync(path).size,
}
resolve(fileDetails);
})
})
}
...
export { loadCollection, uploader }
uploads
folder for our case.You may run the application with yarn start
. I try to call the locahost:3001/profile
API with (Postman)[https://www.getpostman.com/apps], a GUI application for API testing.
When I upload a file, you can see that a new file is created in uploads
folder, and the database file db.json
is created as well.
When I issue a call without passing in avatar
, an error will be returned.
We can handle file upload successfully now. Next, we need to limit the file type to image only. To do this, let’s create a filter function that will test the file extensions, then modify our _fileHandler
to accept an optional filter option.
// utils.ts
...
const imageFilter = function (fileName: string) {
// accept image only
if (!fileName.match(/\.(jpg|jpeg|png|gif)$/)) {
return false;
}
return true;
};
const _fileHandler = function (file: any, options: FileUploaderOption) {
if (!file) throw new Error('no file');
// apply filter if exists
if (options.fileFilter && !options.fileFilter(file.hapi.filename)) {
throw new Error('type not allowed');
}
...
}
...
export { imageFilter, loadCollection, uploader }
We need to tell the uploader to apply our image filter function. Add it in fileOptions
variable.
// index.ts
import {
imageFilter, loadCollection, uploader
} from './utils';
..
// setup
...
const fileOptions: FileUploaderOption = { dest: `${UPLOAD_PATH}/`, fileFilter: imageFilter };
...
Restart the application, try to upload a non-image file and you should get an error.
Let’s proceed to handle multiple files upload now. We will create a new route to allow user to upload their photos.
...
app.route({
method: 'POST',
path: '/photos/upload',
config: {
payload: {
output: 'stream',
allow: 'multipart/form-data'
}
},
handler: async function (request, reply) {
try {
const data = request.payload;
const files = data['photos'];
const filesDetails = await uploader(files, fileOptions);
const col = await loadCollection(COLLECTION_NAME, db);
const result = [].concat(col.insert(filesDetails));
db.saveDatabase();
reply(result.map(x => ({ id: x.$loki, fileName: x.filename, originalName: x.originalname })));
} catch (err) {
reply(Boom.badRequest(err.message, err));
}
}
});
...
The code is similar to single file upload, except we accept a field photos
instead of avatar
, accept an array of files as input, and reply the result as an array.
We need to modify our uploader
function to handle multiple files upload.
// utils.ts
...
const uploader = function (file: any, options: FileUploaderOption) {
if (!file) throw new Error('no file(s)');
// update this line to accept single or multiple files
return Array.isArray(file) ? _filesHandler(file, options) : _fileHandler(file, options);
}
const _filesHandler = function (files: any[], options: FileUploaderOption) {
if (!files || !Array.isArray(files)) throw new Error('no files');
const promises = files.map(x => _fileHandler(x, options));
return Promise.all(promises);
}
...
Next, create a route to retrieve all images.
// index.ts
...
app.route({
method: 'GET',
path: '/images',
handler: async function (request, reply) {
try {
const col = await loadCollection(COLLECTION_NAME, db)
reply(col.data);
} catch (err) {
reply(Boom.badRequest(err.message, err));
}
}
});
...
The code is super easy to understand.
Next, create a route to retrieve an image by id.
// index.ts
...
app.route({
method: 'GET',
path: '/images/{id}',
handler: async function (request, reply) {
try {
const col = await loadCollection(COLLECTION_NAME, db)
const result = col.get(request.params['id']);
if (!result) {
reply(Boom.notFound());
return;
};
reply(fs.createReadStream(path.join(UPLOAD_PATH, result.filename)))
.header('Content-Type', result.mimetype); // important
} catch (err) {
reply(Boom.badRequest(err.message, err));
}
}
});
...
content-type
correctly so our client or browser knows how to handle it.Now restart the application, upload a couple of images, and retrieve it by id. You should see the image is return as an image instead of a JSON object.
Sometimes, you might want to clear all the images and database collection during development. Here’s a helper function to do so.
// utils.ts
....
const cleanFolder = function (folderPath) {
// delete files inside folder but not the folder itself
del.sync([`${folderPath}/**`, `!${folderPath}`]);
};
...
export { imageFilter, loadCollection, cleanFolder, uploader }
// index.ts
// setup
...
// optional: clean all data before start
cleanFolder(UPLOAD_PATH);
if (!fs.existsSync(UPLOAD_PATH)) fs.mkdirSync(UPLOAD_PATH);
...
Handling file(s) uploads with Hapi is not as hard as you thought.
The complete source code is available here: https://github.com/chybie/file-upload-hapi.
That’s it. Happy coding.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!