Michi's Tech Blog

一人前のWebエンジニアを目指して

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.jsonscriptsのセクションを更新します。

{
  "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"
  }
}

tsndts-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の画面が表示されます。

ここから、各エンドポイントのインターフェースの確認や、実際にリクエストを送信してテストすることが可能です。