Fastifyで作るAPIにZodでスキーマバリデーションをかけながら型定義と実用レベルのOpenAPI仕様を自動生成する

REST APIを作成する際、OpenAPI仕様のYAMLを手書きするのは面倒ですよね。Zodで定義したバリデーションスキーマから、型定義やOpenAPI仕様を業務で使えるレベルで出力する方法を探ってみました。
2022.08.18

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。CX事業本部Delivery部MADグループのきんじょーです。

最近Fastifyをよく触っています。 Expressのように軽量フレームワークですが、デフォルトでasync/awaitによる非同期処理をサポートしており、プラグイン形式でさまざまな機能をエコシステムから追加できる方式がとても使いやすい印象です。

FastifyでREST APIを開発する際に、Zodで定義したバリデーションスキーマを正として、それを元に型とOpenAPI仕様書と生成する方法が、開発スピードやフロントエンドとのI/Fについてのコミュニケーション面で最適だと思いこの記事でご紹介します。

コードからOpenAPI仕様を生成するサンプルコードは割と目にすることがありますが、今回は、実用に耐えうるレベルでリッチに記述することを目指してOpenAPIを生成します。

Fastifyのバリデーションについて

公式で用意されている方法

公式ドキュメント(Validation and Serialization)によると、Fastifyはスキーマベースのアプローチを採用しており、JSONスキーマを使用することを推奨しています。

JSONスキーマで定義したスキーマは、デフォルトだとAjvで検証されますが、他のバリデーションライブラリを使用する場合、FastifyインスタンスのsetValidatorCompilerメソッドで、独自のバリデーションライブラリに置き換えることができます。

Zodを使用する場合

Zodは以下のような特徴を掲げている、TypeScriptファーストのスキーマバリデーションライブラリです。

  • 依存関係が0
  • Node.jsと全てのモダンブラウザで動作
  • minifyしてzip化してサイズが8kb
  • イミュータブルで副作用がないメソッド
  • 簡潔で連鎖可能なインタフェース
  • 関数型アプローチ: Parse, don’t validate.
  • プレーンなJavaScriptでも使用可能

Zodで定義したスキーマから、このライブラリ単体でTypeScriptの型生成や、JSONスキーマやOpenAPIも出力することが可能です。

先ほど記述したsetValidatorCompilerを使用することで、バリデーションライブラリをAjvからZodに置き換えてFastifyを動かすことも可能でしたが、あまりカスタマイズしたくないので、ZodからJSONスキーマを出力してAjvでバリデーションする方式をとります。

FastifyからOpenAPI仕様書の出力

OpenAPIはZodから直接吐き出すことも可能です。しかし、今回はFastifyで立ち上げるAPIのエンドポイントでSwagger-UIが表示できるようにしたいので、@fastify/swaggerのプラグインを使用します。

Zodから吐き出したJSONスキーマをFastifyインスタンスのスキーマに登録し、上記プラグインでOpenAPI 3.0.1の定義を出力して、/docsなど指定したパスでSwagger-UIを参照できるようになります。

バリデーション -> 型生成 -> OpenAPI仕様生成の全体構成

というわけで、図にすると以下のようにZodのバリデーションスキーマを正として型とOpenAPIを生成します。

schema-generation-chart

やってみた

完全なコードはこちらのリポジトリに格納してあります。サクッとお手元で試したい方はcloneしてみてください。

少し長い記事なので、先に完成したSwagger-UIをこちらのリンクに置いておきました。

前提条件

以下の環境、バージョンで検証しています。

macOS Big Sur 11.5.1
node v18.12.1
typescript 4.8.4
fastify 4.9.2
@fastify/swagger 8.1.0
@fastify/swagger-ui 1.2.0
zod 3.19.1

※ 2022/11/09追記 @fastify/swaggerのv8から、swagger-uiの表示に@fastify/swagger-uiが必要になったとのご連絡をいただき修正しました。PullRequestの作成ありがとうございました?‍♂️

プロジェクトの作成

まずはプロジェクトを作成し、最小構成のFastifyサーバーを立ち上げるために必要なライブラリを追加します。

$ npm init
$ npm install @fastify/swagger @fastify/swagger-ui fastify fastify-zod uuid zod
$ npm install -D @types/node jest ts-node-dev typescript @types/uuid
$ npx tsc --init

npm run devでts-node-devが走るようにします。

package.json

{
  "name": "fastify-zod-swagger",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "tsnd --respawn --transpile-only --exit-child src/app.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@fastify/swagger": "^8.1.0",
    "@fastify/swagger-ui": "^1.2.0",
    "fastify": "^4.9.2",
    "fastify-zod": "^1.2.0",
    "uuid": "^9.0.0",
    "zod": "^3.19.1"
  },
  "devDependencies": {
    "@types/node": "^18.11.9",
    "@types/uuid": "^8.3.4",
    "jest": "^29.3.0",
    "ts-node-dev": "^2.0.0",
    "typescript": "^4.8.4"
  }
}

最小限のFastifyのコードを書いていきます。

src/app.ts

import fastify from "fastify";

export const server = fastify({
  logger: true,
});

// ルートの宣言
server.get('/', async (request, reply) => {
  return { hello: 'world' }
})

// Fastifyサーバーの起動
const main = async () => {
  try {
    await server.listen({ port: 3000, host: "0.0.0.0" });
    console.log("Server listining on port 3000");
  } catch (error) {
    console.error(error);
    process.exit(1);
  }
}
main()

サーバーを起動し、localhost:3000で応答が返ってきました。

$ npm run dev
[INFO] 14:57:22 ts-node-dev ver. 2.0.0 (using ts-node ver. 10.9.1, typescript ver. 4.7.4)
{"level":30,"time":1660802242996,"pid":14341,"hostname":"HL00935.local","msg":"Server listening at http://0.0.0.0:3000"}
Server listining on port 3000

$ curl http://localhost:3000
{"hello":"world"}

バリデーションスキーマの定義

とある製品の登録と取得APIという設定で、productというディレクトリを切り、以下のようにZodでバリデーションスキーマを定義していきます。

src/modules/product/product.schema.ts

import { z } from "zod";
import { buildJsonSchemas } from "fastify-zod";

export const ProductType = {
  book: "book",
  movie: "movie",
  game: "game",
} as const;

const productInput = {
  title: z.string().min(3).max(50).describe("製品登録名"),
  price: z.number().max(900000).default(100).describe("価格"),
  content: z.nullable(z.string()).optional().describe("内容"),
  type: z.nativeEnum(ProductType).describe("製品種別"),
  salesStartsAt: z.date().describe("販売開始日"),
  salesEndsAt: z.date().describe("販売終了日"),
};

const productGenerated = {
  id: z.string().uuid().describe("製品ID"),
  createdAt: z.date().describe("作成日"),
  updatedAt: z.date().describe("更新日"),
};

// Zodスキーマを定義します
const createProductBodySchema = z.object({
  ...productInput,
});

const productResponseSchema = z
  .object({
    ...productInput,
    ...productGenerated,
  })
  .describe("Product response schema");

const getProductParamsSchema = z.object({
  id: productGenerated.id,
});
const getProductQuerySchema = z.object({
  title: z.optional(productInput.title),
  type: z.optional(z.array(productInput.type)),
});

// Zodスキーマから型を生成します
export type CreateProductInput = z.infer<typeof createProductBodySchema>;
export type ProductOutput = z.infer<typeof productResponseSchema>;
export type GetProductParamsInput = z.infer<typeof getProductParamsSchema>;
export type GetProductQueryInput = z.infer<typeof getProductQuerySchema>;

// Zodスキーマを元に、JSONスキーマを生成します
export const { schemas: productSchemas, $ref } = buildJsonSchemas(
  {
    createProductBodySchema,
    productResponseSchema,
    getProductParamsSchema,
    getProductQuerySchema,
  },
  {
    $id: "productSchemas",
  }
);

ハイライト部分をご確認ください。
Zodスキーマの各バリデーターについて詳しい説明は割愛しますが、直感的に記述できることが見て取れると思います。
TypeScriptの型もz.inferのみで簡単に生成できます。

ここで定義した、HTTPリクエストのヘッダー、ボディ、パス、クエリパラメーター毎のZodスキーマから、型とJSONスキーマを生成し、コントローラー以降やfastifyインスタンスで使用していきます。

コントローラーの定義

あまり本筋と関係ありませんが、先ほどのZodスキーマから生成した型を使用して、コントローラーもそれらしいコードを別ファイルに定義していきます。

src/modules/product/product.controller.ts

import { v4 as uuidv4 } from "uuid";
import { FastifyReply, FastifyRequest } from "fastify";
import {
  CreateProductInput,
  GetProductParamsInput,
  ProductType,
} from "./product.schema";

export const createProductHandler = async (
  request: FastifyRequest<{ Body: CreateProductInput }>,
  reply: FastifyReply
) => {
  // 登録時に生成する項目を生成
  const id = uuidv4();
  const createdAt = new Date();
  const updatedAt = new Date();

  // 実際はここで登録処理を行う
  console.log("Product created.");

  reply.code(201).send({
    id,
    title: request.body.title,
    price: request.body.price,
    content: request.body.content,
    type: request.body.type,
    salesStartsAt: request.body.salesStartsAt,
    salesEndsAt: request.body.salesEndsAt,
    createdAt,
    updatedAt,
  });
};

export const getProductHandler = async (
  request: FastifyRequest<{ Params: GetProductParamsInput }>,
  reply: FastifyReply
) => {
  const id = request.params.id;

  // リポジトリからの取得処理を行う
  console.log(`Fetching product( ${id} )...`);

  reply.code(200).send({
    id,
    title: "super product",
    price: 1000,
    content: "some content",
    type: ProductType.game,
    salesStartsAt: new Date(),
    salesEndsAt: new Date(),
    createdAt: new Date(),
    updatedAt: new Date(),
  });
};

ルーティングの定義

JSONスキーマとハンドラーができたので、先ほどはapp.tsの中で定義していたルートも別ファイルに切り出します。

src/modules/product/product.route.ts

import { FastifyInstance } from "fastify";
import { createProductHandler, getProductHandler } from "./product.controller";
import { $ref } from "./product.schema";

const productRoutes = async (server: FastifyInstance) => {
  server.post("/", {
    schema: {
      body: $ref("createProductBodySchema"),
      response: {
        201: { ...$ref("productResponseSchema"), description: "登録完了" },
      },
      tags: ["Product"],
    },
    handler: createProductHandler,
  });

  server.get("/:id", {
    schema: {
      params: $ref("getProductParamsSchema"),
      querystring: $ref("getProductQuerySchema"),
      response: {
        200: {
          ...$ref("productResponseSchema"),
          description: "取得成功",
        },
      },
      tags: ["Product"],
    },
    handler: getProductHandler,
  });
};

export default productRoutes;

ここではFastifyインスタンスに登録するスキーマへの参照($ref)のみを設定します。

エントリポイントの修正

作成したスキーマやルーティングを使用してapp.tsを修正していきます。

src/app.ts

import fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUI from "@fastify/swagger-ui";
import fs from "fs";
import { withRefResolver } from "fastify-zod";
import { productSchemas } from "./modules/product/product.schema";
import productRoutes from "./modules/product/product.route";

const server = fastify({
  logger: true
});

const main = async () => {
  for (const schema of productSchemas) {
    server.addSchema(schema);
  }

  server.register(
    swagger,
    withRefResolver({
      openapi: {
        info: {
          title: "Sample API using Fastify and Zod.",
          description:
            "ZodのバリデーションスキーマからリッチなOpenAPI仕様を出力するサンプル",
          version: "1.0.0",
        },
      },
    })
  );

  server.register(swaggerUI, {
    routePrefix: "/docs",
    staticCSP: true,
  })

  server.register(productRoutes, { prefix: "/products" });

  try {
    await server.listen({ port: 3000, host: "0.0.0.0" });
    console.log("Server listining on port 3000");
  } catch (error) {
    console.error(error);
    process.exit(1);
  }

  const responseYaml = await server.inject("/docs/yaml");
  fs.writeFileSync("docs/openapi.yaml", responseYaml.payload);
};

main();

Zodスキーマから出力したJSONスキーマをFastifyインスタンスへ登録します。ここで、登録したスキーマが、先ほどルーティングのプラグインで設定した$refから参照されます。

Swagger-UIを確認

`http://localhost:3000/docs`をブラウザで開いてSwagger-uiを確認します。

POST /products

Zodスキーマで定義したバリデーションを元に、OpenAPIのスキーマが生成されました?

swagger-ui-from-zod-schema1

GET /products/:id

パスやクエリパラメーターのスキーマも同じく生成されています。
const assertionで定義した「type」のenumも、OpenAPIの定義に反映しています?

swagger-from-zod-get

OpenAPIにExampleを追加する

Zodスキーマは正しくOpenAPI仕様に反映できましたが、実用レベルで考えると、Example Valueまで意図した値を設定したいところです。

open-api-example-section

Zodやfastify-zodにはそのようなAPIは用意されていないため、出力したJSONスキーマに、手動でexample追加して実装していきます。

型定義の拡張

exampleを差し込むため、fastify-zodJsonSchemaの型を拡張します。

types/fastify-zod/index.d.ts

import { JsonSchema } from "fastify-zod";

declare module "fastify-zod" {
  type MyJsonSchema = JsonSchema & {
    properties?: {
      [key: string]: {
        type: string;
        properties: {
          [key: string]: object;
        };
        example: object;
      };
    };
  };
}

スキーマ定義ファイルにExampleの追加

各スキーマに対応するExampleの情報を追加して、スキーマと紐付けを行います。

src/modules/product/product.schema.ts

import { z } from "zod";
import { buildJsonSchemas } from "fastify-zod";
import { bindExamples } from "../../utils/openApiSpec";

/*
 * 〜中略〜
*/

// Exampleを定義します
const exampleProduct: ProductOutput = {
  title: "何らかの製品A",
  price: 10_000,
  content: "素晴らしい製品です",
  type: "game",
  salesStartsAt: new Date("2022-01-01"),
  salesEndsAt: new Date("2022-12-31"),
  id: "c165ad23-2ac3-4d80-80d7-d7b6c3e526bd",
  createdAt: new Date("2022-06-01"),
  updatedAt: new Date("2022-06-01"),
};
const createProductBodySchemaExample: CreateProductInput = {
  title: exampleProduct.title,
  price: exampleProduct.price,
  content: exampleProduct.content,
  type: exampleProduct.type,
  salesStartsAt: exampleProduct.salesStartsAt,
  salesEndsAt: exampleProduct.salesEndsAt,
};
const productResponseSchemaExample: ProductOutput = { ...exampleProduct };
const getProductParamsSchemaExample: GetProductParamsInput = {
  id: exampleProduct.id,
};
const getProductQuerySchemaExample: GetProductQueryInput = {
  title: exampleProduct.title,
  type: [exampleProduct.type],
};
const schemaExamples = {
  createProductBodySchemaExample,
  productResponseSchemaExample,
  getProductParamsSchemaExample,
  getProductQuerySchemaExample,
};

// Zodスキーマを元に、JSONスキーマを生成します
export const { schemas: productSchemas, $ref } = buildJsonSchemas(
  {
    createProductBodySchema,
    productResponseSchema,
    getProductParamsSchema,
    getProductQuerySchema,
  },
  {
    $id: "productSchemas",
  }
);

// 定義したExampleをJSONスキーマに紐付けます
bindExamples(productSchemas, schemaExamples);

最後のbindExamplesはJSONスキーマにexampleを差し込むために定義したヘルパー関数です。

src/utils/openApiSpec.ts

import { MyJsonSchema } from "fastify-zod";

export const bindExamples = (
  schemas: MyJsonSchema[],
  examples: { [id: string]: object }
) => {
  if (!schemas || schemas.length === 0) return;

  const properties = schemas[0].properties;

  for (const $id in properties) {
    const property = properties[$id];
    const example = examples[`${$id}Example`];

    property.example = example;
  }
};

Avjのバリデーションルールを変更

このままではAvjがexampleという不明なキーワードでエラーを吐くため、起動時の設定でログ出力のみにします。

src/app.ts

const server = fastify({
  logger: true,
  ajv: {
    customOptions: {
      strict: "log",
      keywords: ["example"],
    },
  },
});

ディレクトリ構成

最終的に以下のような構成になりました。

ROOT
├── node_modules
├── docs
│   └── openapi.yaml
├── src
│   ├── app.ts
│   ├── modules
│   │   └── product
│   │       ├── product.controller.ts
│   │       ├── product.route.ts
│   │       └── product.schema.ts
│   ├── types
│   │   └── fastify-zod
│   │       └── index.d.ts
│   └── utils
│       └── openApiSpec.ts
├── package.json
├── package-lock.json
└── tsconfig.json

Swagger-UIを確認

少し苦労しましたが、設定したExampleが表示されるようになりました?

example-in-swagger-ui

いい感じに出力できました

これまで、コードからスキーマを自動生成したりその逆を行う場合、提供されている機能が要件にマッチしていれば非常に便利ですが、少し外れたことをしようとすると一気に辛くなる経験がありました。

今回Zodで定義したスキーマを元に、OpenAPIを手書きするレベルまでリッチに記述することを目指して検証してみたところ、Exampleを差し込むのに少し手こずりましたが、概ね提供されている機能をもとに実装ができ、非常に満足でした。

プロジェクトを立ち上げる際、型生成やドキュメント作成の仕組みやルールは、初めに決めておかないと後から変更が難しい部分です。 自動化できる部分はなるべく自動化して、楽をしていきたいですね。

最後まで読んでいただきありがとうございました。
以上、MADグループのきんじょーでした。