Build a RESTful API With HapiJS and MongoDB
Rate this tutorial
While JAMStack, static site generators, and serverless functions continue to be all the rage in 2020, traditional frameworks like Express.js and Hapi.js remain the go-to solution for many developers. These frameworks are battle-tested, reliable, and scalable, so while they may not be the hottest tech around, you can count on them to get the job done.
In this post, we're going to build a web application with Hapi.js and MongoDB. If you would like to follow along with this tutorial, you can get the code from this GitHub repo. Also, be sure to sign up for a free MongoDB Atlas account to make sure you can implement all of the code in this tutorial.
For this tutorial you'll need:
You can download Node.js here, and it will come with the latest version of npm. For MongoDB, use MongoDB Atlas for free. While you can use a local MongoDB install, you will not be able to implement some of the functionality that relies on MongoDB Atlas Search, so I encourage you to give Atlas a try. All other required items will be covered in the article.
Hapi.js or simply Hapi is a Node.js framework for "building powerful, scalable applications, with minimal overhead and full out-of-the-box functionality". Originally developed for Walmart's e-commerce platform, the framework has been adopted by many enterprises. In my personal experience, I've worked with numerous companies who heavily relied on Hapi.js for their most critical infrastructure ranging from RESTful APIs to traditional web applications.
For this tutorial, I'll assume that you are already familiar with JavaScript and Node.js. If not, I would suggest checking out the Nodejs.dev website which offers an excellent introduction to Node.js and will get you up and running in no time.
The app that we're going to build today is going to expose a series of RESTful endpoints for working with a movies collection. The dataset we'll be relying on can be accessed by loading sample datasets into your MongoDB Atlas cluster. In your MongoDB dashboard, navigate to the Clusters tab. Click on the ellipses (...) button on the cluster you wish to use and select the Load Sample Dataset option. Within a few minutes, you'll have a series of new databases created and the one we'll work with is called
sample_mflix
.We will not build a UI as part of this tutorial, instead, we'll focus on getting the most out of our Hapi.js backend.
Like with any Node.js application, we'll start off our project by installing some packages from the node package manager or npm. Navigate to a directory where you would like to store your application and execute the following commands:
1 npm init 2 3 npm install @hapi/hapi --save
Executing
npm init
will create a package.json
file where we can store our dependencies. When you run this command you'll be asked a series of questions that will determine how the file gets populated. It's ok to leave all the defaults as is. The npm install @hapi/hapi --save
command will pull down the latest
version of the Hapi.js framework and save a reference to this version in the newly created package.json
file. When you've completed this step, create an index.js
file in the root directory and open it up.Much like Express, Hapi.js is not a very prescriptive framework. What I mean by this is that we as the developer have the total flexibility to decide how we want our directory structure to look. We could have our entire application in a single file, or break it up into hundreds of components, Hapi.js does not care. To make sure our install was successful, let's write a simple app to display a message in our browser. The code will look like this:
1 const Hapi = require('@hapi/hapi'); 2 3 const server = Hapi.server({ 4 port: 3000, 5 host: 'localhost' 6 }); 7 8 server.route({ 9 method: 'GET', 10 path: '/', 11 handler: (req, h) => { 12 13 return 'Hello from HapiJS!'; 14 } 15 }); 16 17 server.start(); 18 console.log('Server running on %s', server.info.uri);
Let's go through the code above to understand what is going on here. At the start of our program, we are requiring the hapi package which imports all of the Hapi.js API's and makes them available in our app. We then use the
Hapi.server
method to create an instance of a Hapi server and pass in our parameters. Now that we have a server, we can add routes to it, and that's what we do in the subsequent section. We are defining a single route for our homepage, saying that this route can only be accessed via a GET request, and the handler function is just going to return the message "Hello from HapiJS!". Finally, we start the Hapi.js server and display a message to the console that tells us the server is running. To start the server, execute the following command in your terminal window:1 node index.js
If we navigate to
localhost:3000
in our web browser of choice, our result will look as follows:If you see the message above in your browser, then you are ready to proceed to the next section. If you run into any issues, I would first ensure that you have the latest version of Node.js installed and that you have a
@hapi/hapi
folder inside of your node_modules
directory.Now that we have the basics down, let's go ahead and create the actual routes for our API. The API routes that we'll need to create are as follows:
- Get all movies
- Get a single movie
- Insert a movie
- Update a movie
- Delete a movie
- Search for a movie
For the most part, we just have traditional CRUD operations that you are likely familiar with. But, our final route is a bit more advanced. This route is going to implement search functionality and allow us to highlight some of the more advanced features of both Hapi.js and MongoDB. Let's update our
index.js
file with the routes we need.1 const Hapi = require('@hapi/hapi'); 2 3 const server = Hapi.server({ 4 port: 3000, 5 host: 'localhost' 6 }); 7 8 // Get all movies 9 server.route({ 10 method: 'GET', 11 path: '/movies', 12 handler: (req, h) => { 13 14 return 'List all the movies'; 15 } 16 }); 17 18 // Add a new movie to the database 19 server.route({ 20 method: 'POST', 21 path: '/movies', 22 handler: (req, h) => { 23 24 return 'Add new movie'; 25 } 26 }); 27 28 // Get a single movie 29 server.route({ 30 method: 'GET', 31 path: '/movies/{id}', 32 handler: (req, h) => { 33 34 return 'Return a single movie'; 35 } 36 }); 37 38 // Update the details of a movie 39 server.route({ 40 method: 'PUT', 41 path: '/movies/{id}', 42 handler: (req, h) => { 43 44 return 'Update a single movie'; 45 } 46 }); 47 48 // Delete a movie from the database 49 server.route({ 50 method: 'DELETE', 51 path: '/movies/{id}', 52 handler: (req, h) => { 53 54 return 'Delete a single movie'; 55 } 56 }); 57 58 // Search for a movie 59 server.route({ 60 method: 'GET', 61 path: '/search', 62 handler: (req, h) => { 63 64 return 'Return search results for the specified term'; 65 } 66 }); 67 68 server.start(); 69 console.log('Server running on %s', server.info.uri);
We have created our routes, but currently, all they do is return a string saying what the route is meant to do. That's no good. Next, we'll connect our Hapi.js app to our MongoDB database so that we can return actual data. We'll use the MongoDB Node.js Driver to accomplish this.
If you are interested in learning more about the MongoDB Node.js Driver through in-depth training, check out the MongoDB Node.js Developer Path course on MongoDB University. It's free and will teach you all about reading and writing data with the driver, using the aggregation framework, and much more.
Connecting a Hapi.js backend to a MongoDB database can be done in multiple ways. We could use the traditional method of just bringing in the MongoDB Node.js Driver via npm, we could use an ODM library like Mongoose, but I believe there is a better way to do it. The way we're going to connect to our MongoDB database in our Atlas cluster is using a Hapi.js plugin.
Hapi.js has many excellent plugins for all your development needs. Whether that need is authentication, logging, localization, or in our case data access, the Hapi.js plugins page provides many options. The plugin we're going to use is called
hapi-mongodb
. Let's install this package by running:1 npm install hapi-mongodb --save
With the package installed, let's go back to our
index.js
file and
configure the plugin. The process for this relies on the register()
method provided in the Hapi API. We'll register our plugin like so:1 server.register({ 2 plugin: require('hapi-mongodb'), 3 options: { 4 uri: 'mongodb+srv://{YOUR-USERNAME}:{YOUR-PASSWORD}@main.zxsxp.mongodb.net/sample_mflix?retryWrites=true&w=majority', 5 settings : { 6 useUnifiedTopology: true 7 }, 8 decorate: true 9 } 10 });
We would want to register this plugin before our routes. For the options object, we are passing our MongoDB Atlas service URI as well as the name of our database, which in this case will be
sample_mflix
. If you're working with a different database, make sure to update it accordingly. We'll also want to make one more adjustment to our entire code base before moving on. If we try to run our Hapi.js application now, we'll get an error saying that we cannot start our server before plugins are finished registering. The register method will take some time to run and we'll have to wait on it. Rather than deal with this in a synchronous fashion, we'll wrap an async function around our server instantiation. This will make our code much cleaner and easier to reason about. The final result will look like this:1 const Hapi = require('@hapi/hapi'); 2 3 const init = async () => { 4 5 const server = Hapi.server({ 6 port: 3000, 7 host: 'localhost' 8 }); 9 10 await server.register({ 11 plugin: require('hapi-mongodb'), 12 options: { 13 url: 'mongodb+srv://{YOUR-USERNAME}:{YOUR-PASSWORD}@main.zxsxp.mongodb.net/sample_mflix?retryWrites=true&w=majority', 14 settings: { 15 useUnifiedTopology: true 16 }, 17 decorate: true 18 } 19 }); 20 21 // Get all movies 22 server.route({ 23 method: 'GET', 24 path: '/movies', 25 handler: (req, h) => { 26 27 return 'List all the movies'; 28 } 29 }); 30 31 // Add a new movie to the database 32 server.route({ 33 method: 'POST', 34 path: '/movies', 35 handler: (req, h) => { 36 37 return 'Add new movie'; 38 } 39 }); 40 41 // Get a single movie 42 server.route({ 43 method: 'GET', 44 path: '/movies/{id}', 45 handler: (req, h) => { 46 47 return 'Return a single movie'; 48 } 49 }); 50 51 // Update the details of a movie 52 server.route({ 53 method: 'PUT', 54 path: '/movies/{id}', 55 handler: (req, h) => { 56 57 return 'Update a single movie'; 58 } 59 }); 60 61 // Delete a movie from the database 62 server.route({ 63 method: 'DELETE', 64 path: '/movies/{id}', 65 handler: (req, h) => { 66 67 return 'Delete a single movie'; 68 } 69 }); 70 71 // Search for a movie 72 server.route({ 73 method: 'GET', 74 path: '/search', 75 handler: (req, h) => { 76 77 return 'Return search results for the specified term'; 78 } 79 }); 80 81 await server.start(); 82 console.log('Server running on %s', server.info.uri); 83 } 84 85 init();
Now we should be able to restart our server and it will register the plugin properly and work as intended. To ensure that our connection to the database does work, let's run a sample query to return just a single movie when we hit the
/movies
route. We'll do this with a findOne()
operation. The hapi-mongodb
plugin is just a wrapper for the official MongoDB Node.js driver so all the methods work exactly the same. Check out the official docs for details on all available methods. Let's use the findOne()
method to return a single movie from the database.1 // Get all movies 2 server.route({ 3 method: 'GET', 4 path: '/movies', 5 handler: async (req, h) => { 6 7 const movie = await req.mongo.db.collection('movies').findOne({}) 8 9 return movie; 10 } 11 });
We'll rely on the async/await pattern in our handler functions as well to keep our code clean and concise. Notice how our MongoDB database is now accessible through the
req
or request object. We didn't have to pass in an instance of our database, the plugin handled all of that for us, all we have to do was decide what our call to the database was going to be. If we restart our server and navigate to localhost:3000/movies
in our browser we should see the following response:If you do get the JSON response, it means your connection to the database is good and your plugin has been correctly registered with the Hapi.js application. If you see any sort of error, look at the above instructions carefully. Next, we'll implement our actual database calls to our routes.
We have six API routes to implement. We'll tackle each one and introduce new concepts for both Hapi.js and MongoDB. We'll start with the route that gets us all the movies.
This route will retrieve a list of movies. Since our dataset contains thousands of movies, we would not want to return all of them at once as this would likely cause the user's browser to crash, so we'll limit the result set to 20 items at a time. We'll allow the user to pass an optional query parameter that will give them the next 20 results in the set. My implementation is below.
1 // Get all movies 2 server.route({ 3 method: 'GET', 4 path: '/movies', 5 handler: async (req, h) => { 6 7 const offset = Number(req.query.offset) || 0; 8 9 const movies = await req.mongo.db.collection('movies').find({}).sort({metacritic:-1}).skip(offset).limit(20).toArray(); 10 11 return movies; 12 } 13 });
In our implementation, the first thing we do is sort our collection to ensure we get a consistent order of documents. In our case, we're sorting by the
metacritic
score in descending order, meaning we'll get the highest rated movies first. Next, we check to see if there is an offset
query parameter. If there is one, we'll take its value and convert it into an integer, otherwise, we'll set the offset value to 0. Next, when we make a call to our MongoDB database, we are going to use that offset
value in the skip()
method which will tell MongoDB how many documents to skip. Finally, we'll use the limit()
method to limit our results to 20 records and the toArray()
method to turn the cursor we get back into an object.Try it out. Restart your Hapi.js server and navigate to
localhost:3000/movies
. Try passing an offset query parameter to see how the results change. For example try localhost:3000/movies?offset=500
. Note that if you pass a non-integer value, you'll likely get an error. We aren't doing any sort of error handling in this tutorial but in a real-world application, you should handle all errors accordingly. Next, let's implement the method to return a single movie.This route will return the data on just a single movie. For this method, we'll also play around with projection, which will allow us to pick and choose which fields we get back from MongoDB. Here is my implementation:
1 // Get a single movie 2 server.route({ 3 method: 'GET', 4 path: '/movies/{id}', 5 handler: async (req, h) => { 6 const id = req.params.id 7 const ObjectID = req.mongo.ObjectID; 8 9 const movie = await req.mongo.db.collection('movies').findOne({_id: new ObjectID(id)},{projection:{title:1,plot:1,cast:1,year:1, released:1}}); 10 11 return movie; 12 } 13 });
In this implementation, we're using the
req.params
object to get the dynamic value from our route. We're also making use of the req.mongo.ObjectID
method which will allow us to transform the string id into an ObjectID that we use as our unique identifier in the MongoDB database. We'll have to convert our string to an ObjectID otherwise our findOne()
method would not work as our _id
field is not stored as a string. We're also using a projection to return only the title
, plot
, cast
, year
, and released
fields. The result is below.A quick tip on projection. In the above example, we used the
{ fieldName: 1 }
format, which told MongoDB to return only this specific field. If instead we only wanted to omit a few fields, we could have used the inverse { fieldName: 0}
format instead. This would send us all fields, except the ones named and given a value of zero in the projection option. Note that you can't mix and match the 1 and 0 formats, you have to pick one. The only exception is the _id
field, where if you don't want it you can pass {_id:0}
.The next route we'll implement will be our insert operation and will allow us to add a document to our collection. The implementation looks like this:
1 // Add a new movie to the database 2 server.route({ 3 method: 'POST', 4 path: '/movies', 5 handler: async (req, h) => { 6 7 const payload = req.payload 8 9 const status = await req.mongo.db.collection('movies').insertOne(payload); 10 11 return status; 12 } 13 }); 14 15 The payload that we are going to submit to this endpoint will look like this: 16 17 .. code-block:: javascript 18 19 { 20 "title": "Avengers: Endgame", 21 "plot": "The avengers save the day", 22 "cast" : ["Robert Downey Jr.", "Chris Evans", "Scarlett Johansson", "Samuel L. Jackson"], 23 "year": 2019 24 }
In our implementation we're again using the
req
object but this time we're using the payload
sub-object to get the data that is sent to the endpoint. To test that our endpoint works, we'll use Postman to send the request. Our response will give us a lot of info on what happened with the operation so for educational purposes we'll just return the entire document. In a real-world application, you would just send back a {message: "ok"}
or similar statement. If we look at the response we'll find a field titled insertedCount: 1
and this will tell us that our document was successfully inserted.In this route, we added the functionality to insert a brand new document, in the next route, we'll update an existing one.
Updating a movie works much the same way adding a new movie does. I do want to introduce a new concept in Hapi.js here though and that is the concept of validation. Hapi.js can help us easily validate data before our handler function is called. To do this, we'll import a package that is maintained by the Hapi.js team called Joi. To work with Joi, we'll first need to install the package and include it in our
index.js
file.1 npm install @hapi/joi --save 2 npm install joi-objectid --save
Next, let's take a look at our implementation of the update route and then I'll explain how it all ties together.
1 // Add this below the @hapi/hapi require statement 2 const Joi = require('@hapi/joi'); 3 Joi.objectId = require('joi-objectid')(Joi) 4 5 // Update the details of a movie 6 server.route({ 7 method: 'PUT', 8 path: '/movies/{id}', 9 options: { 10 validate: { 11 params: Joi.object({ 12 id: Joi.objectId() 13 }) 14 } 15 }, 16 handler: async (req, h) => { 17 const id = req.params.id 18 const ObjectID = req.mongo.ObjectID; 19 20 const payload = req.payload 21 22 const status = await req.mongo.db.collection('movies').updateOne({_id: ObjectID(id)}, {$set: payload}); 23 24 return status; 25 26 } 27 });
With this route we are really starting to show the strength of Hapi.js. In this implementation, we added an
options
object and passed in a validate
object. From here, we validated that the id
parameter matches what we'd expect an ObjectID string to look like. If it did not, our handler function would never be called, instead, the request would short-circuit and we'd get an appropriate error message. Joi can be used to validate not only the defined parameters but also query parameters, payload, and even headers. We barely scratched the surface.The rest of the implementation had us executing an
updateOne()
method which updated an existing object with the new data. Again, we're returning the entire status object here for educational purposes, but in a real-world application, you wouldn't want to send that raw data.Deleting a movie will simply remove the record from our collection. There isn't a whole lot of new functionality to showcase here, so let's get right into the implementation.
1 // Update the details of a movie 2 server.route({ 3 method: 'PUT', 4 path: '/movies/{id}', 5 options: { 6 validate: { 7 params: Joi.object({ 8 id: Joi.objectId() 9 }) 10 } 11 }, 12 handler: async (req, h) => { 13 const id = req.params.id 14 const ObjectID = req.mongo.ObjectID; 15 16 const payload = req.payload 17 18 const status = await req.mongo.db.collection('movies').deleteOne({_id: ObjectID(id)}); 19 20 return status; 21 22 } 23 });
In our delete route implementation, we are going to continue to use the Joi library to validate that the parameter to delete is an actual ObjectId. To remove a document from our collection, we'll use the
deleteOne()
method and pass in the ObjectId to delete.Implementing this route concludes our discussion on the basic CRUD operations. To close out this tutorial, we'll implement one final route that will allow us to search our movie database.
To conclude our routes, we'll add the ability for a user to search for a movie. To do this we'll rely on a MongoDB Atlas feature called Atlas Search. Before we can implement this functionality on our backend, we'll first need to enable Atlas Search and create an index within our MongoDB Atlas dashboard. Navigate to your dashboard, and locate the
sample_mflix
database. Select the movies
collection and click on the Search (Beta) tab.Click the Create Search Index button, and for this tutorial, we can leave the field mappings to their default dynamic state, so just hit the Create Index button. While our index is built, we can go ahead and implement our backend functionality. The implementation will look like this:
1 // Search for a movie 2 server.route({ 3 method: 'GET', 4 path: '/search', 5 handler: async(req, h) => { 6 const query = req.query.term; 7 8 const results = await req.mongo.db.collection("movies").aggregate([ 9 { 10 $searchBeta: { 11 "search": { 12 "query": query, 13 "path":"title" 14 } 15 } 16 }, 17 { 18 $project : {title:1, plot: 1} 19 }, 20 { 21 $limit: 10 22 } 23 ]).toArray() 24 25 return results; 26 } 27 });
Our
search
route has us using the extremely powerful MongoDB aggregation pipeline. In the first stage of the pipeline, we are using the $searchBeta
attribute and passing along our search term. In the next stage of the pipeline, we run a $project
to only return specific fields, in our case the title
and plot
of the movie. Finally, we limit our search results to ten items and convert the cursor to an array and send it to the browser. Let's try to run a search query against our movies collection. Try search for localhost:3000/search?term=Star+Wars
. Your results will look like this:MongoDB Atlas Search is very powerful and provides all the tools to add superb search functionality for your data without relying on external APIs. Check out the documentation to learn more about how to best leverage it in your applications.
In this tutorial, I showed you how to create a RESTful API with Hapi.js and MongoDB. We scratched the surface of the capabilities of both, but I hope it was a good introduction and gives you an idea of what's possible. Hapi.js has an extensive plug-in system that will allow you to bring almost any functionality to your backend with just a few lines of code. Integrating MongoDB into Hapi.js using the
hapi-mongo
plugin allows you to focus on building features and functionality rather than figuring out best practices and how to glue everything together. Speaking of glue, Hapi.js has a package called glue that makes it easy to break your server up into multiple components, we didn't need to do that in our tutorial, but it's a great next step for you to explore.If you'd like to get the code for this tutorial, you can find it here. If you want to give Atlas Search a try, sign up for MongoDB Atlas for free.
Happy, er.. Hapi coding!