Enter the 'speed force'
From the documentation, fastify is a fast and low overhead web framework for Node.js.
So, I decided to explore some of the awesome features that fastify offers including but not limited to, speed, extensibility via plugins and decorators, schema validation, and serialization and logging. I dived into their documentation, which is excellent by the way, with the help of some GitHub repositories and decided to build some REST API endpoints powered by fastify and a PostgreSQL database.
You can check out the source code or follow along in this post.
Getting Started
Setting up the project
Navigate to the root folder of your project and run npm init
to initialize your project. Create an src
folder for your project source code and create an index.js
file as the entry point.
Installing dependencies
Installing nodemon
nodemon is a dev dependency that'll monitor your file changes and restart your server automatically.
You can install nodemon locally with npm:
npm install nodemon --save-dev
Add this npm script to the scripts in the package.json
file
"start": "nodemon src/index.js"
Installing Fastify
Install with npm:
npm i fastify --save
Hello World: Starting and running your server
In the index.js
file add this block of code:
const fastify = require('fastify')({logger: true})
fastify.get('/', async (request, reply) => {
reply.send({ hello: 'world' })
})
async function start() {
try{
await fastify.listen(3000)
} catch(err) {
fastify.log.error(err)
process.exit(1)
}
}
start()
On the first line, we create a fastify instance and enable logging, fastify uses pino as its logger. We then define a GET
route method, specify a homepage endpoint '/'
and pass in the route handler function which responds with the object {hello: 'world'}
when we make a get request to the homepage.
We instantiate our fastify server instance (wrapped in our start
function) and listen for requests on port 3000. To start the server, run npm start
on your terminal in the root folder. You Server should now be running and the following will be logged in the terminal:
{"level":30,"time":1618477680757,"pid":5800,"hostname":"x","msg":"Server listening at http://127.0.0.1:3000"}
When you visit the homepage you should see the response:
curl http://localhost:3000/
{"hello":"world"}
Great we have our server!
Plugins
We can extend fastify's functionality with plugins.
From the documentation:
A plugin can be a set of routes, a server decorator, or whatever.
We can refactor our route into a plugin and put it in a separate file i.e routes.js
, then require it in our root file and use the register
API to add the route or other plugins.
Create a routes.js
file and add this code:
async function routes(fastify, options) {
fastify.get('/', async (request, reply) => {
reply.send({ hello: 'world' })
})
}
module.exports= routes
We then require our module in index.js
and register it.
const fastify = require('fastify')({logger: true})
const route = require('./routes')
fastify.register(route)
async function start() {
...
}
start()
A request on the homepage should still work. Great, we have our first plugin.
Creating our database
To create a database we first need to connect to psql
, an interactive terminal for working with Postgres.
To connect to psql
run the command in the terminal:
psql -h localhost -U postgres
Enter your password in the prompt to connect to psql
.
The CREATE DATABASE databaseName
statement creates a database:
CREATE DATABASE todos;
To connect to the created database run the command:
\c todos
To create our table run the statement
CREATE TABLE todos (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
"createdAt" TIMESTAMP NOT NULL,
important BOOLEAN NOT NULL,
"dueDate" TIMESTAMP,
done BOOLEAN NOT NULL
);
Connecting our database
To interface with postgreSQL database we need node-postgres or the pg
driver.
To install node-postgres
:
npm install pg
Database connection plugin
Let's create a plugin to connect to our database. Create a db.js
file and add the following code:
const fastifyPlugin = require('fastify-plugin')
const { Client } = require('pg')
require('dotenv').config()
const client = new Client({
user: 'postgres',
password:process.env.PASSWORD,
host: 'localhost',
port: 5432,
database: process.env.DATABASE
})
async function dbconnector(fastify, options) {
try {
await client.connect()
console.log("db connected succesfully")
fastify.decorate('db', {client})
} catch(err) {
console.error(err)
}
}
module.exports= fastifyPlugin(dbconnector)
Let's skip the fastifyPlugin
part first.
We require Client
module from node-postgres
and create a client
instance, passing in the object with the various fields.
Make sure to create a .env
file and add:
PASSWORD='yourpassword'
Install and require the dotenv
module to load the environment variables
npm i dotenv
We then create our dbconnector
plugin and inside the try block, we connect to our postgres database.
Inside the block you can also see:
fastify.decorate('db', {client})
What is the decorate function?
In fastify, to add functionality to the fastify instance, you use decorators. We use the decorate
API, pass the property name 'db'
as the first argument and the value of our client
instance ({client}
) as the second argument. The value could also be a function or a string.
We export the plugin wrapped in a fastifyPlugin
module.
Require the module in the index.js
file and register it.
const dbconnector = require('./db')
fastify.register(dbconnector)
fastify.register(route)
async function start() {
...
}
start()
We can now access our client instance in other parts of the application for instance in our routes to query data using fastify.db.client
.
Let's take a step back to the fastifyPlugin
module. Why wrap our plugin with fastifyPlugin? When we register a plugin, we create a fastify context (encapsulation), which means access to the data outside our registered plugin is restricted. In this case, we can't access our database client
instance using fastify.db.client
anywhere in our application.
To share context, we wrap our plugin in a fastifyPlugin
module. We can now access our database client
instance anywhere in our application.
Serialization
Lets refactor our homepage route to return information from our database:
async function routes(fastify, options) {
//Access our client instance value from our decorator
const client = fastify.db.client
fastify.get('/', {schema: allTodos}, async function (request, reply) {
try {
const {rows} = await client.query('SELECT * FROM todos')
console.log(rows)
reply.send(rows)
} catch(err) {
throw new Error(err)
}
})
}
module.exports= routes
We First access our database client
instance and assign it to a client
variable.
Inside our routes we query all columns from our database using the shorthand *
and send the returned todos using reply.send(rows)
- you could also use return rows
.
Make sure you add some todos in your database first in the psql
terminal i.e:
INSERT INTO todos (id, name, "createdAt", important, "dueDate", done)
VALUES ('54e694ce-6003-46e6-9cfd-b1cf0fe9d332', 'learn fastify', '2021-04-20T12:39:25Z', true, '2021-04-22T15:22:20Z', false);
INSERT INTO todos (id, name, "createdAt", important, "dueDate", done)
VALUES ('d595655e-9691-4d1a-9a6b-9fbba046ae36', 'learn REST APIs', '2021-04-18T07:24:07Z',true, null, false);
If an error occurs, trying to query our database, we throw the error.
When you look closer at our get route method, you can see have an object as our second argument with a schema
key and allTodos
as the value.
Fastify uses fast-json-stringify to serialize your response body when a schema is provided in the route options.
To add the schema create a schemas.js
file and add the allTodos schema
:
const allTodos = {
response: {
200: {
type: 'array',
items: {
type: 'object',
required: ['id', 'name', 'createdAt', 'important', 'dueDate', 'done'],
properties: {
id: {type: 'string', format: 'uuid'},
name: {type: 'string'},
createdAt:{type: 'string',format: "date-time"},
important: {type: 'boolean'},
dueDate: {type: 'string',format: "date-time"},
done: {type: 'boolean'},
}
}
}
}
}
Fastify recommends using JSON Schema to serialize your outputs, you can read how to write JSON schema here.
We're specifying the response
, the response status code
, and the entity which is an array
type. The items
specify each entry in the array as an object with the required keys and the properties with the various fields and types.
Remember to require the module in the routes.js
file.
Validation
In the routes.js
file, let's add a POST
method route inside our route plugin to add todos to our database.
fastify.post('/', {schema: addTodo}, async function(request, reply) {
const {name, important, dueDate} = request.body
const id = uuidv4()
const done = false
createdAt = new Date().toISOString()
const query = {
text: `INSERT INTO todos (id, name, "createdAt", important, "dueDate", done)
VALUES($1, $2, $3, $4, $5, $6 ) RETURNING *`,
values: [id, name, createdAt, important, dueDate, done],
}
try {
const {rows} = await client.query(query)
console.log(rows[0])
reply.code(201)
return {created: true}
} catch (err) {
throw new Error(err)
}
})
We allow the client to send a JSON object in the body with name
of the todo, important
, and dueDate
properties.
We then generate a unique id
, assign false to done
and a timestamp assigned to createdAt
.
To generate the unique id install uuid
:
npm install uuid
Require the module in the routes.js
:
const { v4: uuidv4 } = require('uuid');
We then construct a query object with a text
property with the SQL statement to insert the todos in the database and the values
property containing the values to be inserted into the respective columns.
After a successful insert we send a 201 Created
status code back to the client.
In the schemas.js
file, let's add the validation schema for our todos:
const addTodo = {
body: {
type: 'object',
required: ['name'],
properties: {
name: {type: 'string',},
dueDate: {type: 'string', format: 'date-time', nullable: true, default: null},
important: {type: 'boolean', default: false},
}
},
response: {
201: {
type: 'object',
properties: {
created: {type: 'boolean'}
}
}
}
}
Fastify uses Ajv to validate requests.
We expect the client to always send the name
of the todo by adding it in the required property array.
The dueDate
property can be omitted by the client whereby it will be null
by default. This is made possible by setting the nullable
property to true
which allows a data instance to be JSON null. When provided it has to be of the format 'date-time'.
The client
can optionally indicate whether a todo is important or it falls back to the default.
If the above conditions are not satisfied, fastify will automatically send an error object with the error message.
For instance, if you omit a name, you should see an error like
{
"statusCode": 400,
"error": "Bad Request",
"message": "body should have required property 'name'"
}
Great! Our validation is working
Adding other REST endpoints
Update todo
Let's allow users to set their todo as done or importance of the todo or change dueDate. To do that let's add a PATCH
method route to our routes plugin.
fastify.patch('/:id',{schema: updateTodo}, async function (request, reply) {
const id = request.params.id
const {important, dueDate, done} = request.body
const query = {
text: `UPDATE todos SET
important = COALESCE($1, important),
"dueDate" = COALESCE($2, "dueDate"),
done = COALESCE($3, done)
WHERE id = $4 RETURNING *`,
values : [important, dueDate, done, id]
}
try {
const {rows} = await client.query(query)
console.log(rows[0])
reply.code(204)
} catch (err) {
throw new Error(err)
}
})
We're extracting the id
of the todo we want to update from the parameter and the values from the request body.
We then create our query statement, updating the columns provided optionally using the COALESCE
function. That is, if the clients omit some properties in the JSON body, we update only the provided properties and leave the rest as they are in the todo row.
We then respond with a 204 No Content
.
Lets add a validation schema for our route:
const updateTodo = {
body: {
type: 'object',
properties: {
dueDate: {type: 'string', format: 'date-time'},
important: {type: 'boolean'},
done: {type: 'boolean'}
}
},
params: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' }
}
}
}
params validates the params object.
Delete todo
To delete a todo, we just need the id
sent in the URL parameter.
Add a DELETE
method route:
fastify.delete('/:id', {schema: deleteTodo}, async function(request, reply) {
console.log(request.params)
try {
const {rows} = await client.query('DELETE FROM todos
WHERE id = $1 RETURNING *', [request.params.id])
console.log(rows[0])
reply.code(204)
} catch(err) {
throw new Error(err)
}
})
Lets add a validation schema for our DELETE
route:
const deleteTodo = {
params: {
type: 'object',
properties: {
id: {type: 'string', format: 'uuid'}
}
}
}
Conclusion:
Give fastify a try and "take your HTTP server to ludicrous speed" ~ Matteo Collina.
You can check out the project source code here
References:
Fastify examples; GitHub repos:
Top comments (2)
Edit suggestion: The SQL commands will not work unless ending in semi-colon. e.g.
CREATE DATABASE todo;
instead of justCREATE DATABASE todo
Yup, thanks for that