Better Backend DX: JSON Schema + TypeScript + Swagger = ✨ Vol. 2
Tags: webdev, typescript, node, fastify
Published: 2022-05-20 16:18:14

👋 Hi! I'm David Peng. You can find me on Twitter: [@davipon](https://twitter.com/davipon).

This post is Vol. 2 of the [Better Backend DX: JSON Schema + TypeScript + Swagger = ✨](https://dev.to/davipon/better-backend-dx-json-schema-typescript-swagger-vol-1-2kpf), 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:

```js
// 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.

```js
// src/routes/examples/schema.ts
export const schema = {
  querystring: {
    name: { type: 'string' },
    excitement: { type: 'integer' }
  },
  response: {
    200: {
      type: 'object',
      properties: {
        hello: { type: 'string' }
      }
    }
  }
}
```

```js
// src/routes/examples/handler.ts
export const handler = function (request, reply) {
  reply.send({ hello: 'world' })
}
```

```js
// 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](https://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api#useful-post-responses)

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.

```js
// 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.

```ts
// 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:

```ts
// 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.

```ts
// 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:

```ts
// 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.

```ts
// 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!](https://drive.google.com/uc?id=1P0EqIcjkc97d8mLt60KFKNxcB7ARzHIs)

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:

```ts
// 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](https://drive.google.com/uc?id=1wZfL_Q9Rc9gHahCncPe9GN9xAqlK65R_)

## Swagger UI & OpenAPI Specification

> Please check how to set up `@fastify/swagger` in my [last post](https://davipon.hashnode.dev/better-backend-dx-json-schema-typescript-swagger-vol-1).

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

![swagger ui](https://drive.google.com/uc?id=1ax6_i16KmAzCoG33jG3-FVJX-XO0uFbD)

| URL                   | Description                             |
| :-------------------- | :-------------------------------------- |
| '/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](https://drive.google.com/uc?id=103sElGokmQpBJ20HGb62tgS0deMLA9jY)

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](github.com/davipon/fastify-esbuild) root folder and import it into your VS Code:

![import test suite](https://drive.google.com/uc?id=1ceyImRwD6Qo_ZynhePiOew3ONIHOU0-a)

Let's test our API:

![test api](https://drive.google.com/uc?id=10E6Se0wlratWsE6eh25wfQFiYMquMi_e)

## 🙌 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?](https://swagger.io/blog/api-design/design-first-or-code-first-api-development/)
2. [Best Practices for Designing a Pragmatic RESTful API](https://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api#useful-post-responses)
3. [Microsoft REST API Guidelines](https://github.com/Microsoft/api-guidelines/blob/master/Guidelines.md#microsoft-rest-api-guidelines)