To continue on my trend of MongoDB with Node.js material, I thought it would be a good idea to use one of my favorite Node.js frameworks. Previously I had written about using Express.js with Mongoose, but this time I wanted to evaluate the same tasks using Hapi.js.
In this tutorial we’re going to develop a simple RESTful API using Hapi.js, Joi and Mongoose as the backend framework, and MongoDB as the NoSQL database. Rather than just using Hapi.js as a drop in framework replacement, I wanted to improve upon what we had previously seen, by simplifying functions and validating client provided data.
If you haven’t seen my previous tutorial, don’t worry because it is not a requirement. However, the previous tutorial is a valuable read if you’re evaluating Node.js frameworks. What is required is having a MongoDB instance available to you. If you’re unfamiliar with deploying MongoDB, you might want to check out my tutorial titled, Getting Started with MongoDB as a Docker Container Deployment or my tutorial titled, Developing a RESTful API with Node.js and MongoDB Atlas.
With MongoDB available to us, we can create a fresh Hapi.js project with all the appropriate dependencies. Create a new project directory and execute the following commands:
npm init -y
npm install hapi joi mongoose --save
The above commands will create a new package.json file and install the Hapi.js framework, the Joi validation framework, and the Mongoose object document modeler (ODM).
We’re going to add all of our application code into a single project file. Create an app.js file and include the following boilerplate JavaScript code:
const Hapi = require("hapi");
const Mongoose = require("mongoose");
const Joi = require("joi");
const server = new Hapi.Server({ "host": "localhost", "port": 3000 });
server.route({
method: "POST",
path: "/person",
options: {
validate: {}
},
handler: async (request, h) => {}
});
server.route({
method: "GET",
path: "/people",
handler: async (request, h) => {}
});
server.route({
method: "GET",
path: "/person/{id}",
handler: async (request, h) => {}
});
server.route({
method: "PUT",
path: "/person/{id}",
options: {
validate: {}
},
handler: async (request, h) => {}
});
server.route({
method: "DELETE",
path: "/person/{id}",
handler: async (request, h) => {}
});
server.start();
We’ve added quite a bit of code to our app.js file, but it isn’t really anything beyond the Hapi.js getting started material found on the framework’s website. Essentially we’ve imported the dependencies that we downloaded, defined our servers settings, defined the routes which are also referred to as endpoints, and started the server.
You’ll notice that not all of our routes are the same. We’re developing a create, retrieve, update, and delete (CRUD) based REST API with validation on some of our endpoints. In particular we’ll be adding validation logic to the endpoints that save data to the database, not retrieve or remove.
With our boilerplate code in place, lets take a look at configuring MongoDB and adding our endpoint logic.
Remember, I’m assuming you already have access to an instance of MongoDB. At the top of your app.js file, after you defined your server configuration, we need to connect to MongoDB. Inculde the following line to establish a connection:
Mongoose.connect("mongodb://localhost/thepolyglotdeveloper");
You’ll need to swap out my connection string information with your connection string information. When working with Mongoose, we need to have a model defined for each of our collections. Since this is a simple example, we’ll only have one model and it looks like the following:
const PersonModel = Mongoose.model("person", {
firstname: String,
lastname: String
});
Each of our documents will contain a firstname
and a lastname
, but neither of the two fields are required. These documents will be saved to the people
collection which is the plural form of our ODM model.
At this point in time MongoDB is ready to be used.
It is now time to start developing our API endpoints, so starting with the creation endpoint, we might have something like this:
server.route({
method: "POST",
path: "/person",
options: {
validate: {}
},
handler: async (request, h) => {
try {
var person = new PersonModel(request.payload);
var result = await person.save();
return h.response(result);
} catch (error) {
return h.response(error).code(500);
}
}
});
We’ve skipped the validation logic for now, but inside our handler
we are taking the payload data sent with the request from the client and creating a new instance of our model. Using our model we can save to the database and return the response back to the client. Mongoose will do basic validation against the payload data based on the schema, but we can do better. This is where Joi comes in with Hapi.js.
Let’s look at the validate
object in our route:
validate: {
payload: {
firstname: Joi.string().required(),
lastname: Joi.string().required()
},
failAction: (request, h, error) => {
return error.isJoi ? h.response(error.details[0]).takeover() : h.response(error).takeover();
}
}
In the validate
object, we are choosing to validate our payload
. We also have the option to validate the params
as well as the query
of a request, but neither are necessary here. While we could do some very complex validation, we’re just validating that both properties are present. Rather than returning a vague error to the user if either are missing, we’re returning the exact error using the failAction
which is optional.
Now let’s take a look at retrieving the data that had been created. In a typical CRUD scenario, we can retrieve all data or a particular piece of data. We’re going to accommodate both scenarios.
server.route({
method: "GET",
path: "/people",
handler: async (request, h) => {
try {
var person = await PersonModel.find().exec();
return h.response(person);
} catch (error) {
return h.response(error).code(500);
}
}
});
The above route will execute the find
function in Mongoose with no query parameters. This means that there is no criteria to search for which results in all data being returned from the collection. Similarly, we could return a particular piece of data.
If we wanted to return a particular piece of data we could either provide parameters in the find
function, or use the following:
server.route({
method: "GET",
path: "/person/{id}",
handler: async (request, h) => {
try {
var person = await PersonModel.findById(request.params.id).exec();
return h.response(person);
} catch (error) {
return h.response(error).code(500);
}
}
});
In the above endpoint we are accepting an id
route parameter and are using the findById
function. The data returned from the interaction is returned to the client facing application.
With the create and retrieve endpoints out of the way, we can bring this tutorial to an end with the update and delete endpoints. Starting with the update endpoint, we might have something like this:
server.route({
method: "PUT",
path: "/person/{id}",
options: {
validate: {
payload: {
firstname: Joi.string().optional(),
lastname: Joi.string().optional()
},
failAction: (request, h, error) => {
return error.isJoi ? h.response(error.details[0]).takeover() : h.response(error).takeover();
}
}
},
handler: async (request, h) => {
try {
var result = await PersonModel.findByIdAndUpdate(request.params.id, request.payload, { new: true });
return h.response(result);
} catch (error) {
return h.response(error).code(500);
}
}
});
Just like with the create endpoint we are validating our data. However, our validation is a little different than the previous endpoint. Instead of making our properties required, we are just saying they are optional. When we do this, any property that shows up that isn’t in our validator, we will be throwing an error. So for example, if I wanted to include a middle name, it would fail.
Inside the handler
function, we can use a shortcut function called findByIdAndUpdate
which will allow us to find a document to update and update it in the same operation rather than doing it in two steps. We are including the new
setting so that the latest document information can be returned back to the client.
The delete endpoint will be a lot simpler:
server.route({
method: "DELETE",
path: "/person/{id}",
handler: async (request, h) => {
try {
var result = await PersonModel.findByIdAndDelete(request.params.id);
return h.response(result);
} catch (error) {
return h.response(error).code(500);
}
}
});
Using an id
parameter passed from the client, we can execute the findByIdAndDelete
function which will find a document by the id, then remove it in one swoop rather than using two steps.
You should be able to play around with the API as of now. You might want to use a tool like Postman before trying to use with a frontend framework like Angular or Vue.js.
You just saw how to create a REST API with Hapi.js and MongoDB. While we used Mongoose and Joi to help us with the job, there are other alternatives that can be used as well.
While Hapi.js is awesome, in my opinion, if you’d like to check out how to accomplish the same using a popular framework like Express.js, you might want to check out my tutorial titled, Building a REST API with MongoDB, Mongoose, and Node.js. I’ve also written a version of this tutorial using Couchbase as the NoSQL database. That version of the tutorial can be found here.
As a side note, I’ve also released an eBook and video course titled, Web Services for the JavaScript Developer which goes into REST and GraphQL API development with finer detail.
A video version of this tutorial can be seen below.