Fastify and MongoDB on Heroku

April 18, 2021

Tested with Ubuntu Desktop 18.04.5 LTS and Node.js v14.16.0 LTS

In this post we will use Fastify and MongoDB to create a simple REST API to manage todos that will be deployed on Heroku. The purpose is learning by doing.

The source code is available on GitHub.

Create a MongoDB Atlas account and setup a cluster

We will use a fully managed instance of MongoDB relying on Atlas. We need to create a MongoDB account and then set up a cluster by using this guide.

Once Atlas has finished the provisioning, we have to click on CONNECT and then Connect your application. The next steps are

  1. copy the provided link

  2. on our computers, let's create a folder called todos-api (it is the folder where we will develop our solution)

  3. create a file .env

  4. type MONGODB_URI="" and paste between the quotes the link

    # .env MONGODB_URI="mongodb+srv://<username>:<password>@<cluster-name>.mongodb.net/<db-name>?retryWrites=true&w=majority"
  5. replace <username>, <password> and <db-name> with the username, password and database name we chose. If we use special characters in our password we have to be sure they are encoded.

Create an Heroku account

Go to the signup page and follow the steps.

Solution initialization

During the setup of MongoDB we created the folder todos-api and put inside it the .env file. Let's open the folder with VS Code and open a terminal.

We need to initialize the solution running

$ npm init

and when init will ask for the entry point we insert server.js.

Next we need to install the dependencies

$ npm install dotenv fastify mongoose && npm install mongodb-memory-server tap --save-dev

mongodb-memory-server and tap are the two libraries to write tests.

Open package.json and replace

// package.json "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }

with

// package.json "scripts": { "test": "clear; tap --reporter=spec --watch" }

Let's create a folder test where we keep our test files.

Git initialization

  1. Click the Source Control tab in the VS Code side bar

  2. click Initialize Repository button and follow the steps

  3. add a .gitignore file with this content

    # .gitignore node_modules .nyc_output .env
  4. let's do our first commit

Now we are ready to code our solution.

Connecting and querying MongoDB: database.js

The first brick of our solution is the module to connect and query MongoDB. Let's create the file database.js.

We will rely on mongoose to do all the operations.

We have to create the Schema for our data collection and the Model to manipulate the documents of our collection.

// database.js const mongoose = require("mongoose"); const { Schema } = mongoose; const todoSchema = new Schema( { isCompleted: { default: false, type: Boolean, }, text: { required: true, type: String, }, }, { timestamps: true } ); const Todo = mongoose.model("Todo", todoSchema);

The schema defines a todo like a document that has a field text (always required) and a field isCompleted that will represent the status of our todos (completed or not).

The timestamps option decorates each document with two additional fields: createdAt and updatedAt both of type Date.

After defining our schema, we will use it to instantiate our model called Todo.

Now we have to create the main function that implements the logic of the database module.

// database.js const database = (mongoUri) => { mongoose.connect(mongoUri, { useFindAndModify: false, useNewUrlParser: true, useUnifiedTopology: true, }); return { close: () => { mongoose.connection.close(); }, create: async (params) => { const todo = new Todo({ text: params.text, }); return todo.save(); }, get: async (params) => { let queryParams = {}; let sortParams = {}; if (params != null) { // get by id if ("id" in params && params.id != null) { return Todo.findById(params.id).then( (response) => { return response; }, (error) => { return null; } ); } // all others get for (var key in params) { switch (key) { case "isCompleted": { queryParams.isCompleted = params[key]; break; } case "sortBy": { const paramsSortBy = params[key]; sortParams[ paramsSortBy.property ] = paramsSortBy.isDescending ? -1 : 1; break; } } } } return Todo.find(queryParams).sort(sortParams); }, remove: async (id) => { return Todo.findOneAndDelete({ _id: id }); }, update: async (todo) => { return Todo.findOneAndUpdate({ _id: todo._id }, todo, { new: true, }); }, }; }; module.exports = database;

While writing the code above, I created the tests using mongodb-memory-server in order to generate a mongoUri that I passed to the database function. All queries are executed in memory without the need to connect to the MongoDB cluster.

Let's discuss the code.

First, we use mongoose to connect to the database. Keep in mind that we don't have to wait until the connection is established to use the model.

The close() function terminates the connection to the database. This is particularly useful while writing tests. To execute a proper tear down of resources and avoid timeout errors we call it in our test suites.

The create(params) function creates a Todo. As we defined in our schema the text property is mandatory so it is the only parameter we need.

The get(params) function allows querying the database according to constraints defined as parameters.
If the id property is present, all other constraints are ignored because the caller needs a specific todo.
Otherwise, the parameters are used to prepare the query. Our API supports a filter to get todos that are completed or not through isCompleted boolean property. In addition, we can specify a sort defining the property and the direction isDescending (false for ascending, true for descending).

The remove(id) function takes the id of a todo and removes it from the database.

The update(todo) function takes a modified todo and saves it into the database. The third parameter of findOneAndUpdate needs to have as returned document the updated one. By default findOneAndUpdate returns the document as it was before the update was applied.

server.js: the entry point of our solution

Time to create the main module of our solution (the specified in package.json). Let's create server.js and add this code

// server.js require("dotenv").config(); const app = require("./app"); const database = require("./database"); const db = database(process.env.MONGODB_URI); const server = app({ db: db, logger: { level: "info", }, }); const ADDRESS = "0.0.0.0"; const PORT = process.env.PORT || 3000; server.listen(PORT, ADDRESS, (err, address) => { if (err) { console.log(err); process.exit(1); } });

In the first line we require the dotenv module that reads the .env file and loads variables into process.env.

Then we require the app (we did not create yet) and database modules.

On line 8 we instantiate the database passing the MongoDB connection string stored in the .env file and available within process.env thanks to dotenv.

On line 9 we instantiate the application passing the database instance and a basic configuration for Fastfy logger.

Lines 16 and 17 are very important. In order to be sure that once we deploy on Heroku (or any other cloud platform) we listen to the proper address and port, we will set 0.0.0.0 as address and process.env.PORT as port, so we get the one assigned by the cloud environment. While developing on our local machine, port is not defined and we set 3000 as default.

On line 19 we set our app to listen to requests.

app.js: the core of our solution

Let's create the file app.js where we will implement the routes and logic of the Todos REST API.

// app.js const fastify = require("fastify"); const queryStringParser = require("./queryStringParser"); function build(opts = {}) { const app = fastify(opts); const db = opts.db; // ... routes implementation return app; } module.exports = build;

On lines 3 and 4 we require the modules we need. queryStringParser is a module that validates and parses query string related to Todos REST API. It is small and has a really easy code. You can take a look at the source on GitHub.

The build function will contain the routes. On line 6 we get the reference to the db (database) we passed as an option in server.js.

Route /

This is a service route. It is useful when we want to be sure our server is up and reachable.

// app.js app.get("/", async (request, reply) => { return reply.send({ message: "Todos REST API is running" }); });

Route /todos

This is the real entry point of our API. This route supports

  1. get to retrieve all todos. In addition, it offers the possibility to filter and sort todos.

    // app.js app.get("/todos", async (request, reply) => { const params = queryStringParser(request.query); const todos = await db.get(params); return reply.send(todos); });

    This endpoint supports query strings like

    Query Return
    /todos?sort-by=createdAt.asc Todos sorted by the oldest to the newest
    /todos?sort-by=createdAt.desc Todos sorted by the newest to the oldest
    /todos?is-completed=true Todos completed
    /todos?is-completed=false Todos still open

    It is possible to sort even for updatedAt and of course to combine query strings e.g. /todos?is-completed=true&sort-by=createdAt.asc.

  2. post to create a new todo.

    // app.js app.post("/todos", async (request, reply) => { const todo = request.body; const saved = await db.create(todo); return reply.status(201).send(saved); });

    The JSON payload for the request is

    { "text": "My first todo" }

Route /todos/:id

This route supports HTTP verbs get (retrieves a todo by id), put (updates a todo) and delete (removes a todo).

// app.js app.get("/todos/:id", async (request, reply) => { const todoId = request.params.id; const todo = await db.get({ id: todoId }); if (todo == null) { return reply .status(404) .send({ message: `Todo with id ${todoId} not found` }); } return reply.send(todo); }); app.put("/todos/:id", async (request, reply) => { const todo = request.body; const updated = await db.update(todo); if (updated == null) { return reply .status(404) .send({ message: `Todo with id ${todo._id} not found` }); } return reply.send(updated); }); app.delete("/todos/:id", async (request, reply) => { const todoId = request.params.id; const removed = await db.remove(todoId); return reply.status(204).send(); });

On line 8 and 22 we implemented a control to check if the required todo exists. If it does not exist, we reply with a 404 HTTP status.

Deploying on Heroku

Our solution is ready and it is time to deploy it on Heroku. We will take inspiration from the Getting Started on Heroku with Node.js guide.

  1. Let's install the Heroku CLI

    $ sudo snap install heroku --classic
  2. Login with the account we created before

    $ heroku login

    and follow the instructions

  3. In our solution folder we have to create the file Procfile to declare what command should be executed to start our app

    web: npm start

    and we have to add to our package.json the engines field

    "engines": { "node": "14.x" }
  4. Now we have to create the app on Heroku running the command

    $ heroku create

    Heroku generates a random name for our app.

  5. For local development, we are using a .env file to store the connection string of our MongoDB Atlas cluster and load the key using dotenv. On Heroku we have to set this environment variable running the command

    $ heroku config:set MONGODB_URI="mongodb+srv://<username>:<password>@<cluster-name>.mongodb.net/<db-name>"
  6. Let's deploy our code

    $ git push heroku main

Finally our solution is up, running and ready to serve requests. Let's open our browser and copy the URL generated output of step 4. It should be something like

https://sharp-rain-871.herokuapp.com/

Final notes

Using Fastify and MongoDB to create a REST API is straightforward. While I was implementing the code I used the TDD process that made things even simpler and faster to develop.

I briefly mentioned the queryStringParser module. It can be replaced by a powerful feature of Fastify that enables Validation and Serialization of routes.

To test the database module, together with Node Tap, I used MongoDB In-Memory Server: it stores data in memory and this means that once test suites finish to run data are deleted.

The deployment on Heroku requires almost no configuration and I like this a lot.


A photo of Elia Contini
Written by
Elia Contini
Sardinian UX engineer and a Front-end web architect based in Switzerland. Marathoner, traveller, wannabe nature photographer.