π 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:
Top comments (1)
Great tutorial!
Unfortunately, after declaring the route schemas, the documentation was not automatically generated.
I don't have any error message, I don't know what step I missed :-S