zodスキーマからOpenAPIドキュメントを生成して、型安全なプロジェクトを構築しよう
フロントエンド及びバックエンドがTypeScriptである場合の設計例を紹介したい
こんにちは!sonicmoovエンジニアのchiakiです。
本記事では、
- REST APIで使用されるデータモデルやパラメータ定義
- APIエンドポイントのメタ情報
といった内容をOASではなくTypeScriptで一元管理するために、@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-openapi
のOpenAPIRegistry
クラスインスタンスに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}`);
利用例(不完全😭)
サンプルで作成したリポジトリはこちら
フロントエンドでの利用例
React Router v7
とConform
を利用した例は以下となります!
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
を利用した例になります!
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