Better Backend DX: JSON Schema + TypeScript +  Swagger = ✨  Vol. 2

Better Backend DX: JSON Schema + TypeScript + Swagger = ✨ Vol. 2

Β·

10 min read

πŸ‘‹ Hi! I'm David Peng. You can find me on Twitter: @davipon.

This post is Vol. 2 of the Better Backend DX: JSON Schema + TypeScript + Swagger = ✨, and I'll cover following topics by building a simple Fastify CRUD Posts API:

  1. Improve code readability & testability by separating options and handler of route method
  2. Use of JSON Schema $ref keyword
  3. Swagger UI and OpenAPI specification
  4. Use Thunder Client (VS Code extension) to test APIs

Complete repo on GitHub: github.com/davipon/fastify-esbuild

Improve Code Readability & Testability

This is a general fastify shorthand route:

// src/routes/examples.ts
/*
  Route structure:
  fastify.get(path, [options], handler)
*/
fastify.get('/',
  {
    schema: {
      querystring: {
        name: { type: 'string' },
        excitement: { type: 'integer' }
      },
      response: {
        200: {
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        }
      }
    }
  },
  (request, reply) {
    reply.send({ hello: 'world' })
  }
)

We can refactor it and break it into chunks with the notion of Separation of Concerns (SoC). It would be much easier for us to maintain and test our code.

// src/routes/examples/schema.ts
export const schema = {
  querystring: {
    name: { type: 'string' },
    excitement: { type: 'integer' }
  },
  response: {
    200: {
      type: 'object',
      properties: {
        hello: { type: 'string' }
      }
    }
  }
}
// src/routes/examples/handler.ts
export const handler = function (request, reply) {
  reply.send({ hello: 'world' })
}
// src/routes/examples/index.ts
import { schema } from './schema'
import { handler } from './handler'

...

fastify.get('/', { schema }, handler)

Since we're using TypeScript, we need to type schemas and handler functions.

Build a Simple Blog Post CRUD API

Here is the specification of our API:

  • GET
    • '/posts': Return all posts
    • '/posts?deleted=[boolean]'(querystring): Filter posts that are deleted or not
    • '/posts/[postid]'(params): Find specific post
    • Status code 200: Successful request
    • Status code 404: Specific post not found
  • POST
    • '/posts': Create a new post
    • Status code 201: Create post successfully
  • PUT
    • '/posts/[postid]'(params): Update specific post
    • Status code 204: Update specific post successfully
    • Status code 404: Specific post not found
  • DELETE
    • '/posts/[postid]'(params): Delete specific post
    • Status code 204: Delete specific post successfully
    • Status code 404: Specific post not found

Recommend: Best Practices for Designing a Pragmatic RESTful API

First, create a sample data posts:

I plan to write about MongoDB's official driver & containerization in my next post, so I use a sample object as data here.

// src/routes/posts/posts.ts

// Sample data
export const posts = [
  {
    id: 1,
    title: 'Good Post!',
    published: true,
    content: 'This is a good post',
    tags: ['featured'],
    deleted: false
  },
  {
    id: 2,
    title: 'Better Post!',
    published: true,
    content: 'This is an even better post',
    tags: ['featured', 'popular'],
    deleted: false
  },
  {
    id: 3,
    title: 'Great Post!',
    published: true,
    content: 'This is a great post',
    tags: ['featured', 'popular', 'trending'],
    deleted: false
  }
]

Request & Response Schemas

Let's create JSON Schema for Params, Querystring, Body, Reply:

The shorthand route methods (i.e., .get) accept a generic object "RouteGenericInterface" containing five named properties: Body, Querystring, Params, Headers, and Reply.

// src/routes/posts/schema.ts

import { FastifySchema } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'

// Params Schema
const paramsSchema = {
  type: 'object',
  require: ['postid'],
  properties: {
    postid: { type: 'number' }
  },
  additionalProperties: false
} as const

export type Params = FromSchema<typeof paramsSchema>

// Querystring Schema
const querystringSchema = {
  type: 'object',
  properties: {
    deleted: { type: 'boolean' }
  },
  additionalProperties: false
} as const

export type Querystring = FromSchema<typeof querystringSchema>

// Body Schema
export const bodySchema = {
  type: 'object',
  properties: {
    id: { type: 'number' },
    title: { type: 'string' },
    published: { type: 'boolean' },
    content: { type: 'string' },
    tags: { type: 'array', items: { type: 'string' } },
    deleted: { type: 'boolean' }
  },
  required: ['title', 'published', 'content', 'tags', 'deleted']
} as const

export type Body = FromSchema<typeof bodySchema>

// Reply Schema
const replySchema = {
  type: 'object',
  properties: {
    // Return array of "post" object
    posts: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          id: { type: 'number' },
          title: { type: 'string' },
          published: { type: 'boolean' },
          content: { type: 'string' },
          tags: { type: 'array', items: { type: 'string' } },
          deleted: { type: 'boolean' }
        },
        required: ['title', 'published', 'content', 'tags', 'deleted']
      }
    }
  },
  additionalProperties: false
} as const

export type Reply = FromSchema<typeof replySchema>

// ReplyNotFound Schema
export const postNotFoundSchema = {
  type: 'object',
  required: ['error'],
  properties: {
    error: { type: 'string' }
  },
  additionalProperties: false
} as const

export type ReplyNotFound = FromSchema<typeof postNotFoundSchema>

We also need to create a schema for each route method so @fastify/swagger can generate documents automatically. While before that, let's take a look at the above schemas.

You may notice a duplication in bodySchema and replySchema. We can reduce this by using the $ref keyword in JSON Schema.

JSON Schema $ref Keyword

Let's refactor the code and make it reusable:

// First create a general "post" schema

// Shared Schema
export const postSchema = {
  $id: 'post',
  type: 'object',
  properties: {
    id: { type: 'number' },
    title: { type: 'string' },
    published: { type: 'boolean' },
    content: { type: 'string' },
    tags: { type: 'array', items: { type: 'string' } },
    deleted: { type: 'boolean' }
  },
  required: ['title', 'published', 'content', 'tags', 'deleted']
} as const

// We don't need to create a separate "bodySchema".
// But directly infer type from postSchema
export type Body = FromSchema<typeof postSchema>

// Reply Schema
// Check https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/#adding-a-shared-schema
const replySchema = {
  type: 'object',
  properties: {
    posts: {
      type: 'array',
      items: { $ref: 'post#' }
    }
  },
  additionalProperties: false
} as const

// Check https://github.com/ThomasAribart/json-schema-to-ts#references
export type Reply = FromSchema<
  typeof replySchema,
  { references: [typeof postSchema] }
>

// Also make ReplyNotFound reusable for future use
export const postNotFoundSchema = {
  $id: 'postNotFound', // add $id here
  type: 'object',
  required: ['error'],
  properties: {
    error: { type: 'string' }
  },
  additionalProperties: false
} as const

export type PostNotFound = FromSchema<typeof postNotFoundSchema>

But to create a shared schema, we also need to add it to the Fastify instance.

// src/routes/posts/index.ts
import { type FastifyInstance } from 'fastify'
import { postSchema, postNotFoundSchema } from './schema'

export default async (fastify: FastifyInstance) => {
  fastify.addSchema(postSchema)
  fastify.addSchema(postNotFoundSchema)
  // shorthand route method will add later
}

Route Schemas

Route schemas are composed of request, response schemas, and extra property so that @fastify/swagger can automatically generate OpenAPI spec & Swagger UI!

Let's create route schemas based on our specifications:

// src/routes/posts/schema.ts

// Add route schemas right after request & respoonse schemas

/* Get */
export const getPostsSchema: FastifySchema = {
  // Routes with same tags will be grouped in Swagger UI
  tags: ['Posts'],
  description: 'Get posts',
  querystring: querystringSchema,
  response: {
    200: {
      // Return array of post
      ...replySchema
    }
  }
}

export const getOnePostSchema: FastifySchema = {
  tags: ['Posts'],
  description: 'Get a post by id',
  params: paramsSchema,
  response: {
    200: {
      ...replySchema
    },
    404: {
      description: 'The post was not found',
      // refer to postNotFound whenever a route use params
      $ref: 'postNotFound#'
    }
  }
}

/* Post */
export const postPostsSchema: FastifySchema = {
  tags: ['Posts'],
  description: 'Create a new post',
  body: postSchema,
  response: {
    201: {
      description: 'The post was created',
      // include a Location header that points to the URL of the new resource
      headers: {
        Location: {
          type: 'string',
          description: 'URL of the new resource'
        }
      },
      // Return newly created resource as the body of the response
      ...postSchema
    }
  }
}

/* Put */
export const putPostsSchema: FastifySchema = {
  tags: ['Posts'],
  description: 'Update a post',
  params: paramsSchema,
  body: postSchema,
  response: {
    204: {
      description: 'The post was updated',
      type: 'null'
    },
    404: {
      description: 'The post was not found',
      $ref: 'postNotFound#'
    }
  }
}

/* Delete */
export const deletePostsSchema: FastifySchema = {
  tags: ['Posts'],
  description: 'Delete a post',
  params: paramsSchema,
  response: {
    204: {
      description: 'The post was deleted',
      type: 'null'
    },
    404: {
      description: 'The post was not found',
      $ref: 'postNotFound#'
    }
  }
}

Now we have created schemas. Let's work on handler functions.

Handler Functions

The key in a separate handler.ts is the TYPE.

Since we no longer write the handler function in a fastify route method, we need to type the request and response explicitly.

// src/routes/posts/handler.ts

import { type RouteHandler } from 'fastify'
import {
  type Params,
  type Querystring,
  type Body,
  type Reply,
  type PostNotFound
} from './schema'
import { posts } from './posts'

/*
  We can easily type req & reply by assigning inferred types from schemas to
  Body, Querystring, Params, Headers, and Reply
  πŸ‘† properties of RouteGenericInterface
*/

export const getPostsHandler: RouteHandler<{
  Querystring: Querystring
  Reply: Reply
}> = async function (req, reply) {
  const { deleted } = req.query
  if (deleted !== undefined) {
    const filteredPosts = posts.filter((post) => post.deleted === deleted)
    reply.send({ posts: filteredPosts })
  } else reply.send({ posts })
}

export const getOnePostHandler: RouteHandler<{
  Params: Params
  Reply: Reply | PostNotFound
}> = async function (req, reply) {
  const { postid } = req.params
  const post = posts.find((p) => p.id == postid)
  if (post) reply.send({ posts: [post] })
  else reply.code(404).send({ error: 'Post not found' })
}

export const postPostsHandler: RouteHandler<{
  Body: Body
  Reply: Body
}> = async function (req, reply) {
  const newPostID = posts.length + 1
  const newPost = {
    id: newPostID,
    ...req.body
  }
  posts.push(newPost)
  console.log(posts)
  reply.code(201).header('Location', `/posts/${newPostID}`).send(newPost)
}

export const putPostsHandler: RouteHandler<{
  Params: Params
  Body: Body
  Reply: PostNotFound
}> = async function (req, reply) {
  const { postid } = req.params
  const post = posts.find((p) => p.id == postid)
  if (post) {
    post.title = req.body.title
    post.content = req.body.content
    post.tags = req.body.tags
    reply.code(204)
  } else {
    reply.code(404).send({ error: 'Post not found' })
  }
}

export const deletePostsHandler: RouteHandler<{
  Params: Params
  Reply: PostNotFound
}> = async function (req, reply) {
  const { postid } = req.params
  const post = posts.find((p) => p.id == postid)
  if (post) {
    post.deleted = true
    reply.code(204)
  } else {
    reply.code(404).send({ error: 'Post not found' })
  }
}

Fully typed req and reply can boost our productivity with real-time type checking and code completion in VS Code. πŸ₯³

types types types!

OK, let's finish the last part: fastify route method.

Fastify Route Method

Since we'd finished schema.ts and handler.ts, it's pretty easy to put them together:

// src/routes/posts/index.ts

import { type FastifyInstance } from 'fastify'
import {
  postSchema,
  postNotFoundSchema,
  getPostsSchema,
  getOnePostSchema,
  postPostsSchema,
  putPostsSchema,
  deletePostsSchema
} from './schema'
import {
  getPostsHandler,
  getOnePostHandler,
  postPostsHandler,
  putPostsHandler,
  deletePostsHandler
} from './handler'

export default async (fastify: FastifyInstance) => {
  // Add schema so they can be shared and referred
  fastify.addSchema(postSchema)
  fastify.addSchema(postNotFoundSchema)
  fastify.get('/', { schema: getPostsSchema }, getPostsHandler)
  fastify.get('/:postid', { schema: getOnePostSchema }, getOnePostHandler)
  fastify.post('/', { schema: postPostsSchema }, postPostsHandler)
  fastify.put('/:postid', { schema: putPostsSchema }, putPostsHandler)
  fastify.delete('/:postid', { schema: deletePostsSchema }, deletePostsHandler)
}

Now your folder structure should look like this:

folder structure

Swagger UI & OpenAPI Specification

Please check how to set up @fastify/swagger in my last post.

After you start the dev server, go to 127.0.0.1:3000/documentation and you'll see the Swagger UI:

swagger ui

URLDescription
'/documentation/json'The JSON object representing the API
'/documentation/yaml'The YAML object representing the API
'/documentation/'The swagger UI
'/documentation/*'External files that you may use in $ref

Test API Using Thunder Client

thunder client

Thunder Client is my go-to extension in VS Code for API testing.

I've exported the test suite to thunder-collection_CRUD demo.json. You can find it at my repo root folder and import it into your VS Code:

import test suite

Let's test our API:

test api

πŸ™Œ Wrapping up

Thank you for your reading!

In the 2nd part of the Better Backend DX series, we learned the goodness of using JSON Schema to validate routes and serialize outputs in Fastify.

By using json-schema-to-ts, we no longer need to type twice if we use TypeScript, and we also increase our productivity thanks to type checking and code completion in VS Code. Shorter feedback loop for the win! πŸ’ͺ

Since we'd declared route schemas, we can automatically generate Swagger UI & OpenAPI specifications by leveraging @fastify/swagger. Don't forget that good API documentation can improve your co-workers and end consumers' DX.

Kindly leave your thoughts below, and I'll see you in the next one. πŸ‘‹

Recommended reading about REST API:

  1. Design First or Code First: What’s the Best Approach to API Development?
  2. Best Practices for Designing a Pragmatic RESTful API
  3. Microsoft REST API Guidelines
Β