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

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

Β·

7 min read

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

Welcome to Part 2 of the Better Backend DX series.

This time we're going to build a REST API (yet another REST API tutorial πŸ˜…) using the starter template from the previous post: Better Backend DX: Fastify + ESBuild = ⚑️, and leveraging JSON Schema & TypeScript to:

  1. Validate routes & serialize outputs
  2. Enable VS Code IntelliSense for route's Request & Reply
  3. Automatically generate OpenAPI schemas and serve a Swagger UI
  4. Improve code reusability & testability
  5. Improve DX, for sure πŸ˜‰

Before we start, I like to share a tweet about DX that resonated with me:

This series might cover some, but it's great to keep in mind and review your DX metrics regularly.

What is JSON Schema?

JSON Schema is a vocabulary that allows you to annotate and validate JSON documents.

Here's an example, let's say we have a JSON object like this:

{
  "hello": "world"
}

We can ensure the value of "hello" is a string type by declaring a JSON Schema:

{
  "type": "object",
  "properties": {
    "hello": { "type": "string" }
  }
}

In Fastify, it's recommended to use JSON Schema to validate your routes and serialize your outputs.

Why do we validate routes?

Assume we're developing a blog post REST API. We want to get posts that haven't been deleted:

# General GET method
GET http://localhost:3000/posts HTTP/1.1

# Get method with a filter in the query string (/posts?deleted=[boolean])
GET http://localhost:3000/posts?deleted=false HTTP/1.1

It should return a JSON with an array of posts:

{
  "posts": [
    {
      "title": "...",
      ...
      "deleted": false
    },
    ...
  ]
}

In this case, query string deleted should only accept boolean, but what if the API user accidentally used string?

# The Wrong type of query string "deleted"
GET http://localhost:3000/posts?deleted=abcde HTTP/1.1

If we don't validate our route, the user may still receive a response, but it's a false-positive:

{
  "posts": []
}

As an API designer, we need to provide meaningful insights to the downstream consumer if schema validation fails for a request, like a proper error message:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "querystring.deleted should be boolean"
}

Why do we serialize outputs?

Fastify encourages users to use an output schema, as it can drastically increase throughput and help prevent accidental disclosure of sensitive information.

I recommend reading the Fastify documentation Validation and Serialization for more details.

How to use JSON Schema in Fastify?

Take the blog post API for instance:

// src/routes/posts.ts
import { FastifyInstance } from 'fastify'
import { posts } from './posts'

export default async (fastify: FastifyInstance) => {

  const querySchema = {
    type: 'object',
    properties: {
      deleted: { type: 'boolean' }
    }
  }
  /*
    fastify.get(path, [options], handler)
  */
  fastify.get(
    '/', // GET http://localhost:3000/posts HTTP/1.1
    {
      // Validate Query String Schema: /posts?deleted=[boolean]
      schema: {
        querystring: querySchema
      }
    },
    async function (req, reply) {
      /*
        req.query would look like this:
        {
          "deleted": true | false
        }
      */
     const { deleted } = req.query
      if (deleted !== undefined) {
        const filteredPosts = posts.filter((post) => post.deleted === deleted)
        reply.send({ posts: filteredPosts })
      } else reply.send({ posts }) // return all posts if no "deleted" query string
    }
  )
}

Fastify can now validate the querystring by passing schema to routes options.

But there is one problem if you're using TypeScript: VS Code would shout at you that deleted doesn't exist on type '{}':

type error

Emm 🧐, maybe just create a new type for req.query?

type postQuery = {
  deleted: boolean
}
...
// Works like a charm πŸ₯³
const { deleted } = req.query as postQuery

Yeah, it works. We declare a JSON Schema to validate routes and then declare a type for static type checking.

const querySchema = {
  type: 'object',
  properties: {
    deleted: { type: 'boolean' }
  }
}

type postQuery = {
  deleted: boolean
}

Both objects carry similar if not exactly the same information. This code duplication can annoy developers and introduce bugs if not properly maintained.

Nonono

Keep typing twice is just like forcing Uncle Roger to watch more of Jamie Oliver's video.

Stop typing twice πŸ™…β€β™‚οΈ

json-schema-to-ts comes to the rescue. πŸ’ͺ

It's a library that helps you infer TS types directly from JSON schemas:

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

const querySchema = {
  type: 'object',
  properties: {
    deleted: { type: 'boolean' }
  }
} as const

type postQuery = FromSchema<typeof querySchema>
// => Will infer the same type as above

Here are some benefits to use this library:

  • βœ… Schema validation: FromSchema raises TS errors on invalid schemas, based on DefinitelyTyped's definitions
  • ✨ No impact on compiled code: JSON-schema-to-ts only operates in type space. And after all, what's lighter than a dev-dependency?
  • 🍸 DRYness: Less code means less embarrassing typos
  • 🀝 Real-time consistency: See that string that you used instead of an enum? Or this additionalProperties you confused with additionalItems? Or forgot entirely? Well, JSON-schema-to-ts does!
  • and more!

Let's update the route:

// src/routes/posts.ts
import { FastifyInstance } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'
import { posts } from './posts'

export default async (fastify: FastifyInstance) => {

  const querySchema = {
    type: 'object',
    properties: {
      deleted: { type: 'boolean' }
    }
  } as const

  type postQuery = FromSchema<typeof querySchema>

  /*
    The shorthand route methods (i.e., .get) accept a generic object
    "RouteGenericInterface" containing five named properties:
    Body, Querystring, Params, Headers, and Reply.
    The interfaces Body, Querystring, Params, and Headers will be passed down
    through the route method into the route method handler request instance and
    the Reply interface to the reply instance.
  */

  fastify.get<{ Querystring: postQuery }>(
    '/',
    {
      schema: {
        querystring: querySchema
      }
    },
    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 })
    }
  )
}

hurray

Great! Now we can declare JSON Schemas to validate routes and infer types from them. JSON-schema-to-ts can raise TS error on invalid schemas and enable intelligent code completion of the request or reply instance in the handler function.

yes!

Most of these goodnesses happen during development so that we can catch bugs or problems as early as possible. The shorter feedback loop results in a much better DX.

API Documentation: Swagger UI

swagger

Swagger UI allows anyone β€” be it your development team or your end consumers β€” to visualize and interact with the API's resources without having any of the implementation logic in place.swagger ui

When it comes to DX, it's not just my and my co-workers' experience but also our end consumers' DX. API documentation and standard OpenAPI specification can help your user in many ways.

I'd recommend watching this webinar by Swagger: API Developer Experience: Why it Matters, and How Documenting Your API with Swagger Can Help

While documenting can be a tedious and time-consuming task, and that's why we need @fastify/swagger to generate docs automatically.

OpenAPI Documentation Generator

@fastify/swagger is a fastify plugin to serve a Swagger UI, using Swagger (OpenAPI v2) or OpenAPI v3 schemas automatically generated from your route schemas, or from an existing Swagger/OpenAPI schema.

In other words, we can easily generate the doc and serve a Swagger UI because we'd already had route schemas! Let's do it:

pnpm add @fastify/swagger

Configure the plugin (I'm using OpenAPI v3):

// src/plugins/swagger.ts
import fp from 'fastify-plugin'
import swagger, { FastifyDynamicSwaggerOptions } from '@fastify/swagger'

export default fp<FastifyDynamicSwaggerOptions>(async (fastify, opts) => {
  fastify.register(swagger, {
    openapi: {
      info: {
        title: 'Fastify REST API',
        description: 'Use JSON Schema & TypeScript for better DX',
        version: '0.1.0'
      },
      servers: [
        {
          URL: 'http://localhost'
        }
      ]
    }
    exposeRoute: true
  })
})

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

swagger ui

Fantastic! By adding @fastify/swagger, we don't need to hand-code API specifications in YAML or JSON. Simply declare route schemas, and the doc will be generated automatically.

That's the Vol.1 of JSON Schema + Typescript = ✨

OK. I know we haven't touched the real coding part πŸ˜…. They'll be in Vol. 2.

The goal of the Vol. 1 is to give you a taste of the goodness of JSON Schema and how to cope with always-shouting TS errors properly.

I'm going to cover these in the Vol. 2:

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

Kindly leave your comment and thoughts below!

See you in Vol. 2!

Β