Fastify × Zod × SwaggerでOpenAPIの仕様書を自動生成する
こんにちは!
スマレジ テックファームのMichiです!
今回はFastifyでREST APIを開発するときに、自分でYAMLファイルを定義せずとも、自動でOpenAPI形式の仕様書を生成する方法を解説します。
技術スタック
今回、主に使う技術は次の3つです。
- Fastify ... 高速なレスポンスを誇るNode.jsのWebフレームワーク
- Zod ... TypeScriptのスキーマ・バリデーション定義のライブラリ。今回はFastifyの拡張ライブラリであるfastify-zodと合わせて使用。
- Swagger ... API開発の支援ツールで、OpenAPI形式の仕様書を出力できる。生成された仕様書から、実際にリクエストを送ってテストすることもでき、Postmanような使い方が可能。
今回使用した環境・バージョンは以下の通りです。
node: v18.16.1 typescript: 5.3.3 fastify: 4.24.3 zod: 3.22.4 fastify-zod": 1.4.0 fastify/swagger: 8.12.1 fastify/swagger-ui: 2.0.1 @types/node: 20.10.4 ts-node-dev: 2.0.0
実装
プロジェクの作成
以下のコマンドで必要なパッケージのインストールとtsconfig.json
ファイルの作成を行います。
npm init -y npm i @fastify/swagger @fastify/swagger-ui fastify fastify-zod zod npm i -D @types/node ts-node-dev typescript npx tsc --init
package.json
のscripts
のセクションを更新します。
{ "name": "fastify-sample", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "dev": "tsnd --respawn --transpile-only --exit-child ./src/bootstrap.ts" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@fastify/swagger": "^8.12.1", "@fastify/swagger-ui": "^2.0.1", "fastify": "^4.24.3", "fastify-zod": "^1.4.0", "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^20.10.4", "ts-node-dev": "^2.0.0", "typescript": "^5.3.3" } }
tsnd
はts-node-dev
のエイリアスです。よって、tsnd --respawn --transpile-only --exit-child ./src/bootstrap.ts
は./src/bootstrap.ts
を実行するコマンドになります。(このファイルは後ほど作成)
エンドポイントの作成
先にエンドポイントの実装から行います。
今回は簡単なメモアプリのAPIを作成することを想定します。./src/app/note
というディレクトリを切り、その中で次のファイルをそれぞれ作成します。
schema.ts
メモ取得と作成のスキーマ定義です。
import { z } from "zod"; export const noteSchema = z.object({ title: z.string().min(1).max(20).describe("件名"), content: z.string().min(1).max(100).describe("内容"), status: z.enum(["todo", "doing", "done", "cancel"]).describe("ステータス"), }); export const postResultSchema = z.object({ isSuccess: z.boolean(), message: z.string(), }); export const searchNoteQuerySchema = noteSchema .omit({ content: true }) .partial(); export const noteListSchema = z.array(noteSchema); export type GetNoteRequest = z.infer<typeof searchNoteQuerySchema>; export type GetNoteResponse = z.infer<typeof noteListSchema>; export type CreateNoeRequest = z.infer<typeof noteSchema>; export type CreateNoteResponse = z.infer<typeof postResultSchema>; export const NoteSchemas = { searchNoteQuerySchema, noteListSchema, noteSchema, postResultSchema, };
controller.ts
GETとPOSTの処理を実装します。
今回はDBを作っていないので、data
という配列の中にメモデータを格納します。
import { FastifyRequest, FastifyReply } from "fastify"; import { GetNoteRequest, CreateNoeRequest, GetNoteResponse } from "./schema"; const data: GetNoteResponse = []; export const getNoteHandler = async ( request: FastifyRequest<{ Querystring: GetNoteRequest }>, reply: FastifyReply ) => { const { title, status } = request.query; const note = data.filter((d) => { if (title && status) { return d.title === title && d.status === status; } else if (title) { return d.title === title; } else if (status) { return d.status === status; } else { return true; } }); reply.code(200).send(note); }; export const createNoteHandler = async ( request: FastifyRequest<{ Body: CreateNoeRequest }>, reply: FastifyReply ) => { data.push(request.body); reply .code(200) .send({ isSuccess: true, message: "メモの登録が完了しました。" }); };
route.ts
ルーティングを登録します。
今はまだ、Fastifyの中でZodを使えないので型エラーが発生します。これは後ほど解消します。
import { FastifyInstance } from "fastify"; import { getNoteHandler, createNoteHandler } from "./controller"; export const createNoteRoutes = async (server: FastifyInstance) => { server.zod.get( "/note", { operationId: "getNote", querystring: "searchNoteQuerySchema", response: { 200: { description: "メモ取得成功", key: "noteListSchema", }, }, tags: ["Note"], }, getNoteHandler ); server.zod.post( "/note", { operationId: "createNote", body: "noteSchema", response: { 200: { description: "メモ作成成功", key: "postResultSchema", }, }, tags: ["Note"], }, createNoteHandler ); };
index.ts
export用にindex.ts
ファイルを作成します。
export * from './route'; export * from './schema';
基盤の作成
先ほどでnote
のエンドポイントは完成しましたが、Fastify側の基盤の作成をしていないのでまだ動きません。この項で、基盤の作成、及びZod・Swaggerとの連携をやっていきます。
.src/app/patch.ts
API全体に適用するスキーマを登録します。今回は先ほど作成したNoteSchemas
のみになります。
import { NoteSchemas } from "./note"; export const patchSchemas = { ...NoteSchemas, };
.src/app/register.ts
API全体に適用するルーティングを登録します。今回は先ほど作成したcreateNoteRoutes
のみになります。
import { FastifyInstance } from "fastify"; import { createNoteRoutes } from "./note"; export const registerRoutes = async (server: FastifyInstance) => { await server.register(createNoteRoutes); };
.src/app/index.ts
export用にindex.ts
ファイルを作成します。
export * from './patch'; export * from './register';
./src/bootsstrap.ts
npm run dev
したときに最初に起動するファイルです。
ここで、先ほど登録したスキーマとルーティングをFastifyInstance
に紐づけます。
また、Fastifyの中でZodのスキーマ定義を使用するには、FastifyInstance
の型をFastifyZod
で拡張してやる必要があります(7~11行目)。これで、src/app/note/route.ts
の型エラーが解消されます。
import Fastify, { FastifyInstance } from "fastify"; import { FastifyZod, buildJsonSchemas, register } from "fastify-zod"; import { patchSchemas, registerRoutes } from "./app"; export const server: FastifyInstance = Fastify({ logger: true }); declare module "fastify" { interface FastifyInstance { readonly zod: FastifyZod<typeof patchSchemas>; } } const swaggerSetting = { swaggerOptions: { openapi: { info: { title: "API docs", version: "1.0.0", }, }, }, swaggerUiOptions: { routePrefix: "/api-docs", staticCSP: true, }, }; const bootstrap = async () => { await register(server, { jsonSchemas: buildJsonSchemas(patchSchemas), ...swaggerSetting, }); await registerRoutes(server); try { await server.listen({ port: 8080, }); console.log(`Server listening on localhost: 8080`); } catch (err) { server.log.error(err); process.exit(1); } }; bootstrap();
完成形
最終的にはこのようなディレクトリ構成になります。
. ├── node_modules ├── src │ ├── app │ │ ├── note │ │ │ ├── controller.ts │ │ │ ├── index.ts │ │ │ ├── route.ts │ │ │ └── schema.ts │ │ ├── index.ts │ │ ├── patch.ts │ │ └── register.ts │ └── bootstrap.ts ├── package.lock.json ├── package.json └── tscofig.json
確認
npm run dev
でサーバーを立ち上げます。
localhost:8080/api-docs
をブラウザで開くと、Swaggerの画面が表示されます。
ここから、各エンドポイントのインターフェースの確認や、実際にリクエストを送信してテストすることが可能です。