Building Apps and Services with the Hapi.js Framework

Hapi.js is described as “a rich framework for building applications and services”. Hapi’s smart defaults make it a breeze to create JSON APIs, and its modular design and plugin system allow you to easily extend or modify its behavior.

The recent release of version 17.0 has fully embraced async and await, so you’ll be writing code that appears synchronous but is non-blocking and avoids callback hell. Win-win.

The Project

In this article, we’ll be building the following API for a typical blog from scratch:

# RESTful actions for fetching, creating, updating and deleting articles
GET    /articles                articles#index
GET    /articles/:id            articles#show
POST   /articles                articles#create
PUT    /articles/:id            articles#update
DELETE /articles/:id            articles#destroy

# Nested routes for creating and deleting comments
POST   /articles/:id/comments   comments#create
DELETE /articles/:id/comments   comments#destroy

# Authentication with JSON Web Tokens (JWT)
POST   /authentications         authentications#create
--ADVERTISEMENT--

The article will cover:

  • Hapi’s core API: routing, request and response
  • models and persistence in a relational database
  • routes and actions for Articles and Comments
  • testing a REST API with HTTPie
  • authentication with JWT and securing routes
  • validation
  • an HTML View and Layout for the root route /.

The Starting Point

Make sure you’ve got a recent version of Node.js installed; node -v should return 8.9.0 or higher.

Download the starting code from here with git:

git clone https://github.com/markbrown4/hapi-api.git
cd hapi-api
npm install

Open up package.json and you’ll see that the “start” script runs server.js with nodemon. This will take care of restarting the server for us when we change a file.

Run npm start and open http://localhost:3000/:

[{ "so": "hapi!" }]

Let’s look at the source:

// server.js
const Hapi = require('hapi')

// Configure the server instance
const server = Hapi.server({
  host: 'localhost',
  port: 3000
})

// Add routes
server.route({
  method: 'GET',
  path: '/',
  handler: () => {
    return [{ so: 'hapi!' }]
  }
})

// Go!
server.start().then(() => {
  console.log('Server running at:', server.info.uri)
}).catch(err => {
  console.log(err)
  process.exit(1)
})

The Route Handler

The route handler is the most interesting part of this code. Replace it with the code below, comment out the return lines one by one, and test the response in your browser.

server.route({
  method: 'GET',
  path: '/',
  handler: () => {
    // return [{ so: 'hapi!' }]
    return 123
    return `

HTML rules!

` return null return new Error('Boom') return Promise.resolve({ whoa: true }) return require('fs').createReadStream('index.html') } })

To send a response, you simply return a value and Hapi will send the appropriate body and headers.

  • An Object will respond with stringified JSON and Content-Type: application/json
  • String values will be Content-Type: text/html
  • You can also return a Promise or Stream.

The handler function is often made async for cleaner control flow with Promises:

server.route({
  method: 'GET',
  path: '/',
  handler: async () => {
    let html = await Promise.resolve(`

Google

`) html = html.replace('Google', 'Hapi') return html } })

It’s not always cleaner with async though. Sometimes returning a Promise is simpler:

handler: () => {
  return Promise.resolve(`

Google

`) .then(html => html.replace('Google', 'Hapi')) }

We’ll see better examples of how async helps us out when we start interacting with the database.

The Model Layer

Like the popular Express.js framework, Hapi is a minimal framework that doesn’t provide any recommendations for the Model layer or persistence. You can choose any database and ORM that you’d like, or none — it’s up to you. We’ll be using SQLite and the Sequelize ORM in this tutorial to provide a clean API for interacting with the database.

SQLite comes pre-installed on macOS and most Linux distributions. You can check if it’s installed with sqlite -v. If not, you can find installation instructions at the SQLite website.

Sequelize works with many popular relational databases like Postgres or MySQL, so you’ll need to install both sequelize and the sqlite3 adapter:

npm install --save sequelize sqlite3

Let’s connect to our database and write our first table definition for articles:

// models.js
const path = require('path')
const Sequelize = require('sequelize')

// configure connection to db host, user, pass - not required for SQLite
const sequelize = new Sequelize(null, null, null, {
  dialect: 'sqlite',
  storage: path.join('tmp', 'db.sqlite') // SQLite persists its data directly to file
})

// Here we define our Article model with a title attribute of type string, and a body attribute of type text. By default, all tables get columns for id, createdAt, updatedAt as well.
const Article = sequelize.define('article', {
  title: Sequelize.STRING,
  body: Sequelize.TEXT
})

// Create table
Article.sync()

module.exports = {
  Article
}

Let’s test out our new model by importing it and replacing our route handler with the following:

// server.js
const { Article } = require('./models')

server.route({
  method: 'GET',
  path: '/',
  handler: () => {
    // try commenting these lines out one at a time
    return Article.findAll()
    return Article.create({ title: 'Welcome to my blog', body: 'The happiest place on earth' })
    return Article.findById(1)
    return Article.update({ title: 'Learning Hapi', body: `JSON API's a breeze.` }, { where: { id: 1 } })
    return Article.findAll()
    return Article.destroy({ where: { id: 1 } })
    return Article.findAll()
  }
})

If you’re familiar with SQL or other ORM’s, the Sequelize API should be self explanatory, It’s built with Promises so it works great with Hapi’s async handlers too.

Note: using Article.sync() to create the tables or Article.sync({ force: true }) to drop and create are fine for the purposes of this demo. If you’re wanting to use this in production you should check out sequelize-cli and write Migrations for any schema changes.

Our RESTful Actions

Let’s build the following routes:

GET     /articles        fetch all articles
GET     /articles/:id    fetch article by id
POST    /articles        create article with `{ title, body }` params
PUT     /articles/:id    update article with `{ title, body }` params
DELETE  /articles/:id    delete article by id

Add a new file, routes.js, to separate the server config from the application logic:

// routes.js
const { Article } = require('./models')

exports.configureRoutes = (server) => {
  // server.route accepts an object or an array
  return server.route([{
    method: 'GET',
    path: '/articles',
    handler: () => {
      return Article.findAll()
    }
  }, {
    method: 'GET',
    // The curly braces are how we define params (variable path segments in the URL)
    path: '/articles/{id}',
    handler: (request) => {
      return Article.findById(request.params.id)
    }
  }, {
    method: 'POST',
    path: '/articles',
    handler: (request) => {
      const article = Article.build(request.payload.article)

      return article.save()
    }
  }, {
    // method can be an array
    method: ['PUT', 'PATCH'],
    path: '/articles/{id}',
    handler: async (request) => {
      const article = await Article.findById(request.params.id)
      article.update(request.payload.article)

      return article.save()
    }
  }, {
    method: 'DELETE',
    path: '/articles/{id}',
    handler: async (request) => {
      const article = await Article.findById(request.params.id)

      return article.destroy()
    }
  }])
}

Import and configure our routes before we start the server:

// server.js
const Hapi = require('hapi')
const { configureRoutes } = require('./routes')

const server = Hapi.server({
  host: 'localhost',
  port: 3000
})

// This function will allow us to easily extend it later
const main = async () => {
  await configureRoutes(server)
  await server.start()

  return server
}

main().then(server => {
  console.log('Server running at:', server.info.uri)
}).catch(err => {
  console.log(err)
  process.exit(1)
})

Testing Our API Is as Easy as HTTPie

HTTPie is great little command-line HTTP client that works on all operating systems. Follow the installation instructions in the documentation and then try hitting the API from the terminal:

http GET http://localhost:3000/articles
http POST http://localhost:3000/articles article:='{"title": "Welcome to my blog", "body": "The greatest place on earth"}'
http POST http://localhost:3000/articles article:='{"title": "Learning Hapi", "body": "JSON APIs a breeze."}'
http GET http://localhost:3000/articles
http GET http://localhost:3000/articles/2
http PUT http://localhost:3000/articles/2 article:='{"title": "True happiness, is an inner quality"}'
http GET http://localhost:3000/articles/2
http DELETE http://localhost:3000/articles/2
http GET http://localhost:3000/articles

Okay, everything seems to be working well. Let’s try a few more:

http GET http://localhost:3000/articles/12345
http DELETE http://localhost:3000/articles/12345

Yikes! When we try to fetch an article that doesn’t exist, we get a 200 with an empty body and our destroy handler throws an Error which results in a 500. This is happening because findById returns null by default when it can’t find a record. We want our API to respond with a 404 in both of these cases. There’s a few ways we can achieve this.

Defensively Check for null Values and Return an Error

There’s a package called boom which helps create standard error response objects:

npm install --save boom

Import it and modify GET /articles/:id route:

// routes.js
const Boom = require('boom')

{
  method: 'GET',
  path: '/articles/{id}',
  handler: async (request) => {
    const article = await Article.findById(request.params.id)
    if (article === null) return Boom.notFound()

    return article
  }
}

Extend Sequelize.Model to throw an Error

Sequelize.Model is a reference to the prototype that all of our Models inherit from, so we can easily add a new method find to findById and throw an error if it returns null:

// models.js
const Boom = require('boom')

Sequelize.Model.find = async function (...args) {
  const obj = await this.findById(...args)
  if (obj === null) throw Boom.notFound()

  return obj
}

We can then revert the handler to its former glory and replace occurrences of findById with find:

{
  method: 'GET',
  path: '/articles/{id}',
  handler: (request) => {
    return Article.find(request.params.id)
  }
}
http GET http://localhost:3000/articles/12345
http DELETE http://localhost:3000/articles/12345

Boom. We now get a 404 Not Found error whenever we try to fetch something from the database that doesn’t exist. We’ve replaced our custom error checks with an easy-to-understand convention that keeps our code clean.

Note: another popular tool for making requests to REST APIs is Postman. If you prefer a UI and ability to save common requests, this is a great option.

Path Parameters

The routing in Hapi is a little different from other frameworks. The route is selected on the specificity of the path, so the order you define them in doesn’t matter.

  • /hello/{name} matches /hello/bob and passes 'bob' as the name param
  • /hello/{name?} — the ? makes name optional and matches both /hello and /hello/bob
  • /hello/{name*2} — the * denotes multiple segments, matching /hello/bob/marley by passing 'bob/marley' as the name param
  • /{args*} matches /any/route/imaginable and has the lowest specificity.

The Request Object

The request object that’s passed to the route handler has the following useful properties:

  • request.params — path params
  • request.query — query string params
  • request.payload — request body for JSON or form params
  • request.state — cookies
  • request.headers
  • request.url

Adding a Second Model

Our second model will handle comments on articles. Here’s the complete file:

// models.js
const path = require('path')
const Sequelize = require('sequelize')
const Boom = require('boom')

Sequelize.Model.find = async function (...args) {
  const obj = await this.findById(...args)
  if (obj === null) throw Boom.notFound()

  return obj
}

const sequelize = new Sequelize(null, null, null, {
  dialect: 'sqlite',
  storage: path.join('tmp', 'db.sqlite')
})

const Article = sequelize.define('article', {
  title: Sequelize.STRING,
  body: Sequelize.TEXT
})

const Comment = sequelize.define('comment', {
  commenter: Sequelize.STRING,
  body: Sequelize.TEXT
})

// These associations add an articleId foreign key to our comments table
// They add helpful methods like article.getComments() and article.createComment()
Article.hasMany(Comment)
Comment.belongsTo(Article)

// Create tables
Article.sync()
Comment.sync()

module.exports = {
  Article,
  Comment
}

For creating and deleting comments we can add nested routes under the article’s path:

// routes.js
const { Article, Comment } = require('./models')

{
  method: 'POST',
  path: '/articles/{id}/comments',
  handler: async (request) => {
    const article = await Article.find(request.params.id)

    return article.createComment(request.payload.comment)
  }
}, {
  method: 'DELETE',
  path: '/articles/{articleId}/comments/{id}',
  handler: async (request) => {
    const { id, articleId } = request.params
    // You can pass options to findById as a second argument
    const comment = await Comment.find(id, { where: { articleId } })

    return comment.destroy()
  }
}

Lastly, we can extend GET /articles/:id to return both the article and its comments:

{
  method: 'GET',
  path: '/articles/{id}',
  handler: async (request) => {
    const article = await Article.find(request.params.id)
    const comments = await article.getComments()

    return { ...article.get(), comments }
  }
}

article here is the Model object; article.get() returns a plain object with the model’s values, on which we can use the spread operator to combine with our comments. Let’s test it out:

http POST http://localhost:3000/articles/3/comments comment:='{ "commenter": "mb4", "body": "Agreed, this blog rules!" }'
http POST http://localhost:3000/articles/3/comments comment:='{ "commenter": "Nigerian prince", "body": "You are the beneficiary of a Nigerian prince’s $4,000,000 fortune." }'
http GET http://localhost:3000/articles/3
http DELETE http://localhost:3000/articles/3/comments/2
http GET http://localhost:3000/articles/3

Our blog API is almost ready to ship to production, just needing a couple of finishing touches.

Authentication with JWT

JSON Web Tokens are a common authentication mechanism for APIs. There’s a plugin hapi-auth-jwt2 for setting it up, but it hasn’t yet been updated for Hapi 17.0, so we’ll need to install a fork for now:

npm install --save salzhrani/hapi-auth-jwt2#v-17

The code below registers the hapi-auth-jwt2 plugin and sets up a strategy named admin using the jwt scheme. If a valid JWT token is sent in a header, query string or cookie, it will call our validate function to verify that we’re happy to grant those credentials access:

// auth.js
const jwtPlugin = require('hapi-auth-jwt2').plugin
// This would be in an environment variable in production
const JWT_KEY = 'NeverShareYourSecret'

var validate = function (credentials) {
  // Run any checks here to confirm we want to grant these credentials access
  return {
    isValid: true,
    credentials // request.auth.credentials
  }
}

exports.configureAuth = async (server) => {
  await server.register(jwtPlugin)
  server.auth.strategy('admin', 'jwt', {
    key: JWT_KEY,
    validate,
    verifyOptions: { algorithms: [ 'HS256' ] }
  })

  // Default all routes to require JWT and opt out for public routes
  server.auth.default('admin')
}

Next, import and configure our auth strategy before starting the server:

// server.js
const { configureAuth } = require('./auth')

const main = async () => {
  await configureAuth(server)
  await configureRoutes(server)
  await server.start()

  return server
}

Now all routes will require our admin auth strategy. Try these three:

http GET localhost:3000/articles
http GET localhost:3000/articles Authorization:yep
http GET localhost:3000/articles Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6IkFudGhvbnkgVmFsaWQgVXNlciIsImlhdCI6MTQyNTQ3MzUzNX0.KA68l60mjiC8EXaC2odnjFwdIDxE__iDu5RwLdN1F2A

The last one should contain a valid token and return the articles from the database. To make a route public, we just need to add config: { auth: false } to the route object. For example:

{
  method: 'GET',
  path: '/articles',
  handler: (request) => {
    return Article.findAll()
  },
  config: { auth: false }
}

Make these three routes public so that anyone can read articles and post comments:

GET    /articles                articles#index
GET    /articles/:id            articles#show
POST   /articles/:id/comments   comments#create

Generating a JWT

There’s a package named jsonwebtoken for signing and verifying JWT:

npm install --save jsonwebtoken

Our final route will take an email / password and generate a JWT. Let’s define our login function in auth.js to keep all auth logic in a single place:

// auth.js
const jwt = require('jsonwebtoken')
const Boom = require('boom')

exports.login = (email, password) => {
  if (!(email === 'mb4@gmail.com' && password === 'bears')) return Boom.notAcceptable()

  const credentials = { email }
  const token = jwt.sign(credentials, JWT_KEY, { algorithm: 'HS256', expiresIn: '1h' })

  return { token }
}
// routes.js
const { login } = require('./auth')

{
  method: 'POST',
  path: '/authentications',
  handler: async (request) => {
    const { email, password } = request.payload.login

    return login(email, password)
  },
  config: { auth: false }
}
http POST localhost:3000/authentications login:='{"email": "mb4@gmail.com", "password": "bears"}'

Try using the returned token in your requests to the secure routes!

Validation with joi

You can validate request params by adding config to the route object. The code below ensures that the submitted article has a body and title between three and ten characters in length. If a validation fails, Hapi will respond with a 400 error:

const Joi = require('joi')

{
    method: 'POST',
    path: '/articles',
    handler: (request) => {
      const article = Article.build(request.payload.article)

      return article.save()
    },
    config: {
      validate: {
        payload: {
          article: {
            title: Joi.string().min(3).max(10),
            body: Joi.string().required()
          }
        }
      }
    }
  }
}

In addition to payload, you can also add validations to path, query and headers. Learn more about validation in the docs.

Who Is Consuming this API?

We could serve a single-page app from /. We’ve already seen — at the start of the tutorial — one example of how to serve an HTML file with streams. There are much better ways of working with Views and Layouts in Hapi, though. See Serving Static Content and Views and Layouts for more on how to render dynamic views:

{
  method: 'GET',
  path: '/',
  handler: () => {
    return require('fs').createReadStream('index.html')
  },
  config: { auth: false }
}

If the front end and API are on the same domain, you’ll have no problems making requests: client -> hapi-api.

If you’re serving the front end from a different domain and want to make requests to the API directly from the client, you’ll need to enable CORS. This is super easy in Hapi:

const server = Hapi.server({
  host: 'localhost',
  port: 3000,
  routes: {
    cors: {
      credentials: true
      // See options at https://hapijs.com/api/17.0.0#-routeoptionscors
    }
  }
})

You could also create a new application in between the two. If you go down this route, you won’t need to bother with CORS, as the client will only be making requests to the front-end app, and it can then make requests to the API on the server without any cross-domain restrictions: client -> hapi-front-end -> hapi-api.

Whether that front end is another Hapi application, or Next, or Nuxt … I’ll leave that for you to decide!