Build a RESTful API using hapi.js and MongoDB

Update 05/03/2017: reworked the whole article for hapi 16 and the newest libs!

This is the beginning of a new series about hapi.js (from now on only hapi). If you never heard of hapi before: it is a Node.js web framework for websites and webservices like Express but with some differences. When familiar with Express you should have no trouble following this tutorial...

Our Application

We are going to build a RESTful API that:

  • handles basic CRUD for an item (books in this case)
  • uses MongoDB to store and retrieve those books
  • validates the input and returns meaningful errors
  • works with JSON data
  • uses proper HTTP verbs (GET, POST, PATCH, DELETE)

So far this is pretty standard except maybe the validation part. This is fairly easy to build with hapi (as are many other things as you will see in the next tutorials).

Getting started

Make sure you have the latest version of Node installed on your machine (currently v7.7.1) as we use some of the new ES6 features.

Also you should have a MongoDB server up and running on your localhost. Make sure it listens to the default port: localhost:27017

Everything setup? Let's continue...

Start fast

If you want to skip the step by step copy and paste, just clone the Github Repository and follow the explanations. Otherwise just skip this section.

git clone https://github.com/p-meier/hapi-rest-mongo.git  
cd hapi-rest-mongo  
npm install  

Step 1: Setup project

This is the file structure of our application. It is very simple, but we already move out the routes into an own file.

- node_modules/     // created by 'npm install'
- routes/
----- books.js      // route definitions for book resource
- package.json      // dependencies and project info
- server.js         // entry point of app, define server
Node dependencies

Create a new file named package.json with this content:

{
  "name": "hapi-rest-mongo",
  "version": "1.0.0",
  "description": "Simple REST project with hapijs and mongodb.",
  "main": "server.js",
  "private": true,
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "boom": "^4.2.0",
    "hapi": "^16.1.0",
    "joi": "^10.2.2",
    "mongojs": "^2.4.0",
    "node-uuid": "^1.4.7"
  }
}

So what are all these packages for? hapi should be clear. boom is used for http friendly errors. joi is used for the input validation. Both packages are from the hapi ecosystem. To access our db we use mongojs and node-uuid to generate the ids for our objects.

Now it is time to install our dependencies. Fire up your command line, navigate into the root directory of your app and type:

npm install  

Step 2: Create a basic server

Our next step will be to create a basic server so we can see everything is up and running as expected.

Create our server.js file and fill in this content:

'use strict';

const Hapi = require('hapi');

// Create a server with a host and port
const server = new Hapi.Server();  
server.connection({  
    host: 'localhost',
    port: 3000
});

// Add the route
server.route({  
    method: 'GET',
    path:'/books',
    handler: function (request, reply) {

        return reply('Here the books will be shown soon...');
    }
});

// Start the server
server.start((err) => {

    if (err) {
        throw err;
    }
    console.log('Server running at:', server.info.uri);
});

This code is very simple. First a server with a new connection on port 3000 is created. Then a route is added for handling GET requests on path /books which returns a simple text. Finally our server is started.

Starting and testing our server

Now let’s make sure that everything is working up to this point. We will start our app and then send a request to the one route we defined to make sure we get a response.

Start the server with this command:

node server.js  

Open your browser and navigate to http://localhost:3000/books. You should see our text...

Screenshot of browser

Step 3: Connect to local MongoDB

We use the mongojs library to connect to our local MongoDB server. This is very simple and we only need 2 additional lines of code. This is the beginning of our server.js file:

'use strict';

const Hapi = require('hapi');  
const mongojs = require('mongojs');  //<--- Added

// Create a server with a host and port
const server = new Hapi.Server();  
server.connection({  
    host: 'localhost', 
    port: 3000
});

//Connect to db
server.app.db = mongojs('hapi-rest-mongo', ['books']);  //<--- Added

// Add the route

...

We first require the mongojs library. Then we store our db connection in server.app.db so we can access it from everywhere we have access to our server.

Step 4: Add routes

Time to remove our test route and create the book resource. Create a new file routes/books.js with the following content:

'use strict';

const Boom = require('boom');  
const uuid = require('node-uuid');  
const Joi = require('joi');

exports.register = function(server, options, next) {

  const db = server.app.db;

  //PLACEHOLDER
  //--------------------------------------------------------------
  //Here the routes definitions will be inserted in the next steps...

  return next();
};

exports.register.attributes = {  
  name: 'routes-books'
};

This defines a new hapi plugin called routes-books which encapsulates our routes definitions and handlers. Plugins are a central concept of hapi and allow to build modular applications.

We need to register this newly created plugin in our server.js. This is the complete file how it should look like by now:

'use strict';

const Hapi = require('hapi');  
const mongojs = require('mongojs');

// Create a server with a host and port
const server = new Hapi.Server();  
server.connection({  
  port: 3000
});

//Connect to db
server.app.db = mongojs('hapi-rest-mongo', ['books']);

//Load plugins and start server
server.register([  
  require('./routes/books')
], (err) => {

  if (err) {
    throw err;
  }

  // Start the server
  server.start((err) => {
    console.log('Server running at:', server.info.uri);
  });

});
List all books: GET /books

The first route we implement is to list all the books currently stored in MongoDB. Add this code to our plugin in routes/books.js:

...

server.route({  
    method: 'GET',
    path: '/books',
    handler: function (request, reply) {

        db.books.find((err, docs) => {

            if (err) {
                return reply(Boom.wrap(err, 'Internal MongoDB error'));
            }

            reply(docs);
        });

    }
});

...

The implementation of the handler is fairly easy. We just call the find method of the books collection to return all documents and use reply to send them to the client. If something goes wrong we use Boom to return a 422 error.

List a single book: GET /books/:id

The next route we implement is to get one single book. Add this code to our plugin in routes/books.js:

...

server.route({  
    method: 'GET',
    path: '/books/{id}',
    handler: function (request, reply) {

        db.books.findOne({
            _id: request.params.id
        }, (err, doc) => {

            if (err) {
                return reply(Boom.wrap(err, 'Internal MongoDB error'));
            }

            if (!doc) {
                return reply(Boom.notFound());
            }

            reply(doc);
        });

    }
});


...

In the handler we use the findOne method of the books collection. Note we get the id from the path via request.params.id. If no document is found we return a 404 error.

Create a new book: POST /books

In this route we implement a way to create new books. Add this code to our plugin in routes/books.js:

...

server.route({  
    method: 'POST',
    path: '/books',
    handler: function (request, reply) {

        const book = request.payload;

        //Create an id
        book._id = uuid.v1();

        db.books.save(book, (err, result) => {

            if (err) {
                return reply(Boom.wrap(err, 'Internal MongoDB error'));
            }

            reply(book);
        });
    },
    config: {
        validate: {
            payload: {
                title: Joi.string().min(10).max(50).required(),
                author: Joi.string().min(10).max(50).required(),
                isbn: Joi.number()
            }
        }
    }
});

...

In this handler we obtain the json book object submitted by the client via request.payload. The we generate a unique id for and save it in our database with the save method of the books collection.

Validation

Here we made use of the input validation provided by hapi and Joi for the first time.

We validate the payload to have at least a title, an author and optionally an isbn. Also we define the data types and some restrictions on length.

If there are any violations hapi automatically sends back an appropriate and readable error message with status code 400. Our handler function gets never called in this case. So if our handler function is executed we can be sure of valid data - very cool.

Update a single book: PATCH /books/:id

In this route we implement a way to update an existing book. We use the PATCH method here and allow the update of individual fields or the whole document. Add this code to our plugin in routes/books.js:

...

server.route({  
    method: 'PATCH',
    path: '/books/{id}',
    handler: function (request, reply) {

        db.books.update({
            _id: request.params.id
        }, {
            $set: request.payload
        }, function (err, result) {

            if (err) {
                return reply(Boom.wrap(err, 'Internal MongoDB error'));
            }

            if (result.n === 0) {
                return reply(Boom.notFound());
            }

            reply().code(204);
        });
    },
    config: {
        validate: {
            payload: Joi.object({
                title: Joi.string().min(10).max(50).optional(),
                author: Joi.string().min(10).max(50).optional(),
                isbn: Joi.number().optional()
            }).required().min(1)
        }
    }
});

...

This handler should not contain any surprises. We use the update method of the books collection to set all attributes that are provided by the client in request.payload.

In the validation part we say which attributes are valid and how they should look like. Also we say that at least one attribute must be set - so an empty object would be invalid.

Delete a single book: DELETE /books/:id

Finally we implement a route that allows the deletion of a single book. Add this code to our plugin in routes/books.js:

...

server.route({  
    method: 'DELETE',
    path: '/books/{id}',
    handler: function (request, reply) {

        db.books.remove({
            _id: request.params.id
        }, function (err, result) {

            if (err) {
                return reply(Boom.wrap(err, 'Internal MongoDB error'));
            }

            if (result.n === 0) {
                return reply(Boom.notFound());
            }

            reply().code(204);
        });
    }
});

...

After all the other handlers this one should be pretty self explanatory.

Step 5: Testing our work

Now that we have finished coding we should test our work. You can use curl for this or a tool like Postman.

How to use each of those tools is not part of this tutorial. I assume you are already familiar with one of these or something similar.

Code

You can download the complete code of this tutorial on Github.


You want to learn more?

I just created a new online video course on Udemy and offer a 50% discount to loyal readers of my blog...

All you have to do is to click on the course image above. The discount code is included in the link!

I'm happy to see you in the course...


Patrick Meier

I am an entrepreneur and software developer, building scalable, distributed web systems with Java, NodeJs and AngularJs.

Weiden, Germany