MENU

問い合わせ


    【Honoで作る社内ヘルプデスク 第1回】チケット登録APIを作る:D1のマイグレーションとZodの入力チェック

    社内の問い合わせを受け付けて担当者が管理する「社内ヘルプデスク」を、Hono と Cloudflare Workers で作る連載の第1回です。今回は、問い合わせ1件を「チケット」として保存する登録APIを作ります。

    (2026年10月29日 追記:記事どおりに再現できるよう、必要なコードと手順を追加しました)

    前回は、連載の全体像と、Hono + Workers の雛形が動くところまでを確認しました。

    「社内ツールの入力フォームって、変な値が入ってきて後から困らないか心配…」

    結論から言うと、変な値の対策は「入口で1回だけ、決まったルールで検査する」ことに尽きます。この回では、データベースの表(テーブル)をマイグレーションで作り、Zod というライブラリで入力を検査し、検査を通ったものだけを D1 に保存します。第0回で作ったプロジェクトに続けて作業します。新しく作るファイルは全文を、書き換えるファイルは変更箇所(差分)を載せています。

    目次

    この回で作るものと、仕組みの全体像

    作るのは次の2つのAPIです。

    メソッドとパス役割成功時入力が不正なとき
    POST /api/ticketsチケットを1件登録する201(作成した内容を返す)400(どの項目が悪いかを返す)
    GET /api/tickets/:idチケットを1件取得する200存在しなければ 404

    使う部品は次のとおりです。専門用語は、初めて出てきたところで言い換えます。

    • D1:Cloudflare が提供するデータベース。中身は SQLite(小さくて軽い定番のデータベース)で、サーバーを自分で用意しなくても使えます。
    • マイグレーション:データベースの表の作り方を、番号付きのSQLファイルとして記録し、順番に適用する仕組み。「いつ・どんな変更をしたか」が残り、開発環境と本番を同じ状態に揃えられます。
    • Zod:入力が決めた形かどうかを検査するライブラリ。「件名は1〜100文字」のような決まりを、コードで書いて使い回せます。
    • バリデーション:入力の検査のこと。

    📰 出典:Hono公式「Validation」

    Hono のバリデーションは、検査を行う部品(ミドルウェア)をルートに差し込む形で使います。今回は Zod と組み合わせる @hono/zod-validator を使います。

    ステップ1:チケットの表をマイグレーションで作る

    第0回で作った接続確認用の表(app_meta)に続けて、チケット本体の表を 0002 として追加します。ファイル migrations/0002_create_tickets.sql を次の内容で作ります。

    -- migrations/0002_create_tickets.sql
    -- 第1回: チケット本体。状態の遷移ルールは第2回で追加する
    CREATE TABLE IF NOT EXISTS tickets (
      id           INTEGER PRIMARY KEY AUTOINCREMENT,
      title        TEXT NOT NULL,
      body         TEXT NOT NULL,
      category     TEXT NOT NULL CHECK (category IN ('pc', 'account', 'system', 'other')),
      priority     TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high')),
      status       TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')),
      requester    TEXT NOT NULL,
      created_at   TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
      updated_at   TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
    );
    
    CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets (status, created_at);

    ポイントは3つです。

    • 種別・優先度・状態は CHECK で値を限定しています。アプリの検査をすり抜けても、データベース側で不正な値を断れます(二重の防御です)。
    • 状態(status)は「受付 → 対応中 → 解決 → 完了」に対応する4値です。遷移のルール(受付から完了へ飛ばせない等)は第2回で作ります。
    • 日時は SQLite に文字列(UTC の ISO 形式)で持たせます。SQLite には専用の日時型がないための割り切りです。

    マイグレーションの適用は、ローカルの開発用データベースに対して次のコマンドで行います。

    npx wrangler d1 migrations apply helpdesk --local

    📰 出典:Cloudflare D1 公式「Migrations」

    --local を付けると、自分のパソコン上の仮のデータベースだけに適用されます。本番への適用は最終回で扱います。

    ステップ2:Zod で「受け付けてよい入力」を決める

    次に、受付フォームから届く入力のルールを src/schemas.ts に書きます。先に、Zod と、Hono で Zod を使うための部品を入れておきます(入れ方は第0回と同じく --ignore-scripts を付けます)。

    npm install zod@4 @hono/zod-validator --ignore-scripts

    入力のルールを書いた src/schemas.ts を、次の内容で作ります。

    // src/schemas.ts
    import { z } from 'zod'
    
    export const categories = ['pc', 'account', 'system', 'other'] as const
    export const priorities = ['low', 'normal', 'high'] as const
    
    // 受付フォームから届く入力。ここを通ったものだけを DB に入れる
    export const createTicketSchema = z.object({
      title: z.string().trim().min(1, '件名を入力してください').max(100, '件名は100文字以内です'),
      body: z.string().trim().min(1, '内容を入力してください').max(5000, '内容は5000文字以内です'),
      category: z.enum(categories),
      priority: z.enum(priorities).default('normal'),
      requester: z.string().trim().email('メールアドレスの形式で入力してください').max(254),
    })
    
    export type CreateTicketInput = z.infer<typeof createTicketSchema>

    📰 出典:Zod 公式ドキュメント

    読み方は次のとおりです。

    • trim() で前後の空白を取り除いてから検査するので、空白だけの件名は「空」として断れます。
    • max(...) で長さに上限を付けています。上限がないと、極端に長い文章で保存容量や画面表示を圧迫されます。
    • priority は省略されたら normal になります(default)。
    • 最後の z.infer は、このルールからTypeScript の型を自動で作るものです。ルールと型を別々に書かなくてよいので、食い違いが起きません。

    ステップ3:検査を通ったものだけを D1 に保存する

    ルートを src/routes/tickets.ts に書きます。検査の部品を差し込み、通過した入力だけを処理側で受け取ります。登録(POST)と1件取得(GET /:id)の2つを、次の内容で作ります。

    // src/routes/tickets.ts
    import { zValidator } from '@hono/zod-validator'
    import { Hono } from 'hono'
    import { createTicketSchema } from '../schemas'
    
    export const tickets = new Hono<{ Bindings: Env }>()
    
    // チケット登録。入力が不正なら 400 を返し、DB には触らない
    tickets.post(
      '/',
      zValidator('json', createTicketSchema, (result, c) => {
        if (!result.success) {
          return c.json(
            {
              error: 'invalid_request',
              issues: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
            },
            400,
          )
        }
      }),
      async (c) => {
        const input = c.req.valid('json')
        // 値は必ず bind() で渡す(文字列連結で SQL を組み立てない)
        const ticket = await c.env.DB.prepare(
          `INSERT INTO tickets (title, body, category, priority, requester)
           VALUES (?, ?, ?, ?, ?)
           RETURNING id, title, category, priority, status, requester, created_at`,
        )
          .bind(input.title, input.body, input.category, input.priority, input.requester)
          .first()
        return c.json(ticket, 201)
      },
    )
    
    tickets.get('/:id{[0-9]+}', async (c) => {
      const ticket = await c.env.DB.prepare('SELECT * FROM tickets WHERE id = ?')
        .bind(Number(c.req.param('id')))
        .first()
      if (!ticket) return c.json({ error: 'not_found' }, 404)
      return c.json(ticket)
    })

    📰 出典:Cloudflare D1 公式「Prepared statements」

    ここで大事なのは次の2点です。

    • 検査に失敗したら 400 を返して、データベースには触らない。どの項目がなぜ悪いかを issues に入れて返すので、画面側で項目ごとにエラーを出せます。
    • 値は必ず bind() で渡す。SQL の文字列に入力を直接つなげず、? の場所に値をはめ込む方式です。入力に SQL の断片が混ざっていても、ただの文字として保存されます。これは「SQLインジェクション(=入力に混ぜた命令でデータベースを操作される攻撃)」への基本的な備えです。

    📰 出典:IPA「安全なウェブサイトの作り方」

    最後に、作ったルートをアプリ本体に取り付けます。第0回で作った src/index.ts に次の変更を加えます(+ が追加、- が削除)。

    --- a/src/index.ts
    +++ b/src/index.ts
    @@ -1,5 +1,6 @@
     // src/index.ts
     import { Hono } from 'hono'
    +import { tickets } from './routes/tickets'
     
     const app = new Hono<{ Bindings: Env }>()
     
    @@ -13,4 +14,6 @@ app.get('/api/health', async (c) => {
       return c.json({ status: 'ok', schemaVersion: row?.value ?? null })
     })
     
    +app.route('/api/tickets', tickets)
    +
     export default app

    動作確認:テストとcurlで確かめる

    Workers の実行環境(workerd)の上で動くテストを test/tickets.test.ts に書き、次の5つを確かめました。

    • 正しい入力で 201 が返り、状態は open・優先度は normal になる
    • 件名が空だと 400 になり、title が悪いと返る
    • 一覧にない種別や、メールアドレスでない連絡先は 400
    • x'); DROP TABLE tickets;-- のような文字列も、そのまま保存されて表は壊れない
    • 存在しない ID は 404

    テストのファイルは次の内容で作ります。

    // test/tickets.test.ts
    import { SELF } from 'cloudflare:test'
    import { describe, expect, it } from 'vitest'
    
    const post = (body: unknown) =>
      SELF.fetch('http://example.com/api/tickets', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body),
      })
    
    const valid = {
      title: 'プリンターが印刷できない',
      body: '3階の複合機で昨日から印刷が止まります',
      category: 'pc',
      requester: 'taro@example.com',
    }
    
    describe('チケット登録 API', () => {
      it('正しい入力で 201 が返り、状態は open・優先度は normal になる', async () => {
        const res = await post(valid)
        expect(res.status).toBe(201)
        const ticket = await res.json<{ id: number; status: string; priority: string }>()
        expect(ticket.status).toBe('open')
        expect(ticket.priority).toBe('normal')
    
        const got = await SELF.fetch(`http://example.com/api/tickets/${ticket.id}`)
        expect(got.status).toBe(200)
      })
    
      it('件名が空なら 400 で、どの項目が悪いかが返る', async () => {
        const res = await post({ ...valid, title: '   ' })
        expect(res.status).toBe(400)
        const json = await res.json<{ issues: { path: string }[] }>()
        expect(json.issues.map((i) => i.path)).toContain('title')
      })
    
      it('一覧にない種別・メールアドレスでない連絡先は 400', async () => {
        expect((await post({ ...valid, category: 'unknown' })).status).toBe(400)
        expect((await post({ ...valid, requester: 'not-an-email' })).status).toBe(400)
      })
    
      it('SQL を含む文字列もそのまま保存され、テーブルは壊れない', async () => {
        const res = await post({ ...valid, title: "x'); DROP TABLE tickets;--" })
        expect(res.status).toBe(201)
        expect((await post(valid)).status).toBe(201)
      })
    
      it('存在しない ID は 404', async () => {
        const res = await SELF.fetch('http://example.com/api/tickets/999999')
        expect(res.status).toBe(404)
      })
    })

    次のコマンドで、型の作り直し・型チェック・テストを行います。

    npx wrangler types
    npm run typecheck
    npm test

    本記事の執筆時点(2026年10月)で、型チェックとテスト7件(第0回の2件を含む)が通ることを確認しています。あわせて、ローカルのデータベースにマイグレーションを適用してから npx wrangler dev で起動し、別の画面から curl で実際に登録・取得・不正入力の拒否を確かめました。

    curl -X POST http://localhost:8787/api/tickets \
      -H 'content-type: application/json' \
      -d '{"title":"VPNにつながらない","body":"自宅から接続できません","category":"account","priority":"high","requester":"hanako@example.com"}'

    なお、Cloudflare の実環境(本番アカウント)へのデプロイはこの回では行っておらず、未確認です。

    つまずきやすい点と注意

    • 検査はフロントエンドだけに頼らない。 画面側の入力チェックは親切のためで、安全のためではありません。必ずサーバー側(今回の API)でも検査します。
    • CHECK と Zod の定義を二重管理しない工夫が必要。 種別を増やすときは、マイグレーション(CHECK)と schemas.ts の両方を直す必要があります。今回は小規模なので割り切っており、種別が頻繁に増えるなら別の表に切り出す設計を検討します。
    • この段階ではログインがない。 誰でも登録できる状態です。認証は第3回で付けるので、公開環境には置かないでください。
    • メールアドレスの形式チェックは「それらしい形」の確認にとどまる。 実在するか、本人のものかは確かめていません。

    発注者向けメモ:入力チェックを頼むときの確認点

    この回の内容は、開発会社に社内ツールを頼むときの「入力まわりの品質」に直結します。打ち合わせで使える形にまとめます。

    発注者がやること(チェックリスト):

    • ☐ 入力項目ごとに「必須か」「最大何文字か」「選べる値は何か」を一覧にして渡す(または一緒に決める)
    • ☐ エラーが出たとき、利用者に「どこをどう直せばいいか」が表示されるかを確認する
    • ☐ 画面の入力チェックとは別に、サーバー側でも検査しているかを確認する
    • ☐ データベースの表の変更履歴(マイグレーション)が、納品物に含まれるかを確認する
    • ☐ 種別や状態の選択肢を後から増やす場合、どの作業が必要になるかを聞いておく

    開発会社への質問例:

    • 「入力の検査はサーバー側でも行っていますか。画面側の検査とルールは同じ定義から作っていますか」
    • 「SQLインジェクション対策として、入力値はどのようにデータベースへ渡していますか」
    • 「表の構造を変えたとき、本番のデータを壊さずに反映する手順はどうなっていますか」
    • 「入力できる文字数や添付の上限は、どこで決めて、後から変えられますか」

    なお、入力項目が増えるほど、検査のルールと画面の作り込みが必要になり、工数が増えます。項目の絞り込みは、費用を抑える有効な手段です。

    まとめと次回予告

    今回は、D1 にチケットの表を作り、Zod で入力を検査してから保存する登録API(POST /api/tickets)と、取得API(GET /api/tickets/:id)を作りました。「入口で検査し、値は bind() で渡し、表の側にも CHECK を置く」という三重の備えが、小さな社内ツールでも土台になります。

    次回の第2回では、チケットの一覧・詳細・状態遷移を作ります。一覧のページ分けと、「受付から完了へ飛ばせない」といった状態の遷移ルールを、テストつきで実装する予定です。

    この連載の記事一覧

    この記事は連載「Honoで作る社内ヘルプデスク」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      目次