🗄️

Hono + Cloudflare D1 + Drizzle ORM + Zod で作る API エンドポイント

に公開

この記事は?

この記事では、HonoCloudflare D1、そしてタイプセーフな ORM である Drizzle ORM、バリデーションライブラリ Zod を組み合わせて、簡単な CRUD API を構築する手順です。

Hono の基本的なセットアップについては、以下の記事で完了していることとします。
React Router v7 + Hono + bun でモノレポ構成の初期構築

前提条件

  • Cloudflare アカウントを持っていること
  • 基本的な Hono アプリケーションのセットアップが完了していること(上記参考記事参照)
  • 作業ディレクトリ: /sample-app/apps/backend (適宜読み替えてください)

使用する技術スタック

1. Cloudflare D1 データベースの作成

まずは Cloudflare D1 データベースを作成します。ターミナルで以下のコマンドを実行します。

bunx wrangler login
# Cloudflare にログイン (ブラウザが開きます)
Attempting to login via OAuth...
Successfully logged in.

D1 データベースを作成 (sample-app-db は任意の名前)

bunx wrangler d1 create sample-app-db

成功すると、以下のような情報が出力されます。この情報を wrangler.jsonc に追記します。

wrangler.jsonc
{
  // ... 他の設定 ...
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "sample-app-db",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // wrangler d1 create で表示された ID
      "migrations_dir": "./src/migrations" // マイグレーションファイルのディレクトリ
    }
  ]
  // ... 他の設定 ...
}

binding は Hono アプリケーション内で D1 データベースにアクセスする際の変数名になります。database_id は払い出されたユニークな ID に置き換えてください。migrations_dir は後ほど作成するマイグレーションファイルの格納場所を指定します。

2. Drizzle ORM の導入と設定

次に、Drizzle ORM をプロジェクトに導入します。

Drizzle ORM 本体をインストール

bun add drizzle-orm

Drizzle Kit (マイグレーションツール) を開発依存としてインストール

bun add -D drizzle-kit

スキーマ定義の作成

データベースのテーブル構造を定義するスキーマファイルを作成します。
src ディレクトリ内に database ディレクトリを作成

mkdir src/database
cd src/database

スキーマファイルを作成
今回は簡単な users テーブルを定義します。

src/database/schema.ts
import {
    integer,
    sqliteTable,
    text,
} from "drizzle-orm/sqlite-core";

// "users" テーブルの定義
export const usersTable = sqliteTable("users", {
    // id: INTEGER 型、主キー、自動インクリメント
    id: integer("id").primaryKey({ autoIncrement: true }),
    // name: TEXT 型、NULL 不可
    name: text("name").notNull(),
    // email: TEXT 型、NULL 不可、ユニーク制約
    email: text("email").notNull().unique(),
});

Drizzle 設定ファイルの作成

Drizzle Kit が参照する設定ファイルを作成します。
プロジェクトのルートディレクトリに戻り、drizzle.config.ts を作成します。

drizzle.config.ts
import type { Config } from 'drizzle-kit';

export default {
    out: './src/migrations', // マイグレーションファイルの出力先 (wrangler.jsonc と合わせる)
    schema: './src/database/schema.ts', // スキーマファイルのパス
    dialect: 'sqlite', // 使用するデータベースの種類 (D1 は SQLite 互換)
    driver: 'd1-http', // Cloudflare D1 を使う場合のドライバ指定
} satisfies Config;

out はマイグレーションファイルの出力先を指定します。wrangler.jsoncmigrations_dir と一致させる必要があります。 dialectsqlitedriverd1-http を指定します。

マイグレーションファイルの生成と適用

スキーマ定義に基づいて、データベースに変更を加えるためのマイグレーションファイルを生成します。

bunx drizzle-kit generate

成功すると以下のようなメッセージが表示される

[] Your SQL migration file ➜ src/migrations/0000_left_giant_man.sql 🚀

src/migrations ディレクトリに SQL ファイルが生成されます。

次に、生成されたマイグレーションファイルをローカル環境と Cloudflare 上のリモート環境の D1 データベースに適用します。

ローカルの D1 互換データベースにマイグレーションを適用

bunx wrangler d1 migrations apply sample-app-db --local

Cloudflare 上のリモートデータベースにマイグレーションを適用

bunx wrangler d1 migrations apply sample-app-db --remote

(オプション) ローカルデータベースへのダミーデータ投入

ローカル開発時に動作確認用のデータを入れたい場合は、ローカルに作成された SQLite ファイルに直接データを挿入できます。SQLite ファイルのパスは通常 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/xxxxxxxx.sqlite のような場所にあります。

INSERT

INSERT INTO users (name, email) VALUES('sample user', 'sample@example.com');

3. Hono から D1 へ接続

Hono アプリケーションから D1 データベースに接続し、データを取得する処理を実装します。

src/index.ts を以下のように編集します。

src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { drizzle } from "drizzle-orm/d1"; // Drizzle ORM D1 用モジュールをインポート
import { usersTable } from "./database/schema"; // 作成したスキーマをインポート

// wrangler.jsonc で定義した Bindings の型定義
type Bindings = {
    DB: D1Database; // "DB" は wrangler.jsonc の binding 名と一致させる
}

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

app.use("*", cors({
  origin: "*"
}));
app.get("/hello", (c) => {
  return c.json({ message: "Hello Hono!" })
});

// /users エンドポイント (GET): 全ユーザーを取得
app.get("/users", async (c) => {
  // Context から D1 データベースインスタンスを取得し、Drizzle でラップ
  const db = drizzle(c.env.DB);
  // usersTable から全てのレコードを選択
  const users = await db.select().from(usersTable).all();

  // JSON 形式でユーザーリストを返す (ステータスコード 200)
  return c.json(users, 200);
});

export type AppType = typeof app;
export default app;

API 確認

ローカルサーバーを起動して、/users エンドポイントにリクエストします。

bun run dev

別のターミナルから curl でリクエスト (jq で整形)

curl -X GET "http://localhost:8787/users" | jq

ローカル DB にダミーデータを挿入していれば、以下のようなレスポンスが返ってきます。

[
  {
    "id": 1,
    "name": "sample user",
    "email": "sample@example.com"
  }
]

4. Zod によるバリデーション導入

Zod と Drizzle Zod、Hono Zod Validator を使って、リクエストデータのバリデーションを実装します。

Zod と関連ライブラリをインストール

bun add zod drizzle-zod @hono/zod-validator

バリデーションスキーマの作成

リクエストボディやパラメータの型定義とバリデーションルールを定義するファイルを作成します。

types ディレクトリを作成

mkdir types

ユーザー関連の型定義ファイルを作成します。

types/user.ts
import { createInsertSchema } from "drizzle-zod";
import { usersTable } from "../database/schema"; // Drizzle スキーマをインポート
import { z } from "zod"; // Zod をインポート

// Drizzle スキーマから Zod スキーマを生成
const userZodSchemaBase = createInsertSchema(usersTable, {
    // Drizzle スキーマの型を上書き・拡張可能
    id: z.string(), // パラメータは文字列で受け取るため string に
    name: z.string().min(1, "名前は1文字以上で入力してください").max(100, "名前は100文字以内で入力してください"),
    email: z.string().email("有効なメールアドレスを入力してください"),
});

// 新規作成 (POST) 用のスキーマ (name と email のみ必要)
export const createUserSchema = userZodSchemaBase.pick({ name: true, email: true });

// ID 指定 (GET, PUT, DELETE のパラメータ) 用のスキーマ
export const userIdSchema = userZodSchemaBase.pick({ id: true });

createInsertSchema を使うと、Drizzle のスキーマ定義から Zod のスキーマを簡単に生成できます。バリデーションルールを追加したり、特定のフィールドだけを抜き出したスキーマを作成したりすることも可能です。

Hono ルーティングへの適用

作成した Zod スキーマを使って、index.ts の各エンドポイントにバリデーションを追加します。

src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { drizzle } from "drizzle-orm/d1";
import { usersTable } from "./database/schema";
import { zValidator } from "@hono/zod-validator"; // Hono 用 Zod バリデータ
import { createUserSchema, userIdSchema } from "./types/user"; // 作成した Zod スキーマ
import { eq } from "drizzle-orm";

type Bindings = {
    DB: D1Database;
}

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

app.use("*", cors({ origin: "*" }));
app.get("/hello", (c) => {
  return c.json({ message: "Hello Hono!" })
});

// GET /users: 全ユーザー取得
app.get("/users", async (c) => {
  const db = drizzle(c.env.DB);
  const users = await db.select().from(usersTable).all();
  return c.json(users, 200);
});

// POST /users: 新規ユーザー作成
// zValidator の第一引数にバリデーション対象 ('json', 'query', 'param', 'form', 'header')
// 第二引数に Zod スキーマを指定
app.post("/users", zValidator("json", createUserSchema), async (c) => {
  // バリデーション済みデータを取得
  const data = c.req.valid("json");
  const db = drizzle(c.env.DB);
  const parseData = await createUserSchema.parseAsync(data); // parseAsync は不要かも

  const newUser = await db
      .insert(usersTable)
      .values(parseData)
      .returning()
      .get();

  return c.json(newUser, 201);
});

// GET /users/:id: 特定ユーザー取得
// パスパラメータ (:id) をバリデーション
app.get("/users/:id", zValidator("param", userIdSchema), async (c) => {
  // バリデーション済みパラメータを取得
  const param = c.req.valid("param");
  const db = drizzle(c.env.DB);
  const userId = Number(param.id); // パラメータは文字列なので数値に変換

  const user = await db
      .select()
      .from(usersTable)
      // usersTable の id が :id と一致するレコードを検索
      .where(eq(usersTable.id, userId))
      .get();

  if (!user) {
    return c.json({ message: "User not found" }, 404); // 見つからない場合は 404
  }

  return c.json(user, 200);
});

// PUT /users/:id: 特定ユーザー更新
// パスパラメータと JSON ボディの両方をバリデーション
app.put(
  "/users/:id",
  zValidator("param", userIdSchema),
  zValidator("json", createUserSchema), // 今回は作成と同じスキーマを使用(updateを作成することを推奨)
  async (c) => {
    const param = c.req.valid("param");
    const data = c.req.valid("json");
    const db = drizzle(c.env.DB);
    const userId = Number(param.id);
    const parseData = await createUserSchema.parseAsync(data);

    const user = await db
        .update(usersTable)
        .set(parseData)
        .where(eq(usersTable.id, userId))
        .returning()
        .get();

    if (!user) {
        return c.json({ message: "User not found" }, 404);
    }

    return c.json(user, 200);
  }
);

// DELETE /users/:id: 特定ユーザー削除
// パスパラメータをバリデーション
app.delete("/users/:id", zValidator("param", userIdSchema), async (c) => {
  const param = c.req.valid("param");
  const db = drizzle(c.env.DB);
  const userId = Number(param.id);

  const user = await db
      .delete(usersTable)
      .where(eq(usersTable.id, userId))
      .returning() // 削除したデータを返す (確認用)
      .get();

  if (!user) {
    return c.json({ message: "User not found" }, 404);
  }

  // 成功時は No Content (204) を返す
  return c.newResponse(null, { status: 204 });
});

export type AppType = typeof app;
export default app;

zValidator ミドルウェアをルート定義に追加するだけで、簡単にバリデーションを組み込めます。バリデーションエラーが発生した場合は、Hono が自動的に 400 Bad Request レスポンスを返してくれます。

c.req.valid("json")c.req.valid("param") を使うことで、バリデーション済みのデータを型安全に取得できます。

Drizzle ORM の insert(), select(), update(), delete() メソッドと where() 句 ( eq などの演算子を使用) を組み合わせることで、基本的な CRUD 操作を実装できます。.returning() を使うと、操作後のデータを取得できます。

API 確認 (CRUD)

ローカルサーバーを起動した状態で、各エンドポイントの動作を確認しましょう。

新規ユーザー作成 (POST)

curl -X POST -H "Content-Type: application/json" -d '{"name": "create user", "email": "create@example.com"}' "http://localhost:8787/users" | jq
{
  "id": 2,
  "name": "create user",
  "email": "create@example.com"
}

ID 指定でユーザー取得 (GET)

curl -X GET "http://localhost:8787/users/1" | jq
{
  "id": 1,
  "name": "sample user",
  "email": "sample@example.com"
}

ユーザー情報更新 (PUT) - ID:2 のユーザーを更新

curl -X PUT -H "Content-Type: application/json" -d '{"name": "updated user", "email": "updated@example.com"}' "http://localhost:8787/users/2" | jq
{
  "id": 2,
  "name": "updated user",
  "email": "updated@example.com"
}

更新されたユーザー情報を確認 (GET)

curl -X GET "http://localhost:8787/users/2" | jq
{
  "id": 2,
  "name": "updated user",
  "email": "updated@example.com"
}

ユーザー削除 (DELETE) - ID:2 のユーザーを削除

curl -X DELETE "http://localhost:8787/users/2"
204  No Content

削除されたユーザーを取得しようとする (GET)

curl -X GET "http://localhost:8787/users/2" | jq
{
  "message": "User not found"
}

フロントエンドとの型安全な連携 (Hono RPC)

次に、このバックエンド API をフロントエンド (React Router v7) から 型安全に 呼び出します。
Hono RPCを利用します。

frontend/app/lib/client.ts
import type { AppType } from "@sample-app/backend";
import { hc } from "hono/client";

export const client = hc<AppType>(import.meta.env.VITE_API_URL);

clientの型がunknowとなっており、RPCの型情報が正しく取得できていません。

原因箇所

backend/src/index.ts
export type AppType = typeof app; 

typeof appはHonoインスタンスの型を示します。これには Hono の基本的なメソッドの型は含まれますが、今回定義した /users や /users/:id といった 具体的な API ルートの詳細な型情報が、hono/client が期待する形式で含まれているとは限りません。

backend/src/index.tsの変更

backend/src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import {drizzle} from "drizzle-orm/d1";
import {usersTable} from "./database/schema";
import {zValidator} from "@hono/zod-validator";
import {createUserSchema, userIdSchema} from "./types/users";
import {eq} from "drizzle-orm";

type Bindings = {
    DB: D1Database;
}

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

app.use("*", cors({
  origin: "*"
}));

const route = app
    .get("/hello", (c) => {
      return c.json({ message: "Hello Hono!" })
    })
    .get("/users", async (c) => {
      const db = drizzle(c.env.DB)
      const users = await db.select().from(usersTable).all();

      return c.json(users, 200);
    })
    .post("users", zValidator("json", createUserSchema), async (c) => {
      const data = c.req.valid("json");
      const db = drizzle(c.env.DB);
      const parseData = await createUserSchema.parseAsync(data);
      const newUser = await db
          .insert(usersTable)
          .values(parseData)
          .returning()
          .get();
      return c.json(newUser, 201);
    })
    .get("/users/:id", zValidator("param", userIdSchema), async (c) => {
        const param = c.req.valid("param");
        const db = drizzle(c.env.DB);
        const user = await db
            .select()
            .from(usersTable)
            .where(eq(usersTable.id, Number(param.id)))
            .get();

        if (!user) {
          return c.json({ message: "User not found" }, 404);
        }

        return c.json(user, 200);
    })
    .put("/users/:id", zValidator("param", userIdSchema), zValidator("json", createUserSchema), async (c) => {
        const param = c.req.valid("param");
        const data = c.req.valid("json");
        const db = drizzle(c.env.DB);
        const parseData = await createUserSchema.parseAsync(data);
          const user = await db
              .update(usersTable)
              .set(parseData)
              .where(eq(usersTable.id, Number(param.id)))
              .returning()
              .get();

          if (!user) {
              return c.json({ message: "User not found" }, 404);
          }

          return c.json(user, 200);
      })
    .delete("/users/:id", zValidator("param", userIdSchema), async (c) => {
        const param = c.req.valid("param");
        const db = drizzle(c.env.DB);
        const user = await db
            .delete(usersTable)
            .where(eq(usersTable.id, Number(param.id)))
            .returning()
            .get();

        if (!user) {
          return c.json({ message: "User not found" }, 404);
        }

        return c.newResponse(null, { status: 204 });
    });

export type AppType = typeof route;
export default app;

app.get(...).post(...) のようにメソッドチェーンでルート定義を繋げた結果 (route) の型 (typeof route) には、定義された全ルートのパス、メソッド、リクエスト/レスポンスの型情報などが集約されています。 hono/client はこの typeof route を解釈することで、フロントエンドで正確な型推論を実現します。

API呼び出し

frontend/app/routes/home.tsx
import type { Route } from "./+types/home";
import {client} from "~/lib/client";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "New React Router App" },
    { name: "description", content: "Welcome to React Router!" },
  ];
}

export async function loader({ context }: Route.LoaderArgs) {
  const res = await client.hello.$get();
  const resUser = await client.users.$get();
  const hello = await res.json();
  const users = await resUser.json();
  return {
    hello: hello.message,
    user: users[0],
  };
}

export default function Home({ loaderData }: Route.ComponentProps) {
  return (
      <>
        <div>
          {loaderData.hello}
        </div>
        <h2>User</h2>
        <p>{loaderData.user.name}</p>
          <p>{loaderData.user.email}</p>
      </>
  )
}

await resUsers.json() の返り値 users は、バックエンドの AppType に基づき、自動的に { id: number; name: string; email: string }[] 型として推論されます。これにより、user.nameuser.email といったプロパティアクセス時に型チェックと補完が効きます。

まとめ

この記事では、Hono、Cloudflare D1、Drizzle ORM、Zod を組み合わせたAPIを構築する手順を解説しました。

  • Cloudflare D1: サーバーレス環境で手軽に利用できるリレーショナルデータベース
  • Drizzle ORM: 型安全なクエリビルダとマイグレーション機能を提供
  • Hono: 高速で軽量な Web フレームワーク、Cloudflare Workers との相性抜群
  • Zod: スキーマ定義とバリデーションを強力にサポート

これらの技術スタックを組み合わせることで、開発体験とアプリケーションの品質を向上させることができます。一例として参考になれば幸いです。

GitHub

本記事で作成したコードは以下のリポジトリで公開しています。

参考 URL

Discussion