DEV Community

Carl Barrdahl
Carl Barrdahl

Posted on

Building a REST api using fastify and TypeORM

A restaurant wants to be able to digitally manage their inventory to more easily keep track of when products expire and work in a more data-driven approach. I got the chance to build a prototype in React Native and Typescript.

This is how I created the backend api using fastify and TypeORM.

You can find an example project on Github: https://github.com/carlbarrdahl/fastify-server-example

Requirements

  • Inventory should be stored in an MSSQL database
  • REST api to communicate with database
  • Only authorized users should be able to access the api

What we will cover

  1. Building a REST api using fastify
  2. Integration testing
  3. Database connection with TypeORM
  4. JSON Schema for client data validation and defining allowed responses
  5. Securing endpoints using JWT
  6. Automatically generated documentation in Swagger

REST api in fastify

I decided to write the api using fastify as a server framework because it's fast, modular as well as easy to use and test. It also has a great ecosystem for its plugin-system and you can easily write your own plugins as we will see later.

A good way to make sure the api behaves as expected is to write integration tests. By developing against a test suite we get a fast feedback loop and don't need to go through the process of manually calling the api to check if it works as expected.

I started by specing out the expected behaviour:

test("GET /products returns list of products", () => {})
test("DELETE /products/:id deletes a product", () => {})
test("GET /inventory returns list of inventory", () => {})
test("POST /inventory/:id creates a product", () => {})
test("DELETE /inventory/:id deletes an inventory", () => {})
test("JWT token is required for endpoints", () => {})

To test endpoints in fastify we can use inject to simulate a request to the server and pass method, url, headers and payload and then make sure the response is what we expect.

// test/server.test.ts
import createServer from "../src/server"
const server = createServer()

test("GET /inventory returns list of inventory", async done => {
  server.inject({ method: "GET", url: `/inventory` }, (err, res) => {
    expect(res.statusCode).toBe(200)
    expect(JSON.parse(res.payload)).toEqual([]) // expect it to be empty for now
    done()
  })
})

By using fastify's plugin system we can make the app modular so we more easily can split into smaller pieces if needed. I opted to go with the following folder structure:

/src
  /modules
    /health
      /routes.ts
      /schema.ts
    /product
      /entity.ts
      /routes.ts
      /schema.ts
    /inventory
      /entity.ts
      /routes.ts
      /schema.ts
  /plugins
    /auth.ts
    /jwt.ts
    /printer.ts
  /server.ts
  /index.ts
/test
  /server.test.ts

Here's what the inventory routes might look like:

// src/modules/inventory/routes.ts
module.exports = (server, options, next) => {
  server.get(
    "/inventory",
    // we will cover schema and authentication later
    { preValidation: [server.authenticate], schema: listInventorySchema },
    async (req, res) => {
      req.log.info(`list inventory from db`)
      const inventory = [] // return empty array for now to make the test green

      res.send(inventory)
    }
  )
  // routes and controllers for create, delete etc.
  next()
}

Our test should now be green, that's a good sign!

However, an inventory api that always return an empty array is not very useful. Let's connect a data source!

Connecting to a database with TypeORM

What is an ORM you might ask? Most databases have different ways of communicating with them. An ORM normalizes this into a unified way so we can easily switch between different types of supported databases without having to change implementation.

First let's create the entity (or model):

// src/modules/inventory/entity.ts
@Entity()
export class Inventory {
  @PrimaryGeneratedColumn("uuid")
  id: string

  // Each product can exist in multiple inventory
  @ManyToOne(type => Product, { cascade: true })
  @JoinColumn()
  product: Product

  @Column()
  quantity: number

  @Column("date")
  expiry_date: string

  @CreateDateColumn()
  created_at: string

  @UpdateDateColumn()
  updated_at: string
}

Next, we'll use the plugin to connect to the database and create a decorator with our data repositories. That way they are easily accessible from our routes.

// src/plugins/db.ts
import "reflect-metadata"
import fp from "fastify-plugin"
import { createConnection, getConnectionOptions } from "typeorm"
import { Inventory } from "../modules/inventory/entity"

module.exports = fp(async server => {
  try {
    // getConnectionOptions will read from ormconfig.js (or .env if that is prefered)
    const connectionOptions = await getConnectionOptions()
    Object.assign(connectionOptions, {
      options: { encrypt: true },
      synchronize: true,
      entities: [Inventory, Product]
    })
    const connection = await createConnection(connectionOptions)

    // this object will be accessible from any fastify server instance
    server.decorate("db", {
      inventory: connection.getRepository(Inventory),
      products: connection.getRepository(Product)
    })
  } catch (error) {
    console.log(error)
  }
})
// ormconfig.js
module.exports = {
  type: "mssql",
  port: 1433,
  host: "<project-name>.database.windows.net",
  username: "<username>",
  password: "<password>",
  database: "<db-name>",
  logging: false
}

We can now add the plugin to createServer and update our route to query the database:

// src/server.ts
server.use(require("./plugins/db"))

// src/modules/inventory/routes.ts
const inventory = await server.db.inventory.find({
  relations: ["product"] // populate the product data in the response
})

Unless we want our tests to query our production database we have to either setup an in-memory test-db or just mock it. Let's create a mock in our test:

// test/server.test.ts
import typeorm = require('typeorm')

const mockProducts = [{...}]
const mockInventory = [{...}]
const dbMock = {
  Product: {
    find: jest.fn().mockReturnValue(mockProducts),
    findOne: jest.fn().mockReturnValue(mockProducts[1]),
    remove: jest.fn()
  },
  Inventory: {
    find: jest.fn().mockReturnValue(mockInventory),
    findOne: jest.fn().mockReturnValue(mockInventory[1]),
    save: jest.fn().mockReturnValue(mockInventory[0]),
    remove: jest.fn()
  }
}
typeorm.createConnection = jest.fn().mockReturnValue({
  getRepository: model => dbMock[model.name]
})
typeorm.getConnectionOptions = jest.fn().mockReturnValue({})

Here's how the test will look for the create inventory route:

test("POST /inventory/:id creates an inventory", done => {
  const body = { product_id: mockProducts[0].id, quantity: 1 }
  server.inject(
    {
      method: "POST",
      url: `/inventory`,
      payload: body,
      headers: {
        Authorization: `Bearer ${token}`
      }
    },
    (err, res) => {
      expect(res.statusCode).toBe(201)
      // assert that the database methods have been called
      expect(dbMock.Product.findOne).toHaveBeenCalledWith(body.product_id)
      expect(dbMock.Inventory.save).toHaveBeenCalled()
      // assert we get the inventory back
      expect(JSON.parse(res.payload)).toEqual(mockInventory[0])
      done(err)
    }
  )
})

How do we know the correct data is being sent when creating an inventory?

Validating requests with JSON schema

Another great thing about fastify is it comes with built in schema validation using json-schema specification.

Why is this important?

We can never know what data a client sends and we don't want to have to manually check the request body in every route. Instead we want to describe what such requests might look like and what kind of responses can be expected. If what the client send doesn't match the schema, fastify will automatically throw an error. This leads to clean, understandable code without cluttering it with unnecessary if statements.

Note: This will not protect against potential xss. A bad actor is still able to send malicious javascript code. We can use fastify-helmet's xssFilter to protect us against this attack.

In addition to validation, we can automatically generate Swagger documentation for our routes based on these specifications so developers know how to use the api. Neat!

These json schemas are defined as simple objects. Here are the schemas for the inventory routes:

const inventorySchema = {
  id: { type: "string", format: "uuid" },
  product_id: { type: "string", format: "uuid" },
  // note the reference to the productSchema ↘
  product: { type: "object", properties: productSchema },
  quantity: { type: "number", min: 1 },
  expiry_date: { type: "string", format: "date-time" },
  created_at: { type: "string", format: "date-time" },
  updated_at: { type: "string", format: "date-time" }
}
export const listInventorySchema = {
  summary: "list inventory",
  response: {
    200: {
      type: "array",
      items: {
        properties: inventorySchema
      }
    }
  }
}
export const postInventorySchema = {
  summary: "create inventory",
  body: {
    // incoming request body
    type: "object",
    required: ["product_id", "quantity"],
    properties: {
      product_id: { type: "string", format: "uuid" },
      quantity: { type: "integer", minimum: 1 }
    }
  },
  response: {
    201: {
      type: "object",
      properties: inventorySchema
    }
  }
}

Fastify will now be very picky about the data it receives and will tell us if something is missing or of incorrect type.

Still, anyone is able to access our API. Next we'll look at how we can restrict this to clients with a valid key using json web token.

Authorization

To secure our api we will use json web token.
https://jwt.io/introduction/

This is what JWT.io has to say:

Authorization: This is the most common scenario for using JWT. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token. Single Sign On is a feature that widely uses JWT nowadays, because of its small overhead and its ability to be easily used across different domains.

Information Exchange: JSON Web Tokens are a good way of securely transmitting information between parties. Because JWTs can be signed—for example, using public/private key pairs—you can be sure the senders are who they say they are. Additionally, as the signature is calculated using the header and the payload, you can also verify that the content hasn't been tampered with.

This means we can both use it to verify a user is who they say they are and share secret data in a secure way. In our case we'll use it to simply authorize a shared user.

We'll use fastify plugin to import the library and decorate authenticate with a request handler that will verify our token.

// src/plugins/auth.ts
import fp from "fastify-plugin"

export default fp((server, opts, next) => {
  server.register(require("fastify-jwt"), {
    secret: "change this to something secret"
  })
  server.decorate("authenticate", async (req, res) => {
    try {
      await req.jwtVerify()
    } catch (err) {
      res.send(err)
    }
  })

  next()
})

We then run authenticate in the preValidation hook on every request to make sure the jwt is valid.

Internally it retreives the token passed in Authorization header and verifies it has been signed with our secret key.

// src/modules/inventory/routes.ts
server.post(
  "/inventory",
  // authenticate the request before we do anything else
  { preValidation: [server.authenticate], schema: postInventorySchema },
  async (req, res) => {
    const { quantity, product_id } = req.body
    req.log.info(`find product ${product_id} from db`)
    const product = await server.db.products.findOne(product_id)

    if (!product) {
      req.log.info(`product not found: ${product_id}`)
      return res.code(404).send("product not found")
    }

    req.log.info(`save inventory to db`)
    const inventory = await server.db.inventory.save({
      quantity,
      product,
      expiry_date: addDays(product.expires_in)
    })

    res.code(201).send(inventory)
  }
)

Since we don't have any user accounts implemented right now we can generate a temporary token like this:

server.ready(() => {
  const token = server.jwt.sign({ user_id: "<user_id>" })
  console.log(token)
})

As you may have noticed the token is the signed object (with some other stuff) encoded as a Base64 string. We can use this to limit access to specific users or inventory created by a user. Maybe something like this:

// src/modules/inventory/routes.ts
server.get(
  "/inventory/:id",
  { schema: getInventorySchema, preValidation: [server.authenticate] },
  async (req, res) => {
    const inventory = await server.db.inventory.findOne(req.params.id)
    // Make sure the requesting user is the same as the inventory owner
    if (req.user.user_id !== inventory.owner.id) {
      throw new Error("Unauthorized access")
    }
    res.send(inventory)
  }
)

More advanced usage can check the timestamp for when the token has been issued (iat).

Swagger documentation

What's this Swagger documentation I've been going on about? Basically it provides a visual interface for your api allowing you to see how it works, what the request bodies should look like and example responses. Pretty much what we defined in our json schema exposed as documentation.

Swagger

This is the configuration used in createServer:

server.register(require("fastify-oas"), {
  routePrefix: "/docs",
  exposeRoute: true,
  swagger: {
    info: {
      title: "inventory api",
      description: "api documentation",
      version: "0.1.0"
    },
    servers: [
      { url: "http://localhost:3000", description: "development" },
      { url: "https://<production-url>", description: "production" }
    ],
    schemes: ["http"],
    consumes: ["application/json"],
    produces: ["application/json"],
    security: [{ bearerAuth: [] }],
    securityDefinitions: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT"
      }
    }
  }
})

Future improvements

  • User accounts
  • Caching
  • Improved error handling
  • Improved security against XSS and more using fastify-helmet
  • Load balancing

What did you think of this article?

Did you learn something new? Was something hard to understand? Too much code? Not enough? Am I doing things totally wrong? Tell me in the comments.

Oldest comments (6)

Collapse
 
hoangnguyen291 profile image
hoangnguyen291

Great post! thank you very much, i'm new to nodejs and want to start with fastify, this is very valuable to me, I hope you can continue with more posts regard to fastify

Collapse
 
carlbarrdahl profile image
Carl Barrdahl

Thank you! I'm glad you found it useful.

Collapse
 
bhuvinth profile image
Bhuvin Thakker

Hi it's a great post!
One thing which is peculiar, I noticed that:
// test/server.test.ts
import typeorm = require('typeorm')

I am curious why you did that. I tried this code and with import it doesn't work.
But I am not able to figure out why it doesn't work?

Collapse
 
carlbarrdahl profile image
Carl Barrdahl

Thank you Bhuvin!

That is a good question and unfortunately I can't remember exactly. I imagine it might be to override the methods in typeorm:

typeorm.createConnection = jest.fn().mockReturnValue({
  getRepository: model => dbMock[model.name]
})

typeorm.getConnectionOptions = jest.fn().mockReturnValue({})
Collapse
 
zerodesu profile image
Ahmad Gani

i get this error while running the test script

 SyntaxError: Cannot use import statement outside a module
Enter fullscreen mode Exit fullscreen mode

how to solve it? i has been add jest.config.ts too.

Collapse
 
zerodesu profile image
Ahmad Gani

accrding to the docs below is my configuration

jest.config.ts

module.exports = {
  globals: {
    "ts-jest": {
      tsConfig: "tsconfig.json",
    },
  },
  preset: "ts-jest/presets/default-esm",
  extensionsToTreatAsEsm: [".ts"],
  moduleFileExtensions: ["ts", "js"],
  moduleNameMapper: {
    "(.*)": "<rootDir>/src",
  },
  transform: {
    "^.+\\.(ts)$": [
      "ts-jest",
      {
        useESM: true,
      },
    ],
  },
  testMatch: ["**/test/**/*.test.ts"],
  testEnvironment: "node",
};
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{
  "compilerOptions": {
    "declaration": true,
    "target": "es2016",                                  
    "experimentalDecorators": true,        
    "emitDecoratorMetadata": true,                
    "module": "ES6",                                     
    "moduleResolution": "node",                           
    "baseUrl": "./src",                           
    "paths": {
      "~/*": ["*"]
    },
    "outDir": "./dist",                                  
    "esModuleInterop": true,                             
    "forceConsistentCasingInFileNames": true,             
    "strict": true,                                      
    "skipLibCheck": true                                  
  }
}
Enter fullscreen mode Exit fullscreen mode

I really appreciate your help :)