Movatterモバイル変換


[0]ホーム

URL:


Zenn
no plan incテックブログno plan incテックブログ
no plan incテックブログPublicationへの投稿
🔥

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-appcreate-hono version0.3.2✔ Using target directory … my-app? Which templatedo 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  ↓ nextjscd my-apppnpm i

まずはローカルサーバーを立ち上げてみる

pnpm dev> @ dev /private/tmp/my-app> wrangler dev src/index.ts ⛅️ wrangler3.19.0-------------------⎔ Startinglocal server...[wrangler:inf] Ready on http://localhost:8787

こんな感じの出力が出るので、http://localhost:8787 にアクセスしてみましょう。

curl http://localhost:8787Hello Hono!

と出力されましたか?

試しにsrc/index.ts を適当にエディットして、レスポンスの文字列を変えてみましょう。

当たり前のようにホットリロードがされましたね?最高だぜ!

curl http://localhost:8787Hello 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 APACCreated your database using D1's new storage backend. The new storage backend is not yet recommendedfor productionworkloads, but backs up your data via point-in-time restore.[[d1_databases]]binding="DB"# i.e. available in your Worker on env.DBdatabase_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.DBdatabase_name="test-db"database_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

drizzleをインストールする

pnpmadd drizzle-orm drizzle-zodpnpmadd-D drizzle-kit

DBのshemaを作成していきます

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

src/schema.ts
import{ sqliteTable, integer, text}from"drizzle-orm/sqlite-core";import{ createInsertSchema, createSelectSchema}from'drizzle-zod';exportconst 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()})exportconst insertUsersSchema=createInsertSchema(users);exportconst selectUsersSchema=createSelectSchema(users);exportconst 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()})exportconst insertBotsSchema=createInsertSchema(bots);exportconst selectBotsSchema=createSelectSchema(bots);exportconst 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()})exportconst insertMessagesSchema=createInsertSchema(messages);exportconst selectMessagesSchema=createSelectSchema(messages);

他の記事ではこんな感じでtimestampの設定をしていたのですが、私の環境では動きませんでした。

.default(    sql`(strftime('%s','now'))`),

調査に1mmも時間使ってないですが、なしにしたらとりあえず動いたので、それで進めました。

drizzle.config.tsを作成する

drizzleをいい感じに動かすためのコンフィグです。適宜自由に設定していただいても結構ですが、他の場所で参照してたりするので、とりあえずはこれと同じにしておくことをお勧めします。

drizzle.config.ts
importtype{ Config}from"drizzle-kit";exportdefault{  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:sqlitedrizzle-kit: v0.20.6drizzle-orm: v0.29.1No config path provided, using default'drizzle.config.ts'Reading configfile'/private/tmp/my-app/drizzle.config.ts'3 tablesbots5 columns1 indexes0 fksmessages6 columns0 indexes0 fksusers5 columns1 indexes0 fks[] Your SQL migrationfile ➜ drizzle/migrations/0000_needy_phil_sheldon.sql 🚀

こんな感じでmigrationファイルが生成されたら成功です!

migrations_dirを設定する

wrangler.toml
[[d1_databases]]binding="DB"# i.e. available in your Worker on env.DBdatabase_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--localMigrations to be applied:┌─────────────────────────────┐│ name                        │├─────────────────────────────┤│ 0000_needy_phil_sheldon.sql │└─────────────────────────────┘✔ About to apply1 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 onlocal database test-db(f676b3fc-ed0a-479c-8c29-7381ebc91c23) from .wrangler/state/v3/d1:┌─────────────────────────────┬────────┐│ name                        │ status │├─────────────────────────────┼────────┤│ 0000_needy_phil_sheldon.sql │ ✅       │└─────────────────────────────┴────────┘

はい、成功しました!

ついでに本番もマイグレーションしておきましょう。

pnpm migrate

追加でパッケージ入れる

zodがないと生きれない身体になってしまいました。

pnpmadd zod @hono/zod-validator

CRUDを実装していく

例えばbotはこんな感じで実装しました。

src/bindings.d.ts
exporttypeBindings={DB: D1Database;};

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

src/utils/db.ts
exportfunctionconvertDatesToStr(data:any):any{// オブジェクトの`Date`フィールドをISO文字列に変換for(const keyin data){if(data[key]instanceofDate){            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=newHono<{ 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:newDate(),        updatedAt:newDate()}).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=newHono<{ Bindings: Bindings}>()// ロガーを追加app.use('*',logger());app.route('/api/webhooks', webhooksRoute);app.route('/api/users', usersRoute)app.route('/api/bots', botRoute)exportdefault 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  Speed1004041004040035680 --:--:-- --:--:-- --:--:--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

serinuntius

人類ネコ化計画を企み @noplan_incを起業しCTOに🚀 ブロックチェーン⛓の世界から人類をハックしています🧠 本業はお昼寝😴INTP🧑‍🔬/ 最近のお気に入りはRust🦀/Mastra/Cloudflare/ピックルボール🏓

バッジを贈って著者を応援しよう

バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。

serinuntiusserinuntius

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


[8]ページ先頭

©2009-2025 Movatter.jp