Backend with Fastify — Part 5 (Fastify Concepts, Project Structure, Custom Plugins, Login Route, Swagger)

Arjan Dhakal
codingmountain
Published in
9 min readJan 17, 2024
Photo by Marc-Olivier Jodoin on Unsplash

Now that we have our database set up and seeded in part 4, it’s crucial to grasp some key concepts of Fastify before diving into application development.

You can find the complete code for this part here.

Fastify Concepts

Lifecycle and Hooks

Fastify operates on two main lifecycles: one for the application itself and another for the request and reply. Fastify allows us to listen to these events using hooks.

Application Lifecycle:
- onRoute: Triggered every time we add a new route to the Fastify instance
- onRegister: Triggered every time a new encapsulated context (explained below) is created
- onReady: Triggered when the application is ready to listen for incoming HTTP requests.
- onClose: Triggered when the server is stopping

Request and Reply Lifecycle:
- onRequest: Triggered when the server receives an HTTP Request
- preParsing: Triggered before the request’s body is evaluated
- preValidation: Fastify has a concept of JSON schema validation, which you can read more about here. This event is triggered before the validation is applied
- preHandler: Each route is registered with a handler and this event is triggered before that handler is executed
- preSerialization: Triggered before the response payload is transformed to string, buffer, etc
- onError: Triggered if an error happens during the request’s lifecycle
- onSend: Triggered right before sending the response
- onResponse: Triggered after the request has been served

Example of using a Fastify hook:

fastify.addHook('onError', (request, reply, error, done) => {  
customLogger(error)
done()
})

Plugins and Encapsulated Context

Plugins in Fastify extend the application’s functionalities and encourage code reusability and encapsulation. They allow for logical separation within the application.

Let’s take a look at an example. It will also allow us to understand the encapsulation context of Fastify better.

Consider a scenario where we have three routes: /routeA, /routeB, and /routeC. We want to log request headers only for the first two routes. Here’s how we can achieve it using Fastify plugins:

const loggedRoutes = async (fastify, opts) => {
fastify.addHook("onRequest", (request, reply, done) => {
console.log("Request Headers:", request.headers);
done();
});
fastify.get("/routeA", async () => "Route A");
fastify.get("/routeB", async () => "Route B");
};



const normalRoutes = async (fastify, opts) => {
fastify.get("/routeC", async () => "Route C");
};

fastify.register(loggedRoutes);
fastify.register(normalRoutes);
Encapsulated Context

If we examine the example and the diagram, we can see that plugins have their own encapsulated context. Routes inside the logged plugin context have a hook registered on the `onRequest` lifecycle. Routes in the normal plugin context are free from that logic. Moreover, you can nest another plugin inside a plugin, creating a hierarchy of scoped logics.

This hierarchical approach can be useful for organizing routes that depend on specific authentication mechanisms, separated from other routes.

Creating a Fastify plugin is straightforward. It’s merely a function that accepts a Fastify instance and an options object:

function myFastifyPlugin(fastify,opts){
...plugin logic...
}
fastify.register(myFastifyPlugin)

The option parameter supports a pre-defined set of options that Fastify will use, you can explore more about them here.

The default behavior of Fastify with plugins is always to create a new scope. But this can be overridden for when we require some flexibility and want to share some logic among sibling plugins.

The two ways to do those are:
- Using the fastify-plugin module
- Using the skip-override hidden property

We will be using the first method (fastify-plugin), in our application, especially for the custom plugins we make.

Decorators

Decorators are Fastify’s way of sharing some code over your Fastify application. Using it together with a plugin allows a powerful way to share and isolate code.

Example:

fastify.decorate('envConfig', {  
db: 'dburl.com',
port: 5824
})

fastify.get('/', async function (request, reply) {
return { msg: `my db url is ${this.envConfig.db}` }
})

Our application

Now that we’ve briefly visited the important concepts of Fastify, we can start building the application. For this purpose, we will be creating our Fastify plugins as well as using the Fastify plugins found in the community.

Structure of the project

Our project structure will look like the image below. It is also the structure that is generated if we were using a tool like fastify-cli to set up our project.

Structure of a Fastify project

Plugins contain all the code that needs to be shared across the entire application such as the database instance, swagger, rate limiting, etc.

Routes contain all the endpoints and business logic of the application.

Create plugins, routes, and schemas folder inside src

src/plugins: We will be writing our custom plugins here.
src/routes: We will be writing the applications' endpoints here.
src/schemas: We will write common-type schemas here.

Custom Plugins

inside src/plugins/knex.ts, write the following code:

import knex from 'knex'
import fp from 'fastify-plugin'

export default fp(
function fastifyKnex(fastify, options, next) {
if (!fastify.knex) {
const connection = knex({
client: 'pg',
connection: {
connectionString: fastify.config.DATABASE_URL,
},
...(fastify.config.NODE_ENV === 'development' && { debug: true }),
...(options && { options }),
})
fastify.decorate('knex', connection)
}
next()
},
{
name: 'knex',
},
)

We are using the fastify-plugin module, briefly mentioned above in the concept section. Using the fastify-plugin module, allows us to use the knex function decorated in the plugin in other Fastify contexts as fastify-plugin by default adds the skip-override hidden property.

Let’s also create a custom plugin for authentication purposes inside src/plugins/auth.ts.

import fp from 'fastify-plugin'
import FastifyJWT from '@fastify/jwt'
import type { FastifyRequest } from 'fastify/types/request'
import type { FastifyReply } from 'fastify'

export default fp(
async function authenticatePlugin(fastify, opts) {
await fastify.register(FastifyJWT, {
secret: fastify.config.JWT_SECRET,
})

fastify.decorate(
'authenticate',
async function authenticate(
request: FastifyRequest,
reply: FastifyReply,
) {
try {
await request.jwtVerify()
} catch (err) {
reply.send(err)
}
},
)

fastify.decorateRequest(
'generateToken',
function (payload: Record<string, any>) {
const token = fastify.jwt.sign(
{
...payload,
},
{
jti: String(Date.now()),
expiresIn: fastify.config.JWT_EXPIRES_IN,
},
)
return token
},
)
},
{
name: 'authentication-plugin',
},
)

Our Fastify App

It’s a common practice to use environment variables in any application. In this TypeScript project, we’ll use sinclair/typebox to create a schema for the expected environment variables. Define the environment variables in src/schemas/dotenv.ts

import {type Static, Type} from '@sinclair/typebox'

export const EnvSchema = Type.Object({
NODE_ENV : Type.String({default: 'development'}),
DATABASE_URL: Type.String(),
PORT: Type.Number({default: 8081}),
JWT_SECRET: Type.String(),
JWT_EXPIRES_IN: Type.String()
})

export type EnvSchemaType = Static<typeof EnvSchema>

Also, add any missing values in your .env file:

..prev values...
JWT_SECRET=somesuperduperdifficultsecret
JWT_EXPIRES_IN=24h

We will be using the environment schema in app.ts. Change the app.ts to the following:


import { type FastifyInstance, type FastifyPluginOptions } from 'fastify'
import AutoLoad from '@fastify/autoload'
import Sensible from '@fastify/sensible'
import Env from '@fastify/env'
import Cors from '@fastify/cors'
import { join } from 'path'
import { EnvSchema } from './schemas/dotenv'

export default async function (
fastify: FastifyInstance,
opts: FastifyPluginOptions,
): Promise<void> {
await fastify.register(Env, {
schema: EnvSchema,
dotenv: true,
data: opts.configData,
})

await fastify.register(Sensible)

await fastify.register(Cors, {
origin: true,
})

await fastify.register(AutoLoad, {
dir: join(__dirname, '.', 'plugins'),
dirNameRoutePrefix: false,
ignorePattern: /.*.no-load\.js/,
indexPattern: /^no$/i,
options: Object.assign({}, opts),
})

await fastify.register(AutoLoad, {
dir: join(__dirname, 'routes'),
indexPattern: /.*routes(\.js|\.cjs)$/i,
ignorePattern: /.*\.js/,
autoHooksPattern: /.*hooks(\.js|\.cjs|\.ts)$/i,
autoHooks: true,
cascadeHooks: true,
options: Object.assign({}, opts),
})

if (fastify.config.NODE_ENV === 'development') {
console.log('CURRENT ROUTES:')
console.log(fastify.printRoutes())
}

// Add an onClose hook to close any open connections such as connections to DB
fastify.addHook('onClose', async fastify => {
console.log('Running onClose hook...')
if (fastify.knex) {
await fastify.knex.destroy()
}
console.log('Running onClose hook complete')
})
}

So, if we look at the above code, we can see we are taking advantage of several community plugins to make our lives easier.

@fastify/env: This helps to load the environment variables and make them available everywhere in this as well as the nested encapsulated context through `fastify.config`.

@fastify/autoload: This helps us to automatically load the plugins as well as routes from the files and register them to the instance.

@fastify/sensible: Adds some helpful utilities such as httpErrors and other APIs.

Let us take a look at how we are using the autoload for the routes in the above code:

  await fastify.register(AutoLoad, {
dir: join(__dirname, 'routes'),
indexPattern: /.*routes(\.js|\.cjs)$/i,
ignorePattern: /.*\.js/,
autoHooksPattern: /.*hooks(\.js|\.cjs|\.ts)$/i,
autoHooks: true,
cascadeHooks: true,
options: Object.assign({}, opts),
})

Most of the options are self-explanatory, but some that need clarification are the following:

autoHooks: This lets us register hooks for every routes.js file in the directory. We will mostly be using them to share some common functions (such as functions to save or fetch data from the database).

cascadeHooks: This option allows us to share the feature for the subdirectories too.

Creating a Login Route

Create a file autohook.ts inside src/routes/auth/autohook.ts

import fp from 'fastify-plugin'

export default fp(
async function authAutoHooks(fastify, opts) {
fastify.decorate('usersDataSource', {
findUser: async (email: string) => {
const user = await fastify.knex
.select('*')
.from('users')
.where('email', email)
if (!user || user.length === 0) {
return null
}
return user[0]
},
})
},
{
encapsulate: true
},
)

This will make the findUser method available to us inside the src/routes/auth folder whenever needed. Also, take note of the encapsulate: true option. Since the fastify-plugin module defaults to using skip-override to expose decorated properties in other scopes, we utilize this option to avoid that behavior.

We’ve been decorating Fastify with a lot of properties using the decorate feature. But, since we are using typescript, we need to manually update the fastify instance with these properties. For this, we need to type augment the fastify module.

So let’s create a type file inside src/types/index.d.ts

declare module 'fastify' {
type UserDataSource = {
findUser: (email: string) => Promise<QueryResult<any>>
}

export interface FastifyRequest {
generateToken: (payload: Record<string, any>) => string
}
export interface FastifyInstance<
RawServer extends RawServerBase = RawServerDefault,
RawRequest extends RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
RawReply extends RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
Logger = FastifyBaseLogger,
> {
knex: knex
config: EnvSchemaType
usersDataSource: UserDataSource
authenticate: (request: FastifyRequest, reply: FastifyReply) => void
}
}

Also, since we don’t want to lint this file, add to .eslintignore

*.d.ts

And finally, in tsconfig.json, add :

{
... prev ...
"ts-node":{
"files": true
},
"typeRoots": ["./src/types"]
}

This should register the types we defined and the pesky typescript errors should be gone.

Now, create a schema for a login endpoint, inside the src/routes/auth/schemas/login.ts

import { type Static, Type } from '@sinclair/typebox'

/** Login Schemas */
export const LoginBodySchema = Type.Object({
email: Type.String({ format: 'email' }),
password: Type.String(),
})

export type LoginBodySchemaType = Static<typeof LoginBodySchema>

We can use this schema, to validate the request and also to create a swagger endpoint.

Finally, we can write the endpoint logic inside src/routes/auth/routes.ts

import fp from 'fastify-plugin'
import { LoginBodySchema, type LoginBodySchemaType } from './schemas/login'
import { generateHash } from '../../utils/generate_hash'

export default fp(
async function auth(fastify, opts) {
fastify.post<{ Body: LoginBodySchemaType }>(
'/login',
{
schema: {
body: LoginBodySchema,
},
},
async (request, reply) => {
const user = await fastify.usersDataSource.findUser(request.body.email)
if (!user) {
const err = fastify.httpErrors.unauthorized(
'Wrong credentials provided!',
)
throw err
}

const { hash } = await generateHash(request.body.password, user.salt)

if (hash !== user.password) {
const err = fastify.httpErrors.unauthorized(
'Wrong credentials provided!',
)
throw err
}

const data = {
token: request.generateToken({
id: user.id,
email: user.email,
}),
}
return { success: 'true', data }
},
)
},
{
name: 'auth-routes',
encapsulate: true,
},
)

Now, to make checking the endpoint easy, we can also easily integrate Swagger using the Fastify plugin.

Adding Swagger

If you don’t have these packages installed, which we did in the part 1 series, you can install them

npm i @fastify/swagger-ui @fastify/swagger

As we’ve done several times before, let’s create a custom plugin for this inside src/plugins/swagger.ts. Our Autoload Plugin will then automatically load it, making Swagger easily accessible. This is one of the noteworthy features of using Fastify.

import fp from 'fastify-plugin'
import SwaggerUI from '@fastify/swagger-ui'
import Swagger, { type FastifyDynamicSwaggerOptions } from '@fastify/swagger'

export default fp<FastifyDynamicSwaggerOptions>(async (fastify, opts) => {
await fastify.register(Swagger, {
openapi: {
info: {
title: 'Favmov',
description: 'API Endpoints for favmov',
version: '0.1.0',
},
servers: [
{
url: `http://0.0.0.0:${fastify.config.PORT}`,
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
},
})

if (fastify.config.NODE_ENV !== 'production') {
await fastify.register(SwaggerUI, {
routePrefix: '/docs',
})
}
})

Now, you get access to the swagger at http://0.0.0.0:8081/docs

Swagger in action

If you’ve followed, part 4 and seeded the data, you should be able to log in with the following credentials

{
"email": "example1@favmov.com",
"password": "password1"
}

This should yield a response body in the following form:

{
"success": "true",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZW1haWwiOiJleGFtcGxlMUBmYXZtb3YuY29tIiwianRpIjoiMTcwNDg5NzUzMDM3OCIsImlhdCI6MTcwNDg5NzUzMCwiZXhwIjoxNzA0ODk5MzMwfQ.aAADIE4bsTy-G_tLU-SOmI6fwjBcJU5m8h-hPSRj6tA"
}
}

Conclusion

This blog post turned out to be much longer than I anticipated but we covered most of the core essentials of Fastify. In the next part, we will look at how to create authenticated routes, by creating some endpoints to save those favorite movies of ours.

--

--