🔥

Honoで作るスキーマファーストなAPI(を完成させたかった・・・)

2023/12/12に公開1

はじめに

こんにちは〜!皆様いかがお過ごしでしょうか? no plan inc. CTOの @serinuntius です。
これはno plan inc.の Advent Calendar 2023の12日目の記事です。

ずっとHonoを使って何かを作ってみたいと思っていまして、作っておくと便利そうなものを作りました。
テーマはLine Messaging APIです。

課題

LineのMessaging APIを無料で利用しようとすると、友達追加してくれたuserIdのリストができません。

ドキュメントによると4つの方法があるようです。

  1. 開発者が自分自身のユーザーIDを取得する
  2. WebhookからユーザーIDを取得する
  3. 友だち全員のユーザーIDを取得する
  4. グループトークや複数人トークのメンバーのユーザー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にコピーします。

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を作成していきます

適当に今回のモデリングをしてみました。一旦こんな感じで行ってみましょう。

src/schema.ts
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をいい感じに動かすためのコンフィグです。適宜自由に設定していただいても結構ですが、他の場所で参照してたりするので、とりあえずはこれと同じにしておくことをお勧めします。

drizzle.config.ts
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に以下のコマンドを登録しておきましょう。

package.json
...
    "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を設定する

wrangler.toml
[[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に以下のコマンドを登録しておきます。

package.json
...
    "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はこんな感じで実装しました。

src/bindings.d.ts
export type Bindings = {
    DB: D1Database;
};

CRUDを雑に実装しようとすると c.json() のところでエラーが出ました。Date型をシリアライズできないっていうエラーですね。
雑にこんな感じで対処しましたが、もっといい方法ありそう・・・。

src/utils/db.ts
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;
}
src/bot.ts

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 }

ルートはこんな感じ

src/index.ts
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株式会社 | ブログ一覧

エンジニアの採用も積極的に行なっていますので、興味がある方は是非ご連絡ください!

参考文献

https://hono.dev/getting-started/cloudflare-workers
https://hono.dev/guides/validation#with-zod
https://developers.cloudflare.com/d1/
https://qiita.com/kmkkiii/items/2b22fa53a90bf98158c0
https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
https://github.com/drizzle-team/drizzle-orm/tree/main/drizzle-zod
https://developers.line.biz/ja/docs/messaging-api/receiving-messages/#webhook-event-types

Discussion

serinuntiusserinuntius

今更だけど、本当に言いたかったのはスキーマファーストではなく、コードファーストだった