REST でも GraphQL のようにスキーマを共有したい ( TypeScript + zod )
概要
GraphQL はクライアントと API の中間言語としてスキーマを利用できて便利ですよね。
GraphQL でスキーマを共有できる良さを知っているのでなるべく採用したいところですが、「GraphQL に精通しているメンバーが少ない」とか「シンプルな API を数本用意するだけだから REST で十分」など、プロジェクト事情によって GraphQL を採用しないケースもあると思います。
REST API でもクライアントとスキーマを共有したい!と考えた結果、これから紹介する構成に落ち着いたので紹介します。
前提条件
この記事で紹介する方法は、スキーマの作成に zod を利用します。
そのため、API は Node.js など TypeScript を使うことを前提としています。
また、この記事では monorepo でスキーマを共有する方法を紹介しますが、スキーマを定義したリポジトリを npm パッケージ化することで monorepo でなくとも実現できると思います。
どのようにスキーマを共有するのか
例えば以下のようなスキーマを zod で定義します。
import { z } from "zod";
export const createAccountSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
})
export type CreateAccountSchema = z.infer<typeof createAccountSchema>
client のアカウント登録ページでスキーマを利用してみましょう。
参考コードは React を利用し、且つフォームライブラリとして react-hook-form を利用している前提です。
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { createAccountSchema, CreateAccountSchema } from "schema/account"
const Account = () => {
const { handleSubmit, register } = useForm<CreateAccountSchema>({
defaultValues: { name: "", email: "" },
resolver: zodResolver(createAccountSchema),
})
const onSubmit = (input) => {
// API をコール
}
return (
<div>
<h2>アカウント登録</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')}type="text" />
<input {...register('email')} type="email" />
<button type="submit">SUBMIT</button>
</form>
</div>
)
}
次に API でも同じスキーマを利用します。
以下の参考コードは Hono で REST API を実装した場合です。
import { Hono } from 'hono'
import { createAccountSchema } from "schema/account"
const app = new Hono()
app.post('/account', async (c) => {
const validatedBody = createAccountSchema.safeParse(await c.req.json())
if (!validatedBody.success) {
c.status(400)
return c.json({ message: validatedBody.error.message })
}
const body = validatedBody.data
// 以降の処理
})
REST でもスキーマを共有できると何が嬉しいのか?
さて、上記のようにREST でもスキーマを共有できると何が嬉しいのでしょうか?
GraphQL のメリットと共通する点もありますが、私は以下が嬉しいポイントだと考えています。
- API 実装前でも期待するインターフェイスが分かるためクライアントの実装を進められる
- クライアントと API で共通のバリデーションを実施できる
- スキーマを変更する場合、自ずとクライアントと API の両方を修正する(一方のみ修正された状態になりにくい)
結果として、コミュニケーションコスト、ドキュメント作成コスト、手戻り・追加作業を削減できます。さらに、変更にも強い(バグが発生にくい)です。
monorepo での実例紹介
yarn workspaces を用いた monorepo 構成での実例を紹介します。
monorepo 構成を実現できるなら pnpm workspace でも lerna でもなんでもいいでしょう。
以下のようなディレクトリ構成とします。
packages/
|- api/
| |- src/
| |- tsconfig.json
| └ package.json
|- client/
| |- src/
| |- tsconfig.json
| └ package.json
|- common/
| |- schema/ ← ここに schema ファイルを作成していく予定
| |- tsconfig.json
| └ package.json
|- tsconfig.base.json
└ package.json
ルートの設定
ルートディレクトリの package.json に workspaces を定義します。
{
// 中略
"private": true,
"workspaces": [
"packages/*"
],
// 中略
}
また、 tsconfig.base.json を定義しておきます( compilerOptions は好みで変えてOKです)
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"downlevelIteration": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string", "dom"]
}
}
common リポジトリの設定
package.json と tsconfig.json を以下のように定義します。
{
"name": "common",
"private": true,
// 中略
"peerDependencies": {
"zod": "*"
// その他 api と client で共有したいパッケージを定義しておく
},
// 中略
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
// 任意でオプションを設定
},
"include": ["**/*.ts"],
"exclude": ["./node_modules"]
}
api リポジトリの設定
package.json と tsconfig.json を以下のように定義します。
tsconfig.json でpaths
を設定することでpackages/common/
配下のファイルをimport Foo from "@common/bar"
のように参照できます。
{
"name": "api",
"private": true,
// 中略
"dependencies": {
// 中略
"zod": "3.22.4",
},
// 中略
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
// 任意でオプションを設定
"paths": {
"@common/*": ["../common/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["./node_modules"]
}
client リポジトリの設定
package.json と tsconfig.json も api リポジトリと同様に定義します。
なお、 client のフレームワークが Next.js の場合、 next.config.js に transpilePackage の設定を書いてやる必要があります。
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
transpilePackages: ['common'], // pakckages/common/package.json の name の値と合わせる必要がある
// その他、必要な設定
}
module.exports = nextConfig
monorepo 構成でスキーマを共有したコード
冒頭に記載したコードを、monorepo 構成にした場合のファイルパスなどを変更したコードを置いておきます。
import { z } from "zod";
export const createAccountSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
})
export type CreateAccountSchema = z.infer<typeof createAccountSchema>
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { createAccountSchema, CreateAccountSchema } from "@common/schema/account"
const Account = () => {
const { handleSubmit, register } = useForm<CreateAccountSchema>({
defaultValues: { name: "", email: "" },
resolver: zodResolver(createAccountSchema),
})
const onSubmit = (input) => {
// API をコール
}
return (
<div>
<h2>アカウント登録</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')}type="text" />
<input {...register('email')} type="email" />
<button type="submit">SUBMIT</button>
</form>
</div>
)
}
import { Hono } from 'hono'
import { createAccountSchema } from "@common/schema/account"
const app = new Hono()
app.post('/account', async (c) => {
const validatedBody = createAccountSchema.safeParse(await c.req.json())
if (!validatedBody.success) {
c.status(400)
return c.json({ message: validatedBody.error.message })
}
const body = validatedBody.data
// 以降の処理
})
まとめ
この構成を採用した3~4名規模の開発プロジェクトは、今のところスキーマの共有がうまくワークしています。
zod でスキーマを先に定義しておけば、クライアントとAPIの開発者はそれぞれの進捗を待たずして開発を進めることができます。
最後に、toraco株式会社では2024年11月1日にエンジニア向けのコミュニティを立ち上げました。
Discord のサーバーで運営しており、以下のリンクから無料で参加できます。コミュニティ内では以下のような投稿・活動がされます!
Discussion