🤣

zodスキーマからOpenAPIドキュメントを生成して、型安全なプロジェクトを構築しよう

2025/02/24に公開

フロントエンド及びバックエンドがTypeScriptである場合の設計例を紹介したい

こんにちは!sonicmoovエンジニアのchiakiです。

本記事では、

  • REST APIで使用されるデータモデルやパラメータ定義
  • APIエンドポイントのメタ情報

といった内容をOASではなくTypeScriptで一元管理するために、@asteasolutions/zod-to-openapiを使用した設計例を紹介したいと思います!

https://github.com/asteasolutions/zod-to-openapi

ディレクトリ構成

今回はお問い合わせフォームや一覧といった画面を想定しています。FEとBEがともにモノレポで管理され、packagesディレクトリ配下にschemaディレクトリがあるという構成になります。

└── schema/
    ├── constants/       # 定数や型定義をまとめる
    │   └── contact.ts
    ├── models/          # DBやレスポンスのモデルスキーマ定義
    │   └── contact.ts
    ├── parameters/      # リクエストパラメータのスキーマ定義
    │   └── contact/
    │       └── post.ts
    └── routes/          # エンドポイントのメタ情報(zod-to-openapi用)
        └── contact.ts

schemaディレクトリ配下はどうなっているか

定数の管理(constants/contact.ts

アプリ全体で共通して使う定数・型を定義します。
今回は問い合わせ種別を想定した定数を作成しました。
product, technical, general の3種類とし、それぞれにラベルを付与して管理しています。

import { z } from "zod";

const CONTACT_TYPE_NAMES = ["product", "technical", "general"] as const;

export type ContactTypeValues = (typeof CONTACT_TYPE_NAMES)[number];
export type ContactTypeKeys = Capitalize<ContactTypeValues>;

export const CONTACT_TYPE = {
  Product: { value: "product", label: "製品" },
  Technical: { value: "technical", label: "技術" },
  General: { value: "general", label: "その他全般" },
} as const satisfies Record<
  ContactTypeKeys,
  { value: ContactTypeValues; label: string }
>;

export const contactTypeSchema = z.enum(CONTACT_TYPE_NAMES);

IDのような値の場合は以下となります!

const CONTACT_TYPE_NAMES = ["product", "technical", "general"] as const;
const CONTACT_TYPE_VALUES = [1, 2, 3] as const;

export type ContactTypeNames = (typeof CONTACT_TYPE_NAMES)[number];
export type ContactTypeKeys = Capitalize<ContactTypeNames>;
export type ContactTypeValuesType = (typeof CONTACT_TYPE_VALUES)[number];

export const CONTACT_TYPE = {
  Product: { value: 1, label: "製品" },
  Technical: { value: 2, label: "技術" },
  General: { value: 3, label: "その他全般" },
} as const satisfies Record<
  ContactTypeKeys,
  { value: ContactTypeValuesType; label: string }
>;

export const contactTypeSchema = z.enum(CONTACT_TYPE_NAMES);

モデルスキーマの定義(models/contact.ts)

ここでは、お問い合わせ1件のデータモデル(DBやレスポンスボディに相当)を定義します。
また、@asteasolutions/zod-to-openapiを使ってOpenAPIドキュメント用のメタ情報を付与しています。

import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";

extendZodWithOpenApi(z);

const BaseContact = z.object({
  id: z.string().min(1),
  status: z
    .enum(["received", "inProgress", "resolved", "closed"])
    .default("received"),
  assigned_staff_id: z.string().optional(),
  created_at: z.string().min(1),
  updated_at: z.string().min(1),
  user_id: z.string().optional(),
  support_memo: z.string().optional(),
  response_note: z.string().optional(),
  notes: z.string().optional(),
});

const ProductContact = BaseContact.extend({
  type: z.literal("product"),
  product_name: z.string(),
  purchase_date: z.string(),
});

const TechnicalContact = BaseContact.extend({
  type: z.literal("technical"),
  environment_os: z.string(),
  environment_browser: z.string(),
  environment_version: z.string(),
  error_message: z.string(),
});

const GeneralContact = BaseContact.extend({
  type: z.literal("general"),
  subject: z.string(),
});

export const Contact = z
  .discriminatedUnion("type", [
    ProductContact,
    TechnicalContact,
    GeneralContact,
  ])
  .openapi("Contact");

export type ContactType = z.infer<typeof Contact>;

リクエストパラメータのスキーマ定義(parameters/contact/post.ts

フォーム送信などで利用するリクエストパラメータの定義をparametersに置いていきます。
エンドポイント名/リクエストメソッド名のような形式にしてみました。今回はお問い合わせフォームでの例となります。

import { z } from "zod";
import { contactTypeSchema } from "../../constants/contact";

const contactBaseSchema = z.object({
  name: z.string().min(1, "お名前を入力してください"),
  email: z.string().email("正しいメールアドレスを入力してください"),
  phone: z
    .string()
    .regex(/^[0-9-]{10,}$/, "正しい電話番号を入力してください")
    .optional(),
  type: contactTypeSchema,
  details: z.string().min(10, "詳細を10文字以上で入力してください"),
});

const productInquiryFields = {
  productName: z.string().min(1, "製品名を入力してください"),
  purchaseDate: z.string().datetime("購入日をYYYY-MM-DD形式で入力してください"),
};

const technicalSupportFields = {
  environment: z.object({
    os: z.string().min(1, "OSを入力してください"),
    browser: z.string().min(1, "ブラウザを入力してください").optional(),
    version: z.string().optional(),
  }),
  errorMessage: z.string().optional(),
};

const generalInquiryFields = {
  subject: z.string().min(1, "件名を入力してください"),
};

const productInquirySchema = contactBaseSchema.extend({
  type: z.literal("product"),
  ...productInquiryFields,
});

const technicalSupportSchema = contactBaseSchema.extend({
  type: z.literal("technical"),
  ...technicalSupportFields,
});

const generalInquirySchema = contactBaseSchema.extend({
  type: z.literal("general"),
  ...generalInquiryFields,
});

export const contactFormSchema = z.discriminatedUnion("type", [
  productInquirySchema,
  technicalSupportSchema,
  generalInquirySchema,
]);

エンドポイントのメタ情報定義(routes/contact.ts

各エンドポイントのリクエスト・レスポンスのスキーマやセキュリティ設定などを定義します。
後述するyamlファイルの自動生成で必要になるのですが、@asteasolutions/zod-to-openapiOpenAPIRegistryクラスインスタンスにpathを動的に登録します。

import { z } from "zod";
import { Contact } from "../models/contact";
import { bearerAuth, registry } from "../generator";
import { RouteConfig } from "@asteasolutions/zod-to-openapi";
import { contactFormSchema } from "../parameters/contact/post";
import { pagination } from "../parameters/pagination";

const contactRoutes: RouteConfig[] = [
  {
    method: "get",
    path: "/contact",
    description: "お問い合わせ一覧を取得する",
    summary: "お問い合わせ一覧取得",
    security: [{ [bearerAuth.name]: [] }],
    request: { query: pagination },
    responses: {
      200: {
        description: "お問い合わせ一覧",
        content: { "application/json": { schema: z.array(Contact) } },
      },
    },
  },
  {
    method: "post",
    path: "/contact",
    description: "お問い合わせを登録する",
    summary: "お問い合わせ登録",
    security: [{ [bearerAuth.name]: [] }],
    request: {
      body: { content: { "application/json": { schema: contactFormSchema } } },
    },
    responses: { 201: { description: "登録成功" } },
  },
];

contactRoutes.forEach((route) => registry.registerPath(route));

自動生成スクリプト

上記のファイル内容と合致したOpenAPIドキュメントを自動生成するスクリプトを作成します。
実行後にschema/documentsへ書き出されます!

import {
  OpenAPIRegistry,
  OpenApiGeneratorV31,
} from "@asteasolutions/zod-to-openapi";
import * as yaml from "yaml";
import * as fs from "fs";
import * as path from "path";

export const registry = new OpenAPIRegistry();

export const bearerAuth = registry.registerComponent(
  "securitySchemes",
  "bearerAuth",
  {
    type: "http",
    scheme: "bearer",
    bearerFormat: "JWT",
  }
);

// import routes
import "./routes/contact";

const generator = new OpenApiGeneratorV31(registry.definitions);

const docs = generator.generateDocument({
  openapi: "3.1.0",
  info: {
    title: "Contact API",
    version: "1.0.0",
    description: "お問い合わせAPI",
  },
  servers: [
    {
      url: "http://localhost:3000",
      description: "Development server",
    },
  ],
});

const outputPath = path.join(__dirname, "..", "documents", "app.yaml");

const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
  fs.mkdirSync(outputDir, { recursive: true });
}

fs.writeFileSync(outputPath, yaml.stringify(docs), {
  encoding: "utf-8",
});

console.log(`OpenAPI specification generated at: ${outputPath}`);

利用例(不完全😭)

サンプルで作成したリポジトリはこちら

https://github.com/doubutsunokarada/react-zod-openapi

フロントエンドでの利用例

React Router v7Conformを利用した例は以下となります!

https://reactrouter.com/
https://conform.guide/

import { parseWithZod } from "@conform-to/zod";
import type { Route } from "./+types/home";
import { contactFormSchema } from "@packages/schema/parameters/contact/post";
import { Form, redirect, useActionData } from "react-router";
import {
  Button,
  Checkbox,
  Group,
  Radio,
  Stack,
  Textarea,
  TextInput,
  Title,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { CONTACT_TYPE } from "@packages/schema/constants/contact";
import { useForm } from "@conform-to/react";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema: contactFormSchema });
  console.log("submission", submission.status);
  if (submission.status !== "success") {
    return submission.reply();
  }
  return redirect("/contact/thanks");
}

export function meta({}: Route.MetaArgs) {
  return [
    { title: "お問い合わせ" },
    { name: "description", content: "お問い合わせページです" },
  ];
}

export default function Contact() {
  const lastResult = useActionData<typeof action>();
  const [form, fields] = useForm({
    lastResult,
    onValidate: ({ formData }) => {
      return parseWithZod(formData, { schema: contactFormSchema });
    },
    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",
  });
  console.log(fields.purchaseDate.value);

  const ProductForm = () => (
    <>
      <TextInput
        label="製品名"
        placeholder="製品名を入力してください"
        key={fields.productName.key}
        name={fields.productName.name}
        error={fields.productName.errors}
        withAsterisk
      />
      <DateInput
        label="購入日"
        key={fields.purchaseDate.key}
        name={fields.purchaseDate.name}
        error={fields.purchaseDate.errors}
        valueFormat="YYYY-MM-DD"
        withAsterisk
      />
    </>
  );

  const TechnicalSupport = () => {
    const { os, browser, version } = fields.environment.getFieldset();
    return (
      <>
        <TextInput
          label="使用OS"
          placeholder="使用OSを入力してください"
          key={os.key}
          name={os.name}
          error={os.errors}
          withAsterisk
        />
        <TextInput
          label="ブラウザ"
          placeholder="使用ブラウザを入力してください"
          key={browser.key}
          name={browser.name}
          error={browser.errors}
        />
        <TextInput
          label="バージョン"
          placeholder="バージョンを入力してください"
          key={version.key}
          name={version.name}
          error={version.errors}
        />
        <Textarea
          label="エラーメッセージ"
          placeholder="エラーメッセージを入力してください"
          key={fields.errorMessage.key}
          name={fields.errorMessage.name}
          error={fields.errorMessage.errors}
        />
      </>
    );
  };

  const GeneralInquiry = () => {
    return (
      <>
        <Textarea
          label="件名"
          placeholder="件名を入力してください"
          key={fields.subject.key}
          name={fields.subject.name}
          defaultValue={fields.subject.value}
          error={fields.subject.errors}
          withAsterisk
        />
      </>
    );
  };

  return (
    <>
      <Title mb={16} order={1}>
        お問い合わせ
      </Title>
      <Form method="post" id={form.id} onSubmit={form.onSubmit}>
        <Stack mb={20}>
          <TextInput
            withAsterisk
            label="お名前"
            placeholder="山田太郎"
            key={fields.name.key}
            name={fields.name.name}
            defaultValue={fields.name.value}
            error={fields.name.errors}
          />
          <TextInput
            withAsterisk
            label="Eメール"
            placeholder="your-email@example.com"
            key={fields.email.key}
            name={fields.email.name}
            defaultValue={fields.email.value}
            error={fields.email.errors}
          />
          <TextInput
            label="電話番号"
            placeholder="000-0000-0000"
            key={fields.phone.key}
            name={fields.phone.name}
            defaultValue={fields.phone.value}
            error={fields.phone.errors}
          />
        </Stack>
        <Textarea
          label="詳細"
          key={fields.details.key}
          name={fields.details.name}
          defaultValue={fields.details.value}
          error={fields.details.errors}
          withAsterisk
        />
        <Radio.Group
          label="お問い合わせ内容"
          withAsterisk
          error={fields.type.errors}
        >
          <Group mt="xs" mb={20}>
            {Object.values(CONTACT_TYPE).map((type) => (
              <Radio
                label={type.label}
                value={type.value}
                key={type.value}
                name={fields.type.name}
              />
            ))}
          </Group>
        </Radio.Group>
        {fields.type.value === "product" && <ProductForm />}
        {fields.type.value === "technical" && <TechnicalSupport />}
        {fields.type.value === "general" && <GeneralInquiry />}

        <Checkbox mt="md" label="I agree to sell my privacy" />

        <Group justify="flex-end" mt="md">
          <Button type="submit">送信</Button>
        </Group>
      </Form>
    </>
  );
}

バックエンドでの利用例

Hono@hono/zod-validatorを利用した例になります!

https://hono.dev/
https://hono.dev/docs/guides/validation#zod-validator-middleware

import { Hono } from "hono";
import { handle } from "hono/aws-lambda";
import { contactFormSchema } from "@packages/schema/parameters/contact/post";
import { zValidator } from "@hono/zod-validator";
import { ContactType } from "@packages/schema/models/contact";

const app = new Hono();

app.get("/contact", (c) => {
  // do something...
  return c.json<ContactType[]>([
    {
      id: "1",
      // ...
    },
  ]);
});

app.post("/contact", zValidator("json", contactFormSchema), (c) => {
  const params = c.req.valid("json");
  // do something...
  return c.json({ status: 201, message: "created" });
});

export const handler = handle(app);
株式会社ソニックムーブ

Discussion