Handling File Uploads with Hapi.js

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) upload with Hapi, save it to database (LokiJs), and retrieve the saved file for viewing.

The complete sourcecode is available here: https://github.com/chybie/file-upload-hapi.

We will be using Typescript throughout this tutorial.

Install Required Dependencies

I am using Yarn for package management. However, you can use npm if you like.

Dependencies

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

Notes:-

  • hapi: We will develop our API using HapiJs
  • boom: A plugin for Hapi, HTTP-friendly error objects
  • loki: LokiJs, a fast, in-memory document-oriented datastore for node.js, browser and cordova
  • uuid: Generate unique id
  • del: Delete files and folders

Development Dependencies

Since we are using Typescript, we need to install typings files in order to have auto-complete function (intellesense) 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

Setup

A couple of setup steps to go before we start.

Typescript Configuration

Add a typescript configuration file. To know more about Typescript 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"      }  }

Notes:-

  1. The compiled javascript code will be output to dist folder.
  2. Since Node Js 7.5+ support ES6 / 2015, we will set the target as es6.

Start Script

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.

Notes:-

  1. When we run 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.
  2. Then, we will run the compiled index file dist/index.js.

Starting Hapi Server

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.

Upload a Single File

Let’s create our first route. We will create a route to allow users to upload their profile avatar.

Route

// 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));          }      }  });  

Notes:

  1. This is a HTTP POST function.
  2. We configure the payload to allow multipart/form-data and receive the data as stream.
  3. We will read the field avatar for file upload.
  4. We will call uploader function (we will create soon) to save the input file.
  5. Then, we will load the LokiJs images table / collection (we will create loadCollection next) and create a new record.
  6. Save the database.
  7. Return result.

Load LokiJs Collection

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 }  

Uploader Function

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 }  

Notes:

  1. We will read the uploaded file name.
  2. We will generate a random UUID as the new file name to avoid name conflicts.
  3. We will then stream and write the file to the defined folder. It’s uploads folder for our case.

Run Our Application

You may run the application with yarn start. I try to call the locahost:3001/profile API with (Postman)[https://www.getpostman.com/apps], an GUI application for API testing.

When I upload a file, you can see that a new file created in uploads folder and the database file db.json is created as well.

When I issue a call without passing in avatar, error will be returned.

Upload single file

Filter File Type

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 the our _fileHandler to apply 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 }  

Apply the Image Filter

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 error.

Upload Multiple Files

Let’s proceed to handle multiple files upload now. We will create a new route to allow user upload their photos.

Route

...  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 result as array.

Modify Uploader Function

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);  }  ...  

Retrieve List of Images

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.

Retrieve Image by Id

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));          }      }  });  ...

Notes:-

  1. We will return 404 if image not exist in database.
  2. We will stream the file as output, set the content-type correctly so our client or browser know how to handle it.

Run the Application

Now restart the application, upload a couple of images, and retrieve it by id. You should see the image is return as image instead of json object.

Get image by id

Clear All Data When Restart

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);  ...

Summary

Handling file(s) uploads with Hapi is not as hard as you (I) thought.

The complete sourcecode is available here: https://github.com/chybie/file-upload-hapi.

That’s it. Happy coding.


You may also like...