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

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 server receives a HTTP Request

  • preParsing : triggered before the requests'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 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);

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

Decorators

Decorators are fastify way of sharing some code over your fastify application. Using it together with 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 over the important concepts of fastify, we can start building the application. For this purpose, we will be creating our own fastify plugins as well as use 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 setup our project.

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

Routes contains all the business logic.

Create plugins, routes and schemas folder inside src

src/plugins : We will be writing our own custom plugins here. src/routes : We will be writing the applications endpoints here. src/schemas : We will writing 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 context 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 life easier.

@fastify/env : This helps to load the environment variables and make them available on everywhere in this as well as 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.

Lets 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 needs clarifications 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 database)

cascadeHooks : This options allows us to share the feature on 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 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 end point easy, we can also easily integrate swagger using 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 0.0.0.0:8081/docs

If you've followed, part 4 and seeded the data, you should be able to login with the following credentials

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

This should yield us 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. In the next part we will look at how to create authenticated routes, by creating some endpoints to save those favorite movies of ours.