Honoで作るスキーマファーストなAPI(を完成させたかった・・・)
はじめに
こんにちは〜!皆様いかがお過ごしでしょうか? no plan inc. CTOの @serinuntius です。
これはno plan inc.の Advent Calendar 2023の12日目の記事です。
ずっとHonoを使って何かを作ってみたいと思っていまして、作っておくと便利そうなものを作りました。
テーマはLine Messaging APIです。
課題
LineのMessaging APIを無料で利用しようとすると、友達追加してくれたuserIdのリストができません。
ドキュメントによると4つの方法があるようです。
- 開発者が自分自身のユーザーIDを取得する
- WebhookからユーザーIDを取得する
- 友だち全員のユーザーIDを取得する
- グループトークや複数人トークのメンバーのユーザーIDを取得する
無料で普通の用途で考えると2のWebhookからユーザーIDを取得するしかありません。
ただメッセージを送りたいだけなのに、いちいちWebhookのエンドポイントを作成するのはDRYじゃありません。
Lineのダメなところをいい感じにラップしてくれるAPIを作成すれば、今後Line botを作成するときに捗るんじゃないかと考えました。
モチベーションの共有ができたところで、環境を作成していきたいと思います。
インストールする
pnpm create hono my-app
create-hono version 0.3.2
✔ Using target directory … my-app
? Which template do you want to use? › - Use arrow-keys. Return to submit.
aws-lambda
bun
cloudflare-pages
❯ cloudflare-workers # 今回はCloudflare Workersを使いたいのでこちらを選択
deno
fastly
lagon
lambda-edge
netlify
↓ nextjs
cd my-app
pnpm i
まずはローカルサーバーを立ち上げてみる
pnpm dev
> @ dev /private/tmp/my-app
> wrangler dev src/index.ts
⛅️ wrangler 3.19.0
-------------------
⎔ Starting local server...
[wrangler:inf] Ready on http://localhost:8787
こんな感じの出力が出るので、 http://localhost:8787
にアクセスしてみましょう。
curl http://localhost:8787
Hello Hono!
と出力されましたか?
試しに src/index.ts
を適当にエディットして、レスポンスの文字列を変えてみましょう。
当たり前のようにホットリロードがされましたね?最高だぜ!
curl http://localhost:8787
Hello Hono!!!!
wranglerのログインをしておく
wranglerというCloudflareの管理用cliコマンドがありまして、D1の操作やWorkersのデプロイの際に使います。
ログインして、連携しておきましょう。
pnpm wrangler login
d1とdrizzleでCRUDを作る
d1というserverlessの SQL Databaseがあります。こいつをDBにしてCRUDを作ってみましょう。
d1はまだパブリックベータな為、対応しているORMが少ないです。
軽く調べた結果 drizzle
というORMが有力そうだったので、そちらを使ってみます。
d1のDBを作る
pnpm wrangler d1 create <DB-NAME>
✅ Successfully created DB 'test-db' in region APAC
Created your database using D1's new storage backend. The new storage backend is not yet recommended for production
workloads, but backs up your data via point-in-time restore.
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "test-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
こんな感じで出力されるので、 d1_database
以下のところを wrangler.toml
にコピーします。
name = "my-app"
compatibility_date = "2023-01-01"
main = "src/index.ts"
# [vars]
# MY_VARIABLE = "production_value"
# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# [[r2_buckets]]
# binding = "MY_BUCKET"
# bucket_name = "my-bucket"
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "test-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
drizzleをインストールする
pnpm add drizzle-orm drizzle-zod
pnpm add -D drizzle-kit
DBのshemaを作成していきます
適当に今回のモデリングをしてみました。一旦こんな感じで行ってみましょう。
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
export const users = sqliteTable('users', {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name"),
lineUserId: text("lineUserId").notNull().unique("lineUserId"),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull()
})
export const insertUsersSchema = createInsertSchema(users);
export const selectUsersSchema = createSelectSchema(users);
export const bots = sqliteTable('bots', {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique("name"),
lineChannelAccessToken: text("lineChannelAccessToken").notNull(),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull()
})
export const insertBotsSchema = createInsertSchema(bots);
export const selectBotsSchema = createSelectSchema(bots);
export const messages = sqliteTable('messages', {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
userId: integer("userId", { mode: "number" }).notNull(),
botId: integer("botId", { mode: "number" }).notNull(),
text: text("text").notNull(),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull()
})
export const insertMessagesSchema = createInsertSchema(messages);
export const selectMessagesSchema = createSelectSchema(messages);
他の記事ではこんな感じでtimestampの設定をしていたのですが、私の環境では動きませんでした。
.default(
sql`(strftime('%s', 'now'))`
),
調査に1mmも時間使ってないですが、なしにしたらとりあえず動いたので、それで進めました。
drizzle.config.tsを作成する
drizzleをいい感じに動かすためのコンフィグです。適宜自由に設定していただいても結構ですが、他の場所で参照してたりするので、とりあえずはこれと同じにしておくことをお勧めします。
import type { Config } from "drizzle-kit";
export default {
schema: "./src/schema.ts",
out: "./drizzle/migrations",
driver: "d1",
dbCredentials: {
wranglerConfigPath: "wrangler.toml",
dbName: "<DATABASE-NAME>", # FIXME
},
} satisfies Config;
migrationファイルを作成する
scriptsに以下のコマンドを登録しておきましょう。
...
"generate": "pnpm drizzle-kit generate:sqlite"
...
pnpm generate
> @ generate /private/tmp/my-app
> pnpm drizzle-kit generate:sqlite
drizzle-kit: v0.20.6
drizzle-orm: v0.29.1
No config path provided, using default 'drizzle.config.ts'
Reading config file '/private/tmp/my-app/drizzle.config.ts'
3 tables
bots 5 columns 1 indexes 0 fks
messages 6 columns 0 indexes 0 fks
users 5 columns 1 indexes 0 fks
[✓] Your SQL migration file ➜ drizzle/migrations/0000_needy_phil_sheldon.sql 🚀
こんな感じでmigrationファイルが生成されたら成功です!
migrations_dirを設定する
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "test-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# 追記する
migrations_dir = "drizzle/migrations"
localに対してmigrationしてみる
package.jsonのscriptsに以下のコマンドを登録しておきます。
...
"migrate:local": "wrangler d1 migrations apply <DB-NAME> --local",
"migrate": "wrangler d1 migrations apply <DB-NAME>"
...
ではローカルに対してmigrateしてみましょう。
pnpm migrate:local
> @ migrate:local /private/tmp/my-app
> wrangler d1 migrations apply test-db --local
Migrations to be applied:
┌─────────────────────────────┐
│ name │
├─────────────────────────────┤
│ 0000_needy_phil_sheldon.sql │
└─────────────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Mapping SQL input into an array of statements
🌀 Executing on local database test-db (f676b3fc-ed0a-479c-8c29-7381ebc91c23) from .wrangler/state/v3/d1:
┌─────────────────────────────┬────────┐
│ name │ status │
├─────────────────────────────┼────────┤
│ 0000_needy_phil_sheldon.sql │ ✅ │
└─────────────────────────────┴────────┘
はい、成功しました!
ついでに本番もマイグレーションしておきましょう。
pnpm migrate
追加でパッケージ入れる
zodがないと生きれない身体になってしまいました。
pnpm add zod @hono/zod-validator
CRUDを実装していく
例えばbotはこんな感じで実装しました。
export type Bindings = {
DB: D1Database;
};
CRUDを雑に実装しようとすると c.json()
のところでエラーが出ました。Date型をシリアライズできないっていうエラーですね。
雑にこんな感じで対処しましたが、もっといい方法ありそう・・・。
export function convertDatesToStr(data: any): any {
// オブジェクトの`Date`フィールドをISO文字列に変換
for (const key in data) {
if (data[key] instanceof Date) {
data[key] = data[key].toISOString();
}
}
return data;
}
import { drizzle } from "drizzle-orm/d1";
import { Hono } from "hono";
import { Bindings } from "./bindings";
import { bots, insertBotsSchema } from "./schema";
import { convertDatesToStr } from "./utils/db";
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { eq } from "drizzle-orm";
// D1使えるようにBindingsを渡す
const botRoute = new Hono<{ Bindings: Bindings }>();
botRoute.get('/', async (c) => {
const db = drizzle(c.env.DB);
const result = await db.select().from(bots).all();
return c.json(convertDatesToStr(result));
})
// zValidatorはいいぞ(後で解説します)
botRoute.get('/:id', zValidator('json', z.object({
id: z.number()
})), async (c) => {
const { id } = c.req.valid('json');
const db = drizzle(c.env.DB);
const result = await db.select().from(bots).where(eq(bots.id, id)).limit(1).get();
return c.json(convertDatesToStr(result));
})
// DBのschemaからinsertに必要な型を作成してくれる (drizzle-zodの魔法)
// ただtimestampはサーバー側で生成したいので、適当にomitしてる
botRoute.post('/', zValidator('json', insertBotsSchema.omit({ createdAt: true, updatedAt: true})), async (c) => {
const { name, lineChannelAccessToken } = c.req.valid('json');
const db = drizzle(c.env.DB);
const result = await db.insert(bots).values({
name,
lineChannelAccessToken,
createdAt: new Date(),
updatedAt: new Date()
}).execute();
return c.json(convertDatesToStr(result));
});
botRoute.delete('/:id', zValidator('json', z.object({
id: z.number()
})), async (c) => {
const { id } = c.req.valid('json');
const db = drizzle(c.env.DB);
const result = await db.delete(bots).where(eq(bots.id, id)).execute();
return c.json(convertDatesToStr(result));
});
export { botRoute }
ルートはこんな感じ
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { showRoutes } from 'hono/dev'
import { webhooksRoute } from './webhooks'
import { usersRoute } from './user';
import { botRoute } from './bot';
import { Bindings } from './bindings';
const app = new Hono<{ Bindings: Bindings }>()
// ロガーを追加
app.use('*', logger());
app.route('/api/webhooks', webhooksRoute);
app.route('/api/users', usersRoute)
app.route('/api/bots', botRoute)
export default app
// いい感じにRailsみたいなルートを表示してくれる
showRoutes(app)
webhooksのところとusersのところもほぼ一緒で特に何もないのでそんな感じで・・・。(本当は時間あればちゃんとLINEとの連携までやりたかった・・・)
deployしてみる
pnpm run deploy # pnpmにはdeployというサブコマンドがあるので、混同されないようにrunをつける
# https://pnpm.io/ja/cli/deploy
動作確認してみる
curl https://<app name>.<user name>.workers.dev/api/bots
[]
何も入ってないのでそれはそうw
適当にデータを登録してみよう。
curl -X POST -H 'Content-Type: application/json' -d '{"name": "test20", "lineChannelAccessToken": "hoge"}' https://<app name>.<user name>.workers.dev/api/bots
{"success":true,"meta":{"served_by":"v3-prod","duration":0.2303,"changes":1,"last_row_id":3,"changed_db":true,"size_after":45056,"rows_read":3,"rows_written":3},"results":[]}%
もう一度確認してみる
curl https://<app name>.<user name>.workers.dev/api/bots | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 404 100 404 0 0 3568 0 --:--:-- --:--:-- --:--:-- 3672
[
{
"id": 1,
"name": "test20",
"lineChannelAccessToken": "hoge",
"createdAt": "2023-12-11T15:29:39.000Z",
"updatedAt": "2023-12-11T15:29:39.000Z"
}
]
うまくいきました!
まとめ
Honoは本当にすごい。Cloudflareもすごい。D1もすごい。zodもすごい。
語彙力が低下してしまうほど、感動しました。
デプロイがめちゃくちゃ速いのいいですね。
某Functionsとかどんだけ時間かかるか・・・。
no plan株式会社について
- no plan株式会社は 「テクノロジーの力でZEROから未来を創造する、精鋭クリエイター集団」 です。
- ブロックチェーン/AI技術をはじめとした、Webサイト開発、ネイティブアプリ開発、チーム育成、などWebサービス全般の開発から運用や教育、支援なども行っています。よくわからない、ふわふわしたノープラン状態でも大丈夫!ご一緒にプランを立てていきましょう!
- no plan株式会社について
- no plan株式会社 | web3実績
- no plan株式会社 | ブログ一覧
エンジニアの採用も積極的に行なっていますので、興味がある方は是非ご連絡ください!
参考文献
Discussion
今更だけど、本当に言いたかったのはスキーマファーストではなく、コードファーストだった