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:
- Validate routes & serialize outputs
- Enable VS Code IntelliSense for route's Request & Reply
- Automatically generate OpenAPI schemas and serve a Swagger UI
- Improve code reusability & testability
- 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 '{}':
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.
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 anenum
? Or thisadditionalProperties
you confused withadditionalItems
? 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 })
}
)
}
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.
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 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.
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:
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:
- Improve code reusability & testability by separating
options
andhandler
of route method - Use of JSON Schema
$ref
keyword - Use Thunder Client (VS Code extension) to test APIs
Kindly leave your comment and thoughts below!
See you in Vol. 2!
Top comments (1)
Great!
I can't wait to see what happens next!