π 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:
- Improve code readability & testability by separating options and handler of route method
- Use of JSON Schema
$ref
keyword - Swagger UI and OpenAPI specification
- 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. π₯³
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:
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:
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 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:
Let's test our 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: