Hono + Cloudflare D1 + Drizzle ORM + Zod で作る API エンドポイント
この記事は?
この記事では、Hono
と Cloudflare 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
に追記します。
{
// ... 他の設定 ...
"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
テーブルを定義します。
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
を作成します。
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.jsonc
の migrations_dir
と一致させる必要があります。 dialect
は sqlite
、driver
は d1-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
を以下のように編集します。
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
ユーザー関連の型定義ファイルを作成します。
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
の各エンドポイントにバリデーションを追加します。
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を利用します。
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の型情報が正しく取得できていません。
原因箇所
export type AppType = typeof app;
typeof app
はHonoインスタンスの型を示します。これには Hono の基本的なメソッドの型は含まれますが、今回定義した /users や /users/:id といった 具体的な API ルートの詳細な型情報が、hono/client が期待する形式で含まれているとは限りません。
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呼び出し
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.name
や user.email
といったプロパティアクセス時に型チェックと補完が効きます。
まとめ
この記事では、Hono、Cloudflare D1、Drizzle ORM、Zod を組み合わせたAPIを構築する手順を解説しました。
- Cloudflare D1: サーバーレス環境で手軽に利用できるリレーショナルデータベース
- Drizzle ORM: 型安全なクエリビルダとマイグレーション機能を提供
- Hono: 高速で軽量な Web フレームワーク、Cloudflare Workers との相性抜群
- Zod: スキーマ定義とバリデーションを強力にサポート
これらの技術スタックを組み合わせることで、開発体験とアプリケーションの品質を向上させることができます。一例として参考になれば幸いです。
GitHub
本記事で作成したコードは以下のリポジトリで公開しています。
Discussion