Top View


Author Daiki Urata

Hono + Zod OpenAPIでバリデーション処理を書いてそのままOpenAPIスキーマを生成する

2024/05/10

はじめに

前回の記事ではHonoにPrismaを導入してCloudflare D1と接続して簡単なAPIを作成しました。

今回はこれに続きZod OpenAPIを利用して、リクエストのバリデーション処理を実装し、それをそのままOpenAPIスキーマとして生成、Swagger UIで表示させるところまでやってみます。

環境

  • Hono v4.3.4
  • Zod OpenAPI v0.11.0

Zod OpenAPIのインストール

まずはパッケージのインストールから始めます。

pnpm add zod @hono/zod-openapi

Zodスキーマ

次にZodスキーマを書いて、リクエスト値やレスポンス値を定義します。

schema.tsというファイルを作成しました。

import { z } from "@hono/zod-openapi";

export const ErrorSchema = z.object({
  code: z.number().openapi({
    example: 400,
  }),
  message: z.string().openapi({
    example: "Bad Request",
  }),
});

export const PrefectureParamSchema = z.object({
  prefectureId: z
    .string()
    .min(2)
    .max(2)
    .openapi({
      param: {
        name: "prefectureId",
        in: "path",
      },
      example: "01",
    }),
});

export const PrefectureSchema = z
  .object({
    name: z.string(),
    kana: z.string(),
    prefectureId: z.string(),
  })
  .openapi("Prefecture");

export const PrefecturesSchema = z.array(PrefectureSchema);

  • ErrorSchema: エラーレスポンス
  • PrefectureParamSchema: GET /prefecture/{prefectureId}のリクエストバリデーション
  • PrefectureSchema: GET /prefecture/{prefectureId}のレスポンス
  • PrefecturesSchema: GET /prefecturesのレスポンス

今回2つのAPIを用意するので上記のようなスキーマになりました。

ルーティング

今回は都道府県一覧と単体の情報を取得できる2つのAPIを用意するので、そのルーティングを定義します。

ここでは先ほど書いたZodのスキーマとパスを繋ぎ合わせて、かつOpenAPIスキーマの生成に必要な情報などを定義します。

route.tsというファイルを作成しました。

import { createRoute } from "@hono/zod-openapi";
import {
  ErrorSchema,
  PrefectureParamSchema,
  PrefectureSchema,
  PrefecturesSchema,
} from "./schema";

export const prefectureRoute = createRoute({
  method: "get",
  path: "/prefectures/{prefectureId}",
  request: {
    params: PrefectureParamSchema,
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: PrefectureSchema,
        },
      },
      description: "Returns a single prefecture by ID",
    },
    400: {
      content: {
        "application/json": {
          schema: ErrorSchema,
        },
      },
      description: "Bad Request",
    },
    404: {
      content: {
        "application/json": {
          schema: ErrorSchema,
        },
      },
      description: "Not Found",
    },
  },
});

export const prefecturesRoute = createRoute({
  method: "get",
  path: "/prefectures",
  request: {},
  responses: {
    200: {
      content: {
        "application/json": {
          schema: PrefecturesSchema,
        },
      },
      description: "Returns a list of prefectures",
    },
  },
});

  • prefectureRoute: GET /prefecture/{prefectureId}のルーティング。 リクエストでprefectureIdのバリデーションエラーの場合400を返して、DBからレコード取得時に見つからなかった場合は404を返すような定義にしています
  • prefecturesRoute: GET /prefecturesのルーティング

API処理

最後は実際に受け取ったリクエストをもとにDBからレコードを取得して、レスポンスを返すという処理を書きます。

前回Prisma関連の設定を説明したので今回は省きます。

import { PrismaClient } from "@prisma/client";
import { PrismaD1 } from "@prisma/adapter-d1";
import { OpenAPIHono } from "@hono/zod-openapi";
import { prefectureRoute, prefecturesRoute } from "./route";

type Bindings = {
  DB: D1Database;
};

const app = new OpenAPIHono<{ Bindings: Bindings }>();

const createClient = async (db: D1Database) => {
  const adapter = new PrismaD1(db);
  const prisma = new PrismaClient({ adapter });
  return prisma;
};

app.openapi(
  prefectureRoute,
  async (c) => {
    const prisma = await createClient(c.env.DB);
    const { prefectureId } = c.req.valid("param");
    const prefecture = await prisma.prefecture.findUnique({
      select: {
        name: true,
        kana: true,
        prefectureId: true,
      },
      where: {
        prefectureId,
      },
    });

    if (!prefecture) {
      return c.json({ code: 404, message: "Not Found" }, 404);
    }

    return c.json(prefecture);
  },
  (result, c) => {
    if (!result.success) {
      return c.json(
        {
          code: 400,
          message: "Validation Error",
        },
        400
      );
    }
  }
);

app.openapi(prefecturesRoute, async (c) => {
  const prisma = await createClient(c.env.DB);

  const prefectures = await prisma.prefecture.findMany({
    select: {
      name: true,
      kana: true,
      prefectureId: true,
    },
  });
  return c.json(prefectures);
});

app.doc31("/doc", {
  openapi: "3.1.0",
  info: {
    version: "1.0.0",
    title: "My API",
  },
});

export default app;

バリデーションエラーのハンドリングは app.openapi() メソッドの第三引数にフックとして書くことができます。

エラーレスポンスもZodのスキーマ通りの形で返さないと、型エラーで怒られるようになっています。

現時点ではOpenAPI v3.1を使いたい場合は app.doc31() のメソッドを呼び出す必要があるようです。

これで pnpm dev して実行してみると http://localhost:8787/doc でOpenAPIスキーマが確認できます。

Swagger UI

Swagger UIでの表示も簡単で、パッケージをインストールして、

pnpm add @hono/swagger-ui

ミドルウェアとして使用するだけになります。

import { swaggerUI } from "@hono/swagger-ui";

// 省略

app.doc31("/doc", {
  openapi: "3.1.0",
  info: {
    version: "1.0.0",
    title: "My API",
  },
});

app.get("/ui", swaggerUI({ url: "/doc" }));

export default app;

これで http://localhost:8787/ui でSwagger UIが確認できるようになりました。

おわりに

リクエストバリデーションとレスポンス値のZodスキーマを書くだけでそれぞれ型が効いて、さらにAPIのドキュメンテーションが実現できるのはとても開発体験がよかったです。

ただ、今回はレコードをほぼそのままで返すというのもあり、Prismaでの定義とZodでの定義で若干の二度手間感があるので、PrismaからZodスキーマを生成してそれを流用するなどもう少し運用方法を今後も検討してみたいと思います。

参考

Daiki Urata

Daiki Urata

Twitter X

フロントエンド/モバイルアプリなどを主に開発しています。