Last week I was in charge of creating a simple API with a small amount of endpoints defined to expose certain information existing in a Postgres database. I decided to use hapi.js as framework to develop the API since most of the team is comfortable with javascript and we liked the idea behind the plugin system. hapi will enable you to focus on writing reusable application logic instead of spending time building infrastructure. In this post we will create a simple API and hopefully understand the benefits of using hapi.

hapi.js background

hapi (for HTTP API server) was created by the mobile web team at Walmart Labs, the creation of the framework resided in the lack of certain features in the frameworks that node offered at the time. Eran Hammer explains in his article hapi, a Prologue the early problems the team encountered when trying to tailor frameworks to their needs. If you really want to know a little more about how hapi came to be, you should read the article and if you want hapi resource you should check out hapi.js in action by Matt Harrison.

hapi was created around the idea that configuration is better than code, that business logic must be isolated from the transport layer, and that native node constructs like buffers and stream should be supported as first class objects. But most importantly, it was created to provide a modern, comprehensive environment in which as much of the effort is spent delivering business value.

Project

The project will try to create a very simple API that will help a music website manipulate resources, in this case the resources will be vinyls. If you are young you may not know what is a vinyl, you can have a clear definition by checking out this article in Wikipedia.

In this tutorial we will be using node 6.5 and rethinkdb by creating containers with Docker and creating a simple configuration with Docker-Compose.
Docker and Docker-Compose Setup

Docker will let us create containers and run our project anywhere (as long as the machine supports Docker) leaving aside all the issues of package versions or the cumbersome setup of a database for a simple tutorial like this one. The tutorial will use docker-compose 1.11.2 and Docker 17.03.0-ce, in order to check your version run the following commands:

1
2
3
4
(docker-compose) ➜ dc-002-hapi-api git:(master) docker-compose --version
docker-compose version 1.11.2, build dfed245
(docker-compose) ➜ dc-002-hapi-api git:(master) docker --version
Docker version 17.03.0-ce, build 60ccb2265b

Without further a due lets create our project structure, create a new folder for our whole project, afterwards create a new folder with the name music-api this will hold our API files. Change directory into our newly created folder and create a Dockerfile with the following content:

hapijs Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Create a Docker image with node 6.5.0
FROM node:6.5.0
# downloads the package lists from the repositories and "updates"
# them to get information on the newest versions of packages and their dependencies
RUN apt-get update -y
# Install pip package manager
RUN apt-get install python-pip -y
# Install rethinkdb python drivers
RUN pip install rethinkdb
# Within the container create the /usr/src/app folders
RUN mkdir -p /usr/src/app
# The WORKDIR instruction sets the working directory for any
# RUN, CMD, ENTRYPOINT, COPY and ADD instructions that follow it in the Dockerfile
WORKDIR /usr/src/app
# When building the image copy the package.json file to container's /usr/src/app path
ONBUILD COPY package.json /usr/src/app/
# With our copied package.json run npm install to get all our desired modules
ONBUILD RUN npm install
# Copy our app files into container's /usr/src/app path
ONBUILD COPY . /usr/src/app
# Expose our desired port
EXPOSE 3000
# Default command to run
CMD [ "npm", "start" ]

Now we have a Dockerfile with node, we still need something to wrap up our containers and create a image for the database we will be using. Docker-compose will allow us to create a yml file with the desired configuration for our containers. Lets create a docker-compose.yml file in the root of our project:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
version: "3"
services:
db: # DB container
image: rethinkdb:2.3.5 # Get latest rethinkdb release
ports: # Expose rethinkdb admin tool and tcp
- "8080:8080"
- "28015:28015"
api: # API container
build: music-api/. # Path to the Dockerfile
command: npm start # The desired command to run at docker-compose up
links: # Create a link between this container and our db container
- db
depends_on: Express dependency between services
- db
volumes: # Link our volumes path_to_host_files:path_to_container
- ./music-api:/usr/src/app
ports: # Expose the API listening port
- "3000:3000"

Nice, right now we should have a project structure like the following:

Structure
1
2
3
4
.
+-- music-api +
| +-- Dockerfile +
+-- docker-compose.yml +

We have everything ready to build our images, so lets do it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
docker-compose build # Use option --no-cache to fully rebuild container
# You should be able to see something like this
# rethink-music uses an image, skipping
# Building hapi-api
# Step 1 : FROM node:6.5.0
# 6.5.0: Pulling from library/node
# 8ad8b3f87b37: Downloading [============================> ] 50.32 MB/51.37 MB
# 751fe39c4d34: Download complete
# ae3b77eefc06: Downloading [========================> ] 32.8 MB/42.5 MB
# 7783aac582ec: Downloading [=============> ] 48.09 MB/129.8 MB
# 393ad8a32e58: Waiting
# 2d923dade19b: Waiting
# ...
# Successfully built e50196e10ad8
docker images # list all images :O!
# REPOSITORY TAG IMAGE ID CREATED SIZE
# apihapi_hapi-api latest b176ae4b8078 About a minute ago 680.3 MB
# node 6.5.0 e3e7156ded1f 2 weeks ago 652.8 MB

We need to create a package.json so we can save our project dependencies and use hapi, we could easily run npm init in our console if we had node installed in our host machine but instead lets use docker-compose to fulfill this task. Change directory to the root of our project and run the following:

1
docker-compose run --rm api npm init

The command docker-compose run will run the desired container and execute the command specified inside our container, we should get prompted with a utility which will walk you through the process of creating a package.json file, at the end you should be able to see that a package.json file was created in our music-api folder with the input you gave to each of the fields the utility requested. You should have a package.json with the following structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"name": "music-api",
"version": "0.0.1",
"description": "music api with hapijs",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"hapijs",
"api",
"music"
],
"author": "Omar Eduardo Torres <torres.omar.eduardo@gmail.com> (http://dataquito.org)",
"license": "MIT"
}

It's very important that every time you run docker-compose run you use the --rm option, this will remove the container that was created for the given command, in case you don't use this you will have a huge list of unused container in your machine. But why clean later what you can clean now!

We still need to install our dependencies, so we should run npm install in our container.

1
docker-compose run --rm api npm install --save hapi boom

This will add all the modules we will need into the node_modules directory and will add the dependencies attr into our package.json with hapi and boom as the values. Lets add a index.js file and mount a server using hapi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
'use strict';
const Hapi = require('hapi'); // Require hapi
// Create a new server object
const server = new Hapi.Server();
// Create array of plugins we want to register, empty at the moment
const plugins = [];
// Adds an incoming server connection
server.connection({
host: '0.0.0.0', // If you use localhost Docker won't expose the app
port: 3000 // Make sure this port is not being used by any other app
});
// Register plugins
server.register(plugins, (err) => {
if(err) {
console.log('There was an error loading plugins...');
console.log('Terminating...');
console.log(err);
throw err;
}
// Start server
server.start((err) => {
if(err) {
console.log('There was an error at server start...');
console.log('Terminating...');
console.log(err);
throw err;
}
console.log(`Server running at: ${server.info.uri}`);
});
});

Lets explain what is happening in the previous snippet, first of all lets set strict mode, if you need more info about why the use of this mode you may be able an answer in any of the following links, MDN - Strict mode, What does “use strict” do in JavaScript, and what is the reasoning behind it?, I personally like to run strict mode since it eliminates some annoying JavaScript silent errors.

  1. Import the hapi module.
  2. Create a Server object in which you may include an optional configuration object.
  3. Create an array of plugins (empty at the moment).
  4. Add a server connection with the following attributes:
    • host the public hostname or IP address
    • port the desired TCP port the connection will listen to
  5. Register plugins.
  6. Issue server start, which will start the server connections by listening for incoming requests on the configured port of each listener.

Perfect, we have a functional server with hapi, right? Well don’t just believe me and lets test our work, run the following in your terminal:

1
docker-compose up

You should see a bunch of logs of rethinkdb being initialized and afterwards our node app should be up, with the sweet message “Server running at: http://0.0.0.0:3000. Amazing, lets go and make a call to our newly created server:

1
2
curl 0.0.0.0:3000
# {"statusCode":404,"error":"Not Found"}

The 404 is a good sign, but why are we having this issue? Our server is running but we have to define the routes which we will allow, if no routes are defined we will continue getting those 404 errors.

routes

The server will need route definitions in order to return a response to the client, hapi will allow us to define routes the following way:

1
2
3
4
5
6
7
8
9
10
11
server.register(plugins, (err) => {
// if(err) {}
server.route({
method: 'GET',
path: '/test',
handler: (request, reply) => {
reply('amazing!');
}
});
// server.start((err) => {...})
});

The previously defined route will create an endpoint at /test and respond with the text amazing!. Lets use curl and test it all works out:

1
amazing!

Perfect, we have a working route, we should start creating the endpoints for our database, but first lets try to understand the benefits of using plugins within our application and how this will help isolate and reuse functionality.

plugins

Remember how we talked about hapi being a plugin-based framework?, well now we can start making our own plugins. The hapi documentation explains how plugins provide a way to organize application code by splitting the server logic into smaller components, within the plugins you will be able to manipulate the server and its connections through the standard server interface. The benefit you get is the sandboxing of certain server properties (e.g. routes, server methods, extension points, custom handlers and more).

At their core they are an object with a register function that has the signature function (server, options, next). That register function then has an attributes object attached to it to provide hapi with some additional information about the plugin, such as name and version.

hapijs official documentationPlugins

In order to understand better how plugins work, lets create a basic example, first lets create a folder with the name plugins and put everything we need to create our new plugin in there, the folder structure should see something like this:

1
2
3
4
5
6
7
8
9
.
+-- music-api
| +-- plugins/ +
| | +-- test-plugin/ +
| | | +-- index.js +
| +-- index.js
| +-- package.json
| +-- Dockerfile
+-- docker-compose.yml

Inside our index.js file in the test-plugin folder lets add the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const plugin = function (server, options, next) {
server.route({ // Add a route to our server
path: '/plugin',
method: 'GET',
handler:(request,reply)=>{
reply({
message: 'A plugin with options',
options: options
})
}
});
next(); // Always call next or we wont be able to start our server
};
plugin.attributes = { name: 'the-name-of-the-plugin', version: '0.0.1' };
module.exports = plugin;

In this plugin we will create a simple route which will return a JSON object with a message and the options we will be providing at the registration of our plugin. It is very important to call the next inside our plugin declaration to return control back to the framework to complete the registration process. Now that we have our plugin, we need our server to register the plugin, lets change the server plugins array we defined previously:

1
2
3
4
5
6
7
8
9
const plugins = [
{
register: require('./plugins/test-plugin'),
options: {
option_a: 'production',
option_b: 'http://www.google.com'
}
}
];

If the registration of our plugin is successful we will be able to test it out with a simple call to our server.

1
2
3
4
5
6
7
8
# curl http://0.0.0.0:3000/test
{
"message":"A plugin with options",
"options": {
"option_a": "production",
"option_b": "http://www.google.com"
}
}

Now that we understand how plugins work, we can start using them through out our application.

rethinkdb

RethinkDB is an open source, NoSQL, distributed document-oriented database. It stores JSON documents with dynamic schemas, and is designed to facilitate pushing real-time updates for query results to applications. The usage of this database is just for experimenting purposes, you should be able to use any database you want as long as you have the correct configuration. This requirement can be easily declared as a plugin, we just need a connection to rethinkdb in order to manage all the data manipulation.
Lets setup our plugin, lets follow the same structure in our previous plugin, create a folder with the name music-db and inside add the index.js and package.json files, we should have something like this:

1
2
3
4
5
6
7
8
9
10
11
12
.
+-- music-api
| +-- plugins/
| | +-- test-plugin/
| | | +-- index.js
| | +-- music-db +
| | | +-- index.js +
| | | +-- package.json +
| +-- index.js
| +-- package.json
| +-- Dockerfile
+-- docker-compose.yml

At this moment we have just two modules installed in our application hapi and boom, we are still in need of the rethinkdb module. Lets take care of that:

1
docker-compose run --rm api npm install --save rethinkdb

In our package.json we are going to declare all the attributes we want/need, in this case we will jsut declare the name and version of our plugin:

1
2
3
4
{
"name": "music‐db",
"version": "1.0.0"
}

Next lets create the logic behind our plugin by modifying our index.js file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
'use strict';
const r = require('rethinkdb');
exports.register = function (server, options, next) { // Plugin def
const dbOptions = { // Create options object
host: options.host,
port: options.port
};
// Create a new connection to the database server.
// https://www.rethinkdb.com/api/javascript/connect/
r.connect(dbOptions, (err, conn) => {
if (err) { // Throw error if connection failed
console.log('Error connecting to RethinkDB');
throw err;
} else { // All good!
console.log('Success connecting to RethinkDB');
// server.app is meant for storing run-time state
server.app.rConnection = conn;
next();
}
});
};
exports.register.attributes = {
pkg: require('./package')
};

We declared a basic plugin which will allow us to get a connection to our database, lets try to understand what we did to accomplish that:

  1. Incude rethinkdb.
  2. Create a function that has the signature function (server, options, next).
  3. Use the options object to configure our plugin.
  4. Use the rethinkdb’s connect to create a connection to our database
    • If a error was found throw it!
    • If everything works store connection using server.app.rConnection = conn;
  5. Execute callback.
  6. Use our package.json to declare our plugin attributes.

At the moment we just have the declaration of our plugin, we still need to register our plugin in our server configuration:

make this more obvious about being index.js
1
2
3
4
5
6
7
8
9
10
11
const plugins = [
// ...
{
register: require('./plugins/music-db'),
options: {
host: 'db', // Reachable at this hostname thanks to our docker-compose.yml
port: '28015',
database: 'music_store'
}
}
];

With our plugin completely configured we should be able to restart our server and see if everything worked as expected, you should see this output in the terminal

1
2
3
# hapi-api_1 | ......
# hapi-api_1 | Success connecting to RethinkDB
# hapi-api_1 | Server running at: http://0.0.0.0:3000

Two plugins already? Well get ready because we will add one more for our routes.

api routes

Remember how we previously declared routes, we need to include the API routes for a our vinyls. This will help us to manipulate our data, lets tackle this by creating a plugin that will contain our routes, lets create our file structure. Create the files so you match the music-api folder.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.
+-- music-api
| +-- plugins/
| | +-- test-plugin/
| | | +-- index.js
| | +-- music-db/
| | | +-- index.js
| | | +-- package.json
| | +-- music-api/ +
| | | +-- vinyl/ +
| | | | +-- index.js +
| | | | +-- schema.js +
| | | +-- index.js +
| | | +-- package.json +
| +-- index.js
| +-- package.json
| +-- Dockerfile
+-- docker-compose.yml

The files and folders included cover a very specific need:

  • index.js will contain our plugin configuration.
  • package.json describes our plugin.
  • vinyl folder will contain all the API files.
  • vinyl/index.js will have all the API handlers.
  • vinyl/schema.js will contain the schema validations for the API handlers.

We have our newly created plugin structure, lets create routes for each of the actions we want to have within our API.

index.js - plugin configuration
1
2
3
4
5
6
7
8
9
10
11
12
'use strict';
const vinyl = require('./vinyl'); // Require vinyl handlers
exports.register = function (server, options, next) { // Plugin signature
// Create a route which will create a vinyl resource in our database
server.route({ method: 'POST', path: '/vinyls', config: vinyl.create });
next();
};
exports.register.attributes = {
pkg: require('./package')
};

We are creating a route which will allow us to create vinyls (if you have any doubt regarding the use of the verbs and resource naming conventions check this link), this route will have a config attribute which will reference our create variable in our handlers file.

vinyl/index.js - handler-like functions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const vinylSchema = require('./schema.js'); // Load schemas
const r = require('rethinkdb');
const create = {
handler: (request, reply) => {
const vinyl = request.payload; // Get payload
r.db('music_store') // Specify db
.table('vinyls') // Specify table
.insert(vinyl) // Specify record
.run(request.server.app.rConnection) // Run query with server connection
.then(res => reply(res)) // Reply with newly created object
.catch(err => reply('err').code(500)); // Fail if something went wrong
},
validate: {
payload: vinylSchema.create // Apply schema validation to payload
}
};

Our handler will receive the payload from the request, the payload will be used directly to create our vinyl record. Inserting to rethinkdb is easy you just have to know where do you want to insert (database and table) and what do you want to insert (atm the payload we are receiving). The next item we need in order to run our insert operation (and many of the following operaion we will execute) is our connection to rethinkdb. When we created our database plugin we had a line that looked like this:

1
server.app.rConnection = conn;

The line shown above will allow us to get the connection from our server object and use it in any of our handlers. We have everything we need so we can create our data insert, but it is very importnt to undertsnad how rethinkdb manages operations. RethinkDB offers two ways to run a query on a connection. The first being callbacks, which will get either an error, a single JSON result, or a cursor, depending on the query. Promises are the second way rethinkdb offers, we will use promises ro run our queries.

rethinkdb runlink
1
2
query.run(conn[, options], callback)
query.run(conn[, options]) → promise

Since we are using a Promise approach we can call the then method and pass as argument an arrow function (to keep it short). The arrow function will have as argument the result from our query, so we can decide what to with it, we can transform the result but in this case we will simply call the reply method with the result given by the query. In order to finish a nice Promise flow we just need to call the catch method and return a 500 to tell the user something is failing. In a more robust API implementation you must deliver a better explanation of why the call to the endpoint failed, log the request, log the error thrown and many other things that would allow us to increase the performance and usability of our API.

You may be intrigued about the validate key in the create object, this will take care of our handler validations, in hapi validation is simple and easy, check out the docs to see the validation you will be able to use. Hapi uses Joi to perform validation, Joi is a object schema description language and validator for JavaScript objects. We are going to make use of Joi and the extensive API provided to validate certain parts of our API. Lets check out the content of the schema.js file.

1
2
3
4
5
const Joi = require('joi');
const create = Joi.object().keys({
// We will fill this in a bit
});

Our first validation will take care of the creation of a vinyl record, even though we are using a NoSQL database approach we still want to take care of the completeness of our data records. In order to create a record we will require certain fields to be present in our request. We are using fields used in a music marketplace called Discogs now lets see which fields we are going to use in order to create a vinyl. Lets check one of my favorite albums of all the time Operation Doomsday by MF DOOM. The detail of this LP will show us the core fields we may declare:

  • Artist
  • Title
  • Genre
  • Style
  • Year
  • Rating
  • Tracklist

With this in mind lets create a validation for all the fields listed above:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Joi = require('joi');
const track = Joi.object().keys({
number: Joi.number().integer().required(),
title: Joi.string().min(1).max(30).required(),
length: Joi.number().required()
});
const create = Joi.object().keys({
artist: Joi.string().min(1).max(30).required(),
title: Joi.string().min(1).max(30).required(),
genre: Joi.string().min(1).max(30).required(),
style: Joi.string().min(1).max(30).required(),
year: Joi.number().integer().min(1900).required(),
rating: Joi.number().min(0).max(5).required()
tracklist: Joi.array().items(track).required()
});

Now we have a decent validation for our vinyl record creation, we included an array item validation which will run the validation we desired for each element in our array.

Everything should be up and running to create a vinyl record, create a file with the json paylod you want to test the endpoint with and lets give it a try:

1
2
curl -v -H "Content-Type: application/json" -X POST -d @create.json http://0.0.0.0:3000/vinyls
{"deleted":0,"errors":0,"generated_keys":["dcf514e5-74c2-4392-ad15-eae5660ed1da"],"inserted":1,"replaced":0,"skipped":0,"unchanged":0}

It works! I always have a trust issue with backend and have to double check of the data I inserted is really there. The easiest way to achieve this is by checking our rethinkdb Administration Console, check our data explorer and run:

1
r.db('music_store').table('vinyls');

Now lets see how our validation works, run the same operation but change the json file to break a vinyl validation, in my case I will remove the artist key:

1
2
curl -v -H "Content-Type: application/json" -X POST -d @create_fail.json http://0.0.0.0:3000/vinyls
{"statusCode":400,"error":"Bad Request","message":"child \"artist\" fails because [\"artist\" is required]","validation":{"source":"payload","keys":["artist"]}}

Sweet our validation and our vinyl insert works, lets continue and create a way to get the details from any vinyl record.

index.js - plugin configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
'use strict';
const vinyl = require('./vinyl'); // Require vinyl handlers
exports.register = function (server, options, next) { // Plugin signature
// Create a route which will create a vinyl resource in our database
server.route({ method: 'POST', path: '/vinyls', config: vinyl.create });
// Use curly braces so hapi can recognize the params
server.route({ method: 'GET', path: '/vinyls/{id}', config: vinyl.read });
next();
};
exports.register.attributes = {
pkg: require('./package')
};

In our route we defined the route /vinyls/{id}, the id part will be the id generated automatically by rethinkdb. This parameter will be accesible from our handler in the request.params object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const read = {
handler: (request, reply) => {
const vinylID = request.params.id;
r.db('music_store')
.table('vinyls')
.filter({ id: vinylID }) // Get the record by the unique id rethinkdb uses
.run(request.server.app.rConnection)
.then(cursor => cursor.toArray()) // Cursor to array
.then(results => {
if(results.length === 0) { // If our arry is empty no record was found send 404
reply('Resource not found').code(404);
} else {
reply(results[0]); // Return our record
}
})
.catch(err => reply('error').code(500));
},
validate: {
params: { id: vinylSchema.vinylID }
}
}

In this handler we add the filter method to our query, the docs explain that filter will return all the elements in a sequence for which the given predicate is true more on the filter method.

1
2
3
4
5
6
// The predicate {age: 30} selects documents in the users table with an age field whose value is 30.
r.table('users').filter({ age: 30 }).run(conn, callback);
// You can even use anon functions
r.table('users').filter(function (user) {
return user("age").eq(30);
}).run(conn, callback);

The handler we created should be able to retrieve the vinyl object we stored in our database. Lets test this with curl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// curl -v -H "Content-Type: application/json" -X GET http://0.0.0.0:3000/vinyls/751bea96-fca0-4570-bc58-7916678cda82
{
"artist":"MF Doom",
"genre":"Hip Hop",
"id":"751bea96-fca0-4570-bc58-7916678cda82",
"rating":4.66,
"style":"Conscious",
"title":"Operation: Doomsday",
"tracklist":[
{
"length":2.04,
"number":1,
"title":"The Time We Faced Doom (Skit)"
}
],
"year":1999
}

We have a way to create a vinyl and consult it’s detail. Lets add a way to list all the available vinyls within the database by creating the following handler.

index.js - plugin configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'use strict';
const vinyl = require('./vinyl'); // Require vinyl handlers
exports.register = function (server, options, next) { // Plugin signature
// Create a route which will create a vinyl resource in our database
server.route({ method: 'POST', path: '/vinyls', config: vinyl.create });
// Use curly braces so hapi can recognize the params
server.route({ method: 'GET', path: '/vinyls/{id}', config: vinyl.read });
// Create a route which will retunr all the vinyls in our database
server.route({ method: 'GET', path: '/vinyls', config: vinyl.index });
next();
};
exports.register.attributes = {
pkg: require('./package')
};

The flow should be pretty similar from the detail handler, right? Well indeed it is, remember how we filtered by id in our previous handler in order to get a specific record? Well since we dont care about filtering in this handler we can remove that and we will get all the records in our database.

vinyl/index.js - handler-like functions
1
2
3
4
5
6
7
8
9
10
// ...
const index = {
handler: (request, reply) => {
r.db('music_store')
.table('vinyls')
.run(request.server.app.rConnection)
.then(cursor => reply(cursor.toArray())) // Parse cursor to array
.catch(err => reply({ data: err }).code(500));
}
}

Lets try this out:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// curl -v -H "Content-Type: application/json" -X GET http://0.0.0.0:3000/vinyls
[
{
"artist":"MF Doom",
"genre":"Hip Hop",
"id":"751bea96-fca0-4570-bc58-7916678cda82",
"rating":4.66,
"style":"Conscious",
"title":"Operation: Doomsday",
"tracklist":[
{
"length":2.04,
"number":1,
"title":"The Time We Faced Doom (Skit)"
}
],
"year":1999
}
]

Eveything seems fine, you can see that the response we get as result is an array of vinyls. Hurray!

The album “Operation: Doomsday” is a record by MF Doom and it has 19 tracks. Seems like we just included one when we created the record. Worry not, we can update our vinyl by creating a handler like the one below:

vinyl/index.js - handler-like functions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ...
const update = {
handler: (request, reply) => {
// We will pass rethinkdb auto generated id of the record
// we want to update
const vinylID = request.params.id;
// And acceept the payload
const payload = request.payload || {};
r.db('music_store')
.table('vinyls')
.filter({ id: vinylID }) // Select the record
.update(payload) // Update with the payload
.run(request.server.app.rConnection)
.then(res => reply(res))
.catch(err => reply('err').code(500));
},
validate: {
payload: vinylSchema.update.required().min(1) // At least one field to update
}
};

The validation applied to this handler differs a little from the one in our create action, we have to require at least one field or else the update action wouldn’t reflect any change in our data.

schema.js
1
2
3
4
5
6
7
8
9
10
// ...
const update = Joi.object().keys({
artist: Joi.string().min(1).max(30),
title: Joi.string().min(1).max(30),
genre: Joi.string().min(1).max(30),
style: Joi.string().min(1).max(30),
year: Joi.number().integer(),
rating: Joi.number().min(0).max(5),
tracklist: Joi.array().items(track)
});

Lets not forget to register our route.

index.js - plugin configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'use strict';
const vinyl = require('./vinyl'); // Require vinyl handlers
exports.register = function (server, options, next) { // Plugin signature
// Create a route which will create a vinyl resource in our database
server.route({ method: 'POST', path: '/vinyls', config: vinyl.create });
// Use curly braces so hapi can recognize the params
server.route({ method: 'GET', path: '/vinyls/{id}', config: vinyl.read });
// Create a route which will return all the vinyls in our database
server.route({ method: 'GET', path: '/vinyls', config: vinyl.index });
// Create a route which will edit a record given a id
server.route({ method: 'PATCH',path: '/vinyls/{id}', config: vinyl.update });
next();
};
exports.register.attributes = {
pkg: require('./package')
};

We should use our API to populate MF Doom’s vinyl tracklist.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# curl -v -H "Content-Type: application/json" -X PATCH -d @update.json http://0.0.0.0:3000/vinyls/751bea96-fca0-4570-bc58-7916678cda82
{
"deleted":0,
"errors":0,
"inserted":0,
"replaced":1,
"skipped":0,
"unchanged":0
}
# curl -v -H "Content-Type: application/json" -X GET http://0.0.0.0:3000/vinyls/751bea96-fca0-4570-bc58-7916678cda82
{
"artist":"MF Doom",
"genre":"Hip Hop",
"id":"751bea96-fca0-4570-bc58-7916678cda82",
"rating":4.66,
"style":"Conscious",
"title":"Operation: Doomsday",
"tracklist":[
{
"length":2.04,
"number":1,
"title":"The Time We Faced Doom (Skit)"
},
{
"length":4.58,
"number":2,
"title":"Doomsday"
},
....
],
"year":1999
}

The last thing we need so our API is finished is to include a way to remove a record form the database.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'use strict';
const vinyl = require('./vinyl');
exports.register = function (server, options, next) {
server.route({ method: 'POST', path: '/vinyls', config: vinyl.create });
server.route({ method: 'GET', path: '/vinyls', config: vinyl.index });
server.route({ method: 'GET', path: '/vinyls/{id}', config: vinyl.read });
server.route({ method: 'PATCH', path: '/vinyls/{id}', config: vinyl.update });
server.route({ method: 'DELETE', path: '/vinyls/{id}', config: vinyl.remove });
console.log('Success loading API plugin');
next();
};
exports.register.attributes = {
pkg: require('./package')
};

We need to create on handler that will delete the record, this handler will and should always receive a ID for the vinyl that should be removed from the database.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const remove = {
handler: (request, reply) => {
const vinylID = request.params.id;
r.db('music_store')
.table('vinyls')
.filter({ id: vinylID })
.delete()
.run(request.server.app.rConnection)
.then(res => reply(res))
.catch(err => reply('err').code(500));
},
validate: {
params: { id: vinylSchema.vinylID }
}
}

In this handler we are using the delete method which will allow us to delete one or more documents from a table based on a selection. If we make a call to our API with the correct ID we should get a response like the one below:

1
2
3
4
5
6
7
8
9
# curl -v -H "Content-Type: application/json" -X DELETE http://0.0.0.0:3000/vinyls/751bea96-fca0-4570-bc58-7916678cda82
{
"deleted": 1,
"errors": 0,
"inserted": 0,
"replaced": 0,
"skipped": 0,
"unchanged": 0
}

Lets corroborate that our delete call did work. Lets make a call to fetch all the records within the database.

1
2
// curl -v -H "Content-Type: application/json" -X GET http://0.0.0.0:3000/vinyls
[]

Ok, everything seems fine. We have a basic API, we may continue the customization but for now I think this should allow you to understand how hapi and rethinkdb work. You can find all the code used for this tutorial right here

Resources

A podcast episode about hapijs
Node Black Friday at Walmart
Hapi not under the first ten