🤝

REST でも GraphQL のようにスキーマを共有したい ( TypeScript + zod )

2024/03/22に公開

概要

GraphQL はクライアントと API の中間言語としてスキーマを利用できて便利ですよね。
GraphQL でスキーマを共有できる良さを知っているのでなるべく採用したいところですが、「GraphQL に精通しているメンバーが少ない」とか「シンプルな API を数本用意するだけだから REST で十分」など、プロジェクト事情によって GraphQL を採用しないケースもあると思います。

REST API でもクライアントとスキーマを共有したい!と考えた結果、これから紹介する構成に落ち着いたので紹介します。

前提条件

この記事で紹介する方法は、スキーマの作成に zod を利用します。
そのため、API は Node.js など TypeScript を使うことを前提としています。

また、この記事では monorepo でスキーマを共有する方法を紹介しますが、スキーマを定義したリポジトリを npm パッケージ化することで monorepo でなくとも実現できると思います。

どのようにスキーマを共有するのか

例えば以下のようなスキーマを zod で定義します。

schema/account.ts
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 を利用している前提です。

pages/account.tsx
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 を実装した場合です。

src/index.ts
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 を定義します。

package.json
{
  // 中略
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  // 中略
}

また、 tsconfig.base.json を定義しておきます( compilerOptions は好みで変えてOKです)

tsconfig.base.json
{
  "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 を以下のように定義します。

packages/common/package.json
{
  "name": "common",
  "private": true,
  // 中略
  "peerDependencies": {
    "zod": "*"
    // その他 api と client で共有したいパッケージを定義しておく
  },
  // 中略
}
packages/common/tsconfig.json
{
  "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"のように参照できます。

packages/api/package.json
{
  "name": "api",
  "private": true,
  // 中略
  "dependencies": {
    // 中略
    "zod": "3.22.4",
  },
  // 中略
}
packages/api/tsconfig.json
{
  "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 の設定を書いてやる必要があります。

packages/client/next.config.js
/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
  transpilePackages: ['common'], // pakckages/common/package.json の name の値と合わせる必要がある
  // その他、必要な設定
}

module.exports = nextConfig

monorepo 構成でスキーマを共有したコード

冒頭に記載したコードを、monorepo 構成にした場合のファイルパスなどを変更したコードを置いておきます。

packages/common/schema/account.ts
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>
packages/client/pages/account.tsx
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>
  )
}
packages/api/src/index.ts
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 のサーバーで運営しており、以下のリンクから無料で参加できます。コミュニティ内では以下のような投稿・活動がされます!

https://discord.gg/bga8nEfjfD

  • もくもく会・作業ラジオ・雑談部屋などオンライン上での交流
  • オフラインイベントの案内
  • 代表の稲垣(トラハック)が公開するコンテンツの説明・質問回答
  • toraco株式会社からの副業や案件の紹介
  • フロントエンド関連技術の情報共有および議論
  • 生成AI関連技術のキャッチアップ
  • その他、技術領域にこだわらない情報共有および議論
toraco株式会社のテックブログ

Discussion