Fastify and MongoDB on Heroku
April 18, 2021Tested 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
-
copy the provided link
-
on our computers, let's create a folder called
todos-api
(it is the folder where we will develop our solution) create a file
.env
-
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"
-
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
-
Click the Source Control tab in the VS Code side bar
-
click Initialize Repository button and follow the steps
add a
.gitignore
file with this content# .gitignore node_modules .nyc_output .env
-
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
-
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
. -
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.
-
Let's install the Heroku CLI
$ sudo snap install heroku --classic
-
Login with the account we created before
$ heroku login
and follow the instructions
-
In our solution folder we have to create the file
Procfile
to declare what command should be executed to start our appweb: npm start
and we have to add to our
package.json
theengines
field"engines": { "node": "14.x" }
-
Now we have to create the app on Heroku running the command
$ heroku create
Heroku generates a random name for our app.
-
For local development, we are using a
.env
file to store the connection string of our MongoDB Atlas cluster and load the key usingdotenv
. 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>"
-
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.