0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#181 Zod × Swaggerで作るAPIドキュメント構築例

0
Posted at

はじめに

最近、個人でAPI開発を行う際に swagger × zodスキーマ を使ってAPIのドキュメントを作ることが多いです。
そこで、最近良く使用する zod-to-openapi を使ったドキュメント構築例と、おまけで汎用的なバリデーションエラーの実装例を共有します。

使用技術

今回のサンプルプロジェクトで使用している主な技術スタックは以下の通りです。

カテゴリ 技術
言語 TypeScript
フレームワーク Express 5
バリデーション Zod
OpenAPI生成 @asteasolutions/zod-to-openapi
Swagger UI swagger-ui-express

プロジェクト構成

src/
├── config/           # アプリケーション設定
├── const/            # 定数定義
├── controller/       # コントローラー層
├── db/               # データベース接続・マイグレーション
├── schema/           # リクエスト/レスポンス定義
│   ├── example/      # Example用
│   └── share/        # 共通定義
├── error/            # カスタムエラークラス
├── middleware/       # ミドルウェア
├── model/            # ドメインモデル
├── repository/       # リポジトリ層
├── router/           # ルーティング
│   ├── path/         # APIルート定義
│   └── swagger/      # OpenAPIドキュメント定義
│       ├── path/     # 各APIのドキュメント
│       └── response/ # 共通レスポンスドキュメント
└── usecase/          # ユースケース層

セットアップ

パッケージのインストール

まず、必要なパッケージをインストールします。

# npm
npm install zod @asteasolutions/zod-to-openapi swagger-ui-express
npm install -D @types/swagger-ui-express

# pnpm
pnpm add zod @asteasolutions/zod-to-openapi swagger-ui-express
pnpm add -D @types/swagger-ui-express

Zodスキーマの定義

Schemaの作成

リクエスト/レスポンスのスキーマをZodで定義します。
extendZodWithOpenApi でZodを拡張することを忘れないようにします。
そして、 .openapi() メソッドでドキュメント情報を追加していきます。

// src/schema/example/createExampleRequestSchema.ts
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import z from "zod";

// Zodを拡張してOpenAPIメソッドを使えるようにする
extendZodWithOpenApi(z);

export const createExampleRequestSchema = z
  .object({
    name: z.string("名前は文字列で入力してください").min(1, "名前は必須です").openapi({
      description: "ユーザー名",
      example: "田中 雅也",
    }),
    nickname: z.string("ニックネームは文字列で入力してください").optional().openapi({
      description: "ニックネーム",
      example: "マサヤ",
    }),
    metadata: z
      .array(
        z.object({
          value: z.string("メタデータ値は文字列で入力してください").openapi({
            description: "メタデータ値",
            example: "meta-value",
          }),
        })
      )
      .optional()
      .openapi({
        description: "メタデータリスト",
      }),
  })
  .openapi("CreateExampleRequestSchema");  // スキーマ名を指定

// こうすると型も生成できます
export type CreateExampleRequestSchema = z.infer<typeof createExampleRequestSchema>;

.openapi() メソッドで descriptionexample を指定することで、Swagger UI上で見やすいドキュメントが生成されます。

OpenAPIドキュメントの生成

Generatorの作成

OpenAPIドキュメントを生成するためのジェネレーターを作成します。

// src/router/swagger/generator.ts
import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { appConfig } from "../../config/app.ts";

// スキーマとパスを登録するためのレジストリ
export const registry = new OpenAPIRegistry();

export const generateOpenApiDoc = () => {
  const generator = new OpenApiGeneratorV3(registry.definitions);

  return generator.generateDocument({
    openapi: "3.0.0",
    info: {
      version: "1.0.0",
      title: "API Documentation",
      description: "APIの仕様書です",
    },
    // アプリケーションのURLは環境変数で管理
    servers: [{ url: appConfig.APP_URL }],
  });
};

APIパスの登録

各APIエンドポイントのドキュメントを登録します。

// src/router/swagger/path/exampleDoc.ts
import { createExampleRequestSchema } from "../../../schema/example/createExampleRequestSchema.ts";
import { createExampleResponseSchema } from "../../../schema/example/createExampleResponseSchema.ts";
import { EXAMPLE_ROUTE } from "../../path/exampleRouter.ts";
import { registry } from "../generator.ts";
import { validationErrorResponseDoc } from "../response/validationErrorResponseDoc.ts";
import { internalErrorResponseDoc } from "../response/internalErrorResponseDoc.ts";

export const registerExampleDoc = () => {
  // スキーマを登録(Swagger UIの「Schemas」セクションに表示される)
  registry.register("CreateExampleRequestSchema", createExampleRequestSchema);

  // APIパスを登録
  registry.registerPath({
    method: "post",
    path: EXAMPLE_ROUTE,
    description: "Exampleを作成します",
    summary: "Example作成API",
    request: {
      body: {
        content: {
          "application/json": {
            schema: createExampleRequestSchema,
          },
        },
      },
    },
    responses: {
      201: {
        description: "正常に作成",
        content: {
          "application/json": {
            schema: createExampleResponseSchema,
          },
        },
      },
      // 共通エラーレスポンスをスプレッド構文で追加(後記)
      ...validationErrorResponseDoc,
      ...internalErrorResponseDoc,
    },
  });
};

共通エラーレスポンスの定義

各APIで共通して使用するエラーレスポンスは、その部分だけ別で定義することで再利用します。

// src/router/swagger/response/validationErrorResponseDoc.ts
import type { ResponseConfig } from "@asteasolutions/zod-to-openapi";
import type { ResponseObject } from "@asteasolutions/zod-to-openapi/dist/types.js";
import { HTTP_STATUS } from "../../../const/http.ts";
import { validationErrorResponseSchema } from "../../../schema/share/validationErrorResponseSchema.ts";

// バリデーションエラー用のドキュメント
export const validationErrorResponseDoc: Record<number, ResponseObject | ResponseConfig> = {
  [HTTP_STATUS.BAD_REQUEST]: {
    description: "入力内容が不正",
    content: {
      "application/json": {
        schema: validationErrorResponseSchema,
      },
    },
  },
};

このようにすることで、先ほど記述したように ...validationErrorResponseDoc として各ドキュメントで再利用が可能です。

Swagger UIの設定

Expressアプリケーションに Swagger UI を組み込みます。

// src/router/registerRouters.ts
import { serve, setup } from "swagger-ui-express";
import { generateOpenApiDoc } from "./swagger/generator.ts";
import { registerExampleDoc } from "./swagger/path/exampleDoc.ts";

export const registerRouters = (app: Express) => {
  app.use(express.json());

  // 本番環境以外でのみSwaggerを有効化
  if (appConfig.APP_ENV !== "prod") {
    // APIドキュメントを登録
    registerExampleDoc();

    // 後にAPIが増える場合にはルート毎に追加
    // 例:registerUserDoc();

    // OpenAPIドキュメントを生成してSwagger UIに渡す
    const document = generateOpenApiDoc();
    // OpenAPIドキュメント用のエンドポイント
    app.use("/docs", serve, setup(document));
  }

  // 以下、APIルートの設定...
  // 例:app.use("/examples", exampleRouter(ExampleController.build()));

  // 汎用レスポンス系ミドルウェア
  app.use(notFoundHandler);
  app.use(errorHandler);
};

このようにすることで、APIのルート定義とドキュメントの定義箇所が近くなるため登録漏れ等が防ぎやすく、個人的に気に入っています。
これで http://localhost:3000/docs にアクセスするとSwagger UIが表示されます。

おまけ:汎用的なバリデーションエラーの実装

Zodのバリデーションエラーを統一されたフォーマットでレスポンスする実装例です。

バリデーションエラーSchema

// src/schema/share/validationErrorResponseSchema.ts
import z from "zod";
import { ERROR_CODE } from "../../const/http.ts";
import { errorApiResponseSchema } from "./errorApiResponseSchema.ts";

export const invalidParamsSchema = z.object({
  name: z.string(),    // エラーが発生したフィールド名
  reason: z.string(),  // エラー理由
});

export const validationErrorResponseSchema = errorApiResponseSchema(
  z.object({
    code: z.literal(ERROR_CODE.VALIDATION),
    message: z.literal("入力内容が不正です"),
    invalidParams: z.array(invalidParamsSchema),
  })
);

// レスポンス生成用のファクトリ関数
export const createValidationErrorResponseSchema = (
  invalidParams: InvalidParamsSchema[]
): ValidationErrorResponseSchema => ({
  success: false,
  error: {
    code: ERROR_CODE.VALIDATION,
    message: "入力内容が不正です",
    invalidParams,
  },
});

export type InvalidParamsSchema = z.infer<typeof invalidParamsSchema>;
export type ValidationErrorResponseSchema = z.infer<typeof validationErrorResponseSchema>;

エラーハンドラーミドルウェア

// src/middleware/errorHandler.ts
import type { ErrorRequestHandler } from "express";
import { ZodError } from "zod";
import { HTTP_STATUS } from "../const/http.ts";
import {
  createValidationErrorResponseSchema,
  type invalidParamsSchema,
} from "../schema/share/validationErrorResponseSchema.ts";

export const errorHandler: ErrorRequestHandler = (err, _, res, __) => {
  // ZodErrorをキャッチして統一フォーマットに変換
  if (err instanceof ZodError) {
    const invalidParams: invalidParamsSchema[] = err.issues.map((issue) => ({
      name: issue.path[issue.path.length - 1]?.toString() ?? "unknown",
      reason: issue.message,
    }));

    const response = createValidationErrorResponseSchema(invalidParams);
    return res.status(HTTP_STATUS.BAD_REQUEST).json(response);
  }

  // その他のエラー処理...
};

この実装により、バリデーションエラー時のレスポンスは以下のような統一されたフォーマットになります。

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "入力内容が不正です",
    "invalidParams": [
      { "name": "name", "reason": "名前は必須です" },
      { "name": "nickname", "reason": "ニックネームは文字列で入力してください" }
    ]
  }
}

終わりに

個人的にAPIのルート定義とSwaggerの設定・登録などを、どういった構成で行うかというのが難しい部分でしたが責務分離を大きく損なうことなく実装出来たのではないかなと思います。
運用していくと細かな課題などが出てくるかもしれませんが、これから使ってみようという方の参考になれれば幸いです。
ここまで読んでいただきありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?