HonoとDrizzleでDB~API~Frontendと型を繋げる
はじめに
Honoとdrizzleを使って、DBのテーブル定義を「API ServerのScheme」と「Frontendの型」まで伝搬させる方法をまとめます。
また、@hono/zod-openapiを使ったバリデーションとOpenAPIの整備を合わせて行います。
Honoのプロジェクト作成
まずはHonoのプロジェクトを作成します。
今回は zenn-hono-drizzle-todo
という名前のプロジェクトとしました。
$ npm create hono@latest
create-hono version 0.16.0
✔ Target directory zenn-hono-drizzle-todo
✔ Which template do you want to use? nodejs
✔ Do you want to install project dependencies? Yes
✔ Which package manager do you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
$ tree -I 'node_modules'
.
├── README.md
├── compose.yaml
├── package-lock.json
├── package.json
├── src
│ └── index.ts
└── tsconfig.json
開発用DBの準備
今回はPostgreSQLを使います。
Docker Composeを使って開発環境用のDBを立ち上げます。
version: "3.9"
services:
postgres:
image: postgres:17.2
environment:
- POSTGRES_USER=myuser
- POSTGRES_PASSWORD=mypassword
volumes:
- ./db/data:/var/lib/postgresql/data
ports:
- "5432:5432"
$ mkdir db
$ docker compose up -d
Drizzleの設定
drizzleの設定をします。
まずは必要なパッケージのインストールします。
npm i drizzle-orm pg drizzle-zod
npm i -D drizzle-kit @types/pg
drizzleの設定ファイルを作成します。
規模が大きくなるとschemaファイルを分割できるので便利です。
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: 'postgres://myuser:mypassword@localhost:5432/',
},
});
次のテーブル定義を行います。
table.ts
を作成し、以下のようなTODOを管理するテーブルを考えます。
import { pgTable, uuid, varchar, timestamp, boolean } from "drizzle-orm/pg-core";
export const todoTable = pgTable("todo", {
id: uuid('id').defaultRandom().primaryKey(),
todo: varchar({ length: 255 }).notNull().unique(),
done: boolean().notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
作成したテーブル定義は以下のコマンドで簡単にDBへ反映できます。
商用環境などにはドキュメントのように段階的にmigrationすることになるでしょう。
$ npx drizzle-kit push
API ServerのSchema定義
DBのテーブル定義ができたので、このテーブル情報を極力活かしてAPI ServerのSchemaを定義していきます。
必要なパッケージをインストールします。
npm i zod @hono/zod-openapi
schema.ts
というファイルを作成しSchemaを定義します。
テーブル定義した table.ts
をインポートし必要のないパラメータをomitします。
Error Schemaはhono/zod-openapi
から定義します。
schema.ts
import { createSchemaFactory } from 'drizzle-zod';
import { z } from '@hono/zod-openapi';
import { todoTable } from './table.js';
const { createInsertSchema, createSelectSchema, createUpdateSchema } = createSchemaFactory({ zodInstance: z });
////////////////////////////////////////////////////////////////
// Create
export const createTodoReqBodySchema = createInsertSchema(todoTable, {
todo: (schema) => schema.max(255, "'todo' must be 255 characters or less").openapi({ example: 'todo' }),
}).omit({
id: true,
done: true,
createdAt: true,
updatedAt: true,
}).required();
export const createTodoResBodySchema = createInsertSchema(todoTable, {
id: (schema) => schema.openapi({ example: 'a415d538-88d5-4db4-8da2-628826a15b9f' }),
todo: (schema) => schema.openapi({ example: 'todo' }),
done: (schema) => schema.openapi({ example: false }),
createdAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
updatedAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
});
////////////////////////////////////////////////////////////////
// Read
export const readTodoReqPathParamSchema = createSelectSchema(todoTable, {
id: (schema) => schema.openapi({ example: 'a415d538-88d5-4db4-8da2-628826a15b9f' }),
}).omit({
todo: true,
done: true,
createdAt: true,
updatedAt: true,
}).required();
export const readTodoResBodySchema = createSelectSchema(todoTable, {
id: (schema) => schema.openapi({ example: 'a415d538-88d5-4db4-8da2-628826a15b9f' }),
todo: (schema) => schema.openapi({ example: 'todo' }),
done: (schema) => schema.openapi({ example: false }),
createdAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
updatedAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
});
export const readTodoListResBodySchema = createSelectSchema(todoTable, {
id: (schema) => schema.openapi({ example: 'a415d538-88d5-4db4-8da2-628826a15b9f' }),
todo: (schema) => schema.openapi({ example: 'todo' }),
done: (schema) => schema.openapi({ example: false }),
createdAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
updatedAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
}).array();
////////////////////////////////////////////////////////////////
// Update
export const updateTodoReqPathParamSchema = createUpdateSchema(todoTable, {
id: (schema) => schema.openapi({ example: 'a415d538-88d5-4db4-8da2-628826a15b9f' }),
}).omit({
todo: true,
done: true,
createdAt: true,
updatedAt: true,
}).required();
export const updateTodoReqBodySchema = createUpdateSchema(todoTable, {
todo: (schema) => schema.openapi({ example: 'todo' }),
done: (schema) => schema.openapi({ example: false }),
}).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const updateTodoResBodySchema = createUpdateSchema(todoTable, {
id: (schema) => schema.openapi({ example: 'a415d538-88d5-4db4-8da2-628826a15b9f' }),
todo: (schema) => schema.openapi({ example: 'todo' }),
done: (schema) => schema.openapi({ example: false }),
createdAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
updatedAt: (schema) => schema.openapi({ example: '2023-10-01T00:00:00.000Z' }),
});
////////////////////////////////////////////////////////////////
// Delete
export const deleteTodoReqPathParamSchema = createSelectSchema(todoTable, {
id: (schema) => schema.openapi({ example: 'a415d538-88d5-4db4-8da2-628826a15b9f' }),
}).omit({
todo: true,
done: true,
createdAt: true,
updatedAt: true,
}).required();
////////////////////////////////////////////////////////////////
// Error
export const errorResBodySchema = z.object({
id: z.string().openapi({
example: 'a415d538-88d5-4db4-8da2-628826a15b9f',
}),
code: z.number().openapi({
example: 400,
}),
message: z.string().openapi({
example: "Bad Request",
}),
});
これでテーブル定義からAPI ServerのSchemaを生成することができました。
ルーティング設定
次にルーティングの設定を行います。
OpenAPI仕様に則り、どのパスに対し、どういうレスポンスが生じ、それぞれどのスキーマを受け取り、送り返すか、を定義します。
route.ts
import { createRoute } from '@hono/zod-openapi'
import {
createTodoReqBodySchema,
createTodoResBodySchema,
readTodoReqPathParamSchema,
readTodoResBodySchema,
readTodoListResBodySchema,
updateTodoReqPathParamSchema,
updateTodoReqBodySchema,
updateTodoResBodySchema,
deleteTodoReqPathParamSchema,
errorResBodySchema
} from './schema.js';
////////////////////////////////////////////////////////////////
// Create
export const createTodoRoute = createRoute({
method: "post",
path: "/",
request: {
body: {
content: {
'application/json': {
schema: createTodoReqBodySchema,
},
},
},
},
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: createTodoResBodySchema,
},
},
},
400: {
content: {
"application/json": {
schema: errorResBodySchema,
},
},
description: "Bad Request",
},
409: {
content: {
"application/json": {
schema: errorResBodySchema,
},
},
description: "Conflict",
}
},
});
////////////////////////////////////////////////////////////////
// Read
export const readTodoRoute = createRoute({
method: 'get',
path: "/{id}",
request: {
params: readTodoReqPathParamSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: readTodoResBodySchema,
},
},
description: 'Retrieve the user',
},
404: {
content: {
"application/json": {
schema: errorResBodySchema,
},
},
description: "Not Found",
},
},
});
export const readTodoListRoute = createRoute({
method: 'get',
path: "/",
responses: {
200: {
content: {
'application/json': {
schema: readTodoListResBodySchema,
},
},
description: 'Retrieve the user',
}
},
});
////////////////////////////////////////////////////////////////
// Update
export const updateTodoRoute = createRoute({
method: "put",
path: "/{id}",
request: {
params: updateTodoReqPathParamSchema,
body: {
content: {
'application/json': {
schema: updateTodoReqBodySchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: updateTodoResBodySchema,
},
},
description: 'Retrieve the user',
},
400: {
content: {
"application/json": {
schema: errorResBodySchema,
},
},
description: "Bad Request",
},
404: {
content: {
"application/json": {
schema: errorResBodySchema,
},
},
description: "Not Found",
},
},
});
////////////////////////////////////////////////////////////////
// Delete
export const deleteTodoRoute = createRoute({
method: "delete",
path: "/{id}",
request: {
params: deleteTodoReqPathParamSchema,
},
responses: {
204: {
description: 'Successfully deleted'
},
404: {
content: {
"application/json": {
schema: errorResBodySchema,
},
},
description: "Not Found",
},
}
});
HonoのAPIの実装プラクティス
APIの実装を行なっていきます。
HonoのBest Practicesにある通り、APIの実装を別ファイルに分割し index.ts
に集約する方法を取ります。
DBの変数定義やrequestIdの設定なども index.ts
に実装します。
import { serve } from '@hono/node-server'
import { OpenAPIHono } from "@hono/zod-openapi";
import { requestId } from 'hono/request-id'
import { NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres';
import api from "./api.js"; // api.ts でAPI実装をこれから行う
export type Variables = {
db: NodePgDatabase
}
const app = new OpenAPIHono<{ Variables: Variables }>()
.use(requestId())
.use(logger())
.use(async (c, next) => {
const db = drizzle('postgres://myuser:mypassword@localhost:5432/');
c.set('db', db)
await next()
})
.route('/todo', api) // 各ルートをここで集約
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
export default app
export type AppType = typeof app
defaultHookによるエラーハンドリング
HonoにはZodのエラー(つまりバリデーションエラー)をまとめてハンドリングできる defaultHook という仕組みがあります。
この仕組みを使うとパスごとに同じバリデーションエラーのハンドリングを書かずに済むため記述量が減るため、積極的に活用していきます。
API実装
それではAPIを実装します。
実装としてはバリデーションエラーをdefaultHook
でハンドリングしています。
また、今回 todo
にユニーク制約を入れました。ユニーク制約のエラーは現状db.insert()
の返り値として返却されないため try catch
でエラーを拾います。
このissueで議論されていますが返り値でDBのエラーも返してほしい気持ちになりますね。
api.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { createTodoRoute, readTodoRoute, readTodoListRoute, updateTodoRoute, deleteTodoRoute } from "./route.js";
import { todoTable } from './table.js';
import pg from "pg";
import { eq } from "drizzle-orm";
type Variables = {
db: NodePgDatabase
}
const app = new OpenAPIHono<{ Variables: Variables }>({
defaultHook: (result, c) => {
if (!result.success) {
return c.json({
id: c.get('requestId'),
code: 400,
message: result.error.errors.map(error => error.message).join(', '),
}, 400);
}
},
}).openapi(createTodoRoute, async (c) => {
const data = c.req.valid('json')
const db = c.get('db')
try {
const result = await db.insert(todoTable).values({...data, done: false}).returning();
return c.json(result[0], 200);
} catch (error) {
if (error instanceof pg.DatabaseError) {
if(error.constraint === "table_todo_unique") {
return c.json({id: c.get('requestId'), code: 409, message: "Todo already exists"}, 409);
}
}
return c.json({id: c.get('requestId'), code: 400, message: error}, 400)
}
}).openapi(readTodoRoute, async (c) => {
const { id } = c.req.valid('param')
const db = c.get('db')
const result = await db.select().from(todoTable).where(eq(todoTable.id, id));
if (result.length === 0) {
return c.json({id: c.get('requestId'), code: 404, message: 'Not Found'}, 404)
}
return c.json(result[0], 200);
}).openapi(readTodoListRoute, async (c) => {
const db = c.get('db')
const result = await db.select().from(todoTable);
return c.json(result, 200);
}).openapi(updateTodoRoute, async (c) => {
const { id } = c.req.valid('param')
const data = c.req.valid('json')
const db = c.get('db')
const result = await db.update(todoTable).set(data).where(eq(todoTable.id, id)).returning();
if (result.length === 0) {
return c.json({id: c.get('requestId'), code: 404, message: 'Not Found'}, 404)
}
return c.json(result[0], 200);
}).openapi(deleteTodoRoute, async (c) => {
const { id } = c.req.valid('param')
const db = c.get('db')
const result = await db.delete(todoTable).where(eq(todoTable.id, id)).returning();
if (result.length === 0) {
return c.json({id: c.get('requestId'), code: 404, message: 'Not Found'}, 404)
}
return c.text('Successfully deleted')
});
export default app
RPCによるクライアント
DBのテーブル情報からAPI Schemaまで型情報を伝搬してきました。
次はフロントエンドです。
HonoにはRPCという仕組みが準備されています。
今回は以下のスクリプトからhc<AppType>
とルーティング情報、型情報をimportしAPIの型情報付きでAPI Callすることができます。
import { hc } from 'hono/client'
import type { AppType } from './index.js'
const client = hc<AppType>('http://localhost:3003')
const fetchApi = async () => {
const postRes = await client.todo.$post({
json: {
todo: 'test todo 01',
},
})
const postData = await postRes.json()
const getRes = await client.todo.$get({})
const getData = await getRes.json()
return getData
}
fetchApi()
.then((data) => {
for(const todo of data) {
console.log(`ID: ${todo.id}, Todo: ${todo.todo}`)
}
})
.catch((error) => {
console.error(`Error fetching data: ${error}`)
});
まとめ
Honoとdrizzleを使って、DBのテーブル定義を「API ServerのScheme」と「Frontendの型」まで伝搬させる方法をまとめました。
Discussion