Learn, develop, and innovate from anywhere. Join us for our MongoDB .Live series.
HomeLearnHow-to

Build a RESTful API with HapiJS and MongoDB

Published: Apr 14, 2020

  • MongoDB
  • Atlas
  • JavaScript
  • ...

By Ado Kukic

Share

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.

#Prerequisites

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.

#What is Hapi.js

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.

HapiJS Homepage

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.

#What We're Building: RESTful Movie Database

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.

Movies Collection

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.

#Setting up a Hapi.js Application

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:

1npm init
2
3npm 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:

1const Hapi = require('@hapi/hapi');
2
3const server = Hapi.server({
4 port: 3000,
5 host: 'localhost'
6});
7
8server.route({
9 method: 'GET',
10 path: '/',
11 handler: (req, h) => {
12
13 return 'Hello from HapiJS!';
14 }
15});
16
17server.start();
18console.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:

1node index.js

If we navigate to localhost:3000 in our web browser of choice, our result will look as follows:

HapiJS Hello World

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.

#Building a RESTful API with Hapi.js

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.

1const Hapi = require('@hapi/hapi');
2
3const server = Hapi.server({
4 port: 3000,
5 host: 'localhost'
6});
7
8// Get all movies
9server.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
19server.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
29server.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
39server.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
49server.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
59server.route({
60 method: 'GET',
61 path: '/search',
62 handler: (req, h) => {
63
64 return 'Return search results for the specified term';
65 }
66});
67
68server.start();
69console.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 for JavaScript Developers 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 Our Hapi.js App to MongoDB

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:

1npm 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:

1server.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:

1const Hapi = require('@hapi/hapi');
2
3const 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
85init();

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
2server.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:

JSON Data for a Single Movie

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.

#Implementing the RESTful 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.

#Get All 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
2server.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.

#Get 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
2server.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.

MongoDB Driver Projection

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}.

#Add A Movie

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
15The 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.

Postman Request

In this route, we added the functionality to insert a brand new document, in the next route, we'll update an existing one.

#Update A Movie

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.

1npm install @hapi/joi --save
2npm 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
2const Joi = require('@hapi/joi');
3Joi.objectId = require('joi-objectid')(Joi)
4
5// Update the details of a movie
6server.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.

Postman Put Request

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.

#Delete A Movie

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
2server.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.

Postman Delete Request

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.

#Search For A Movie

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.

Atlas Search

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
2server.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:

Atlas Search Results

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.

#Putting It All Together

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 and use code ADO200 to get a $200 credit towards your account.

Happy, er.. Hapi coding!

MongoDB Icon
  • Developer Hub
  • Documentation
  • University
  • Community Forums

© MongoDB, Inc.