Tutorial/tutorials

第 2 章: リクエストをひとつ、手で

第 1 章で手にしたアプリには、あなたが書いていないページがひとつありました。この章では次のページを空のファイルから、以降のコースが使う順序で書きます。失敗するテスト、ルート、コントローラー、ページの順です。その後、2 つ目のページをテストで仕様化してエージェントに委ね、ハーネスが編集中のファイルに合った rule を読み込む様子を見届けます。

第 2 章: リクエストをひとつ、手で

第 1 章で手にしたアプリには、あなたが書いていないページがひとつありました。この章では次のページを空のファイルから、以降のコースが使う順序で書きます。失敗するテスト、ルート、コントローラー、ページの順です。その後、2 つ目のページをテストで仕様化してエージェントに委ね、ハーネスが編集中のファイルに合った rule を読み込む様子を見届けます。

この章で学ぶこと:

  • リクエストが routes/web.ts からコントローラーのメソッドを経て Response になるまでの道筋
  • 素の Response と Inertia ページの違い、それぞれで十分な場面
  • bun run codegen がページの Props から何を導出するのか、なぜ pages.about.Index がコンパイル時の名前なのか
  • ルートに名前を付け、型付きの route() ヘルパーでリンクする方法
  • ハーネスの glob スコープ rule が、そのファイルを編集するときにだけエージェントへ届く仕組み

開発サーバーを止めていたら起動し、専用のターミナルで動かしたままにしてください。

bun run dev

1. まずテスト

ページはまだ存在しません。何をすべきかを先に書きます。

tests/AboutController.test.tsimport { beforeAll, describe, it } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'

describe('AboutController', () => {
  let http: TestApp

  beforeAll(async () => {
    http = await TestApp.fromApp(app)
  })

  it('serves the about page', async () => {
    const response = await http.get('/about').assertOk()
    await response.assertBodyContains('About Guren Blog')
  })
})
bun test

新しいテストは 404 で失敗します。/about に答えるものが無いからです。第 1 章の 2 件は通ったままです。では新しいテストを、一層ずつ通していきましょう。

2. ルート

ルートはメソッドとパスをコントローラーのアクションに対応付けます。routes/web.ts を置き換えます。

routes/web.tsimport { Router } from '@guren/core'
import HomeController from '../app/Http/Controllers/HomeController.js'
import AboutController from '../app/Http/Controllers/AboutController.js'

export function registerWebRoutes(router: Router): void {
  router.get('/', [HomeController, 'index'])
  router.get('/about', [AboutController, 'index']).name('about')

  // Health check endpoint for load balancers and uptime monitors
  router.get('/health', (c) => c.json({ status: 'ok' }))
}

新しい点が 2 つあります。[AboutController, 'index'] は関数ではなくクラスとメソッドを指名しています。Guren はリクエストごとにコントローラーをインスタンス化するので、メソッドは this を通してリクエストを読めます。そして .name('about') がルートに名前を付けます。URL は変わりますが、ページがリンクするのは名前です。

もう一度テストを走らせると、違う理由で失敗します。AboutController の import が解決できず、アプリが起動できないのです。それは guren check の仕事でもありますが、テストが先に見つけました。

3. コントローラー、まずは素の Response で

app/Http/Controllers/AboutController.ts を作ります。

app/Http/Controllers/AboutController.tsimport { Controller } from '@guren/core'

export default class AboutController extends Controller {
  async index(): Promise<Response> {
    return this.text('About Guren Blog')
  }
}
bun test

緑です。コントローラーのアクションとは Response を返すメソッドで、this.text() は素の Response を組み立てます。それが契約のすべてで、ページが間に挟まらない形で一度見ておく価値があります。コントローラーの他のすべて(this.inertia()this.json()this.redirect()、第 4 章で出会うバリデーター)は、同じ Response を組み立てる別のやり方にすぎないからです。

http://localhost:3333/about を開いてください。約束どおり、プレーンテキストです。

4. 次はページ

素の Response はヘルスチェックや webhook には正解です。ページには HTML が要り、Guren ではそれが Inertia ページです。resources/js/pages/ 配下の React コンポーネントで、props はコントローラーから受け取ります。作りましょう。

resources/js/pages/about/Index.tsximport { Head, Link } from '@inertiajs/react'

interface Props {
  title: string
  description: string
}

export default function AboutIndex({ title, description }: Props) {
  return (
    <>
      <Head title={title} />
      <main className="min-h-screen bg-g-page font-sans text-g-text">
        <div className="mx-auto max-w-3xl space-y-6 px-6 py-12">
          <h1 className="flex items-center gap-3 text-3xl font-bold text-g-heading">
            <span aria-hidden className="h-7 w-[3px] shrink-0 rounded-full bg-[image:var(--g-tick)]" />
            {title}
          </h1>
          <p className="text-lg text-g-text-2">{description}</p>
          <Link href="/" className="text-sm text-g-accent-text transition hover:underline">
            Back to the front page
          </Link>
        </div>
      </main>
    </>
  )
}

コンポーネントの Props インターフェースは React のためだけのものではありません。codegen がこれを読み、about/Index という名前のページが titledescription という 2 つの文字列を受け取ることを記録します。マニフェストを再生成します。

bun run codegen

.guren/pages.gen.tspages.about.Index ができ、this.inertia() は一致しない props を拒否するようになります。コントローラーをページに向けます。

app/Http/Controllers/AboutController.tsimport { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'

export default class AboutController extends Controller {
  async index(): Promise<Response> {
    return this.inertia(pages.about.Index, {
      title: 'About Guren Blog',
      description: 'A blog built chapter by chapter, by hand and by agent.',
    })
  }
}
bun test

まだ緑ですが、今度は望んだ理由で緑です。コントローラーがタイトルを prop として送ったから、本文にタイトルが含まれています。ブラウザで /about をリロードすると、まずサーバーでレンダリングされ、それからブラウザ側で React が引き継ぐページが表示されます。試しにコントローラーから description を消して bun run typecheck を走らせてみてください。エラーはページ名と欠けた prop を名指しします。戻しておきましょう。

この節から持ち帰るものは 3 つです。

  • ページ名はファイルパスです。 resources/js/pages/about/Index.tsxpages.about.Index です。ファイルを改名すれば次の codegen で名前も変わり、古い名前を使っていたコントローラーはすべてコンパイルできなくなります。
  • props が契約です。 コントローラーはページが宣言したとおりのものを送ります。形が書かれている場所は他にありません。
  • codegen は覚えておいて実行するビルド手順ではありません。 bun run dev が起動時に実行し、ルート、ページ、リソースの変更を監視します。bunx guren gate も最初に実行します。ここで手動で走らせたのは、何をするかを見るためです。

変更全体を確かめてコミットします。

bunx guren gate
git add -A
git commit -m "feat: add the about page"

5. 次のスライスを仕様化する

同じ作り方の contact ページです。今度はテストだけを書きます。

tests/ContactController.test.tsimport { beforeAll, describe, it } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'

describe('ContactController', () => {
  let http: TestApp

  beforeAll(async () => {
    http = await TestApp.fromApp(app)
  })

  it('serves the contact page', async () => {
    const response = await http.get('/contact').assertOk()
    await response.assertBodyContains('Contact')
    await response.assertBodyContains('hello@guren-blog.test')
  })
})
bun test

赤です。このテストが仕様です。誰がページを作ろうと、これが通れば完成です。

委ねる前に知っておくべきことがひとつあります。テストランナーの中では、ページは HTML にレンダリングされません。レスポンスが運ぶのはページ名とその props で、assertBodyContains はそれを検索します。つまりテストに見えるのは、コントローラーが送るものであって、コンポーネントが書くものではありません。アドレスは prop でなければなりません。これは回避すべき制約ではなく、コンテンツの置き場所をテストが教えてくれているのです。第 1 章のタグラインが prop だった理由でもあります。

6. 委ねる

guren-blog の中でエージェントに頼みます。

Add a /contact page the way /about was built: a ContactController with an index action that sends title: 'Contact' and email: 'hello@guren-blog.test' as props, a page at resources/js/pages/contact/Index.tsx that shows the title as a heading and the email as a mailto link, and a route named contact in routes/web.ts. tests/ContactController.test.ts already describes it; make it pass.

作業中、この章のハーネス要素を見守ってください。エージェントのコンテキストはすべての rule を常に抱えているわけではありません。.claude/rules/routes-codegen.md はこう始まります。

---
description: Guren routing & codegen — RouteContractOptions, schema binding, the Zod→ApiRoutes matrix, middleware
globs:
  - "routes/**"
  - "app/Http/Validators/**"
---

肝は globs の行です。この rule はエージェントが routes/ 配下のファイルを編集するときに読み込まれ、それまでは読まれません。controllers-http.mdapp/Http/** に対して同じことをします。だからエージェントが routes/web.ts を開いた瞬間、router.get(...) の正確な形、options オブジェクト、.name() が、このバージョンのフレームワークに対して検証済みの状態で、必要なまさにそのときに手渡されます。本物が目の前にあるので、記憶からルート API をでっち上げることはできません。そしてファイルを保存すると PostToolUse hook が guren check を走らせ、存在しないコントローラーメソッドを指すルートがあれば報告します。

手元にエージェントが無い場合は、 3 ファイルです。(エージェントは bunx guren make:controller Contact から始めるかもしれません。これは pages.contact.Index をレンダリングするコントローラーの骨組みを書きます。その習慣については第 3 章で扱います。)

app/Http/Controllers/ContactController.tsimport { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'

export default class ContactController extends Controller {
  async index(): Promise<Response> {
    return this.inertia(pages.contact.Index, {
      title: 'Contact',
      email: 'hello@guren-blog.test',
    })
  }
}
resources/js/pages/contact/Index.tsximport { Head, Link } from '@inertiajs/react'

interface Props {
  title: string
  email: string
}

export default function ContactIndex({ title, email }: Props) {
  return (
    <>
      <Head title={title} />
      <main className="min-h-screen bg-g-page font-sans text-g-text">
        <div className="mx-auto max-w-3xl space-y-6 px-6 py-12">
          <h1 className="flex items-center gap-3 text-3xl font-bold text-g-heading">
            <span aria-hidden className="h-7 w-[3px] shrink-0 rounded-full bg-[image:var(--g-tick)]" />
            {title}
          </h1>
          <p className="text-lg text-g-text-2">
            Write to <a href={`mailto:${email}`} className="text-g-accent-text hover:underline">{email}</a>.
          </p>
          <Link href="/" className="text-sm text-g-accent-text transition hover:underline">
            Back to the front page
          </Link>
        </div>
      </main>
    </>
  )
}
routes/web.tsimport { Router } from '@guren/core'
import HomeController from '../app/Http/Controllers/HomeController.js'
import AboutController from '../app/Http/Controllers/AboutController.js'
import ContactController from '../app/Http/Controllers/ContactController.js'

export function registerWebRoutes(router: Router): void {
  router.get('/', [HomeController, 'index'])
  router.get('/about', [AboutController, 'index']).name('about')
  router.get('/contact', [ContactController, 'index']).name('contact')

  // Health check endpoint for load balancers and uptime monitors
  router.get('/health', (c) => c.json({ status: 'ok' }))
}

どちらの場合も、再生成して仕様を走らせます。

bun run codegen
bun test

受け入れる前にレビューします。rubric は次のとおりです。

  • routes/web.ts の追加は、contact と名付けた GET /contact ルート 1 行とその import だけ。他は動いていない。
  • ContactController はアクションひとつでページをレンダリングしている。HTML を手で組み立てたり this.text() を返したりしていない。
  • アドレスはコントローラーが送る prop であって、ページにハードコードされた文字列ではない。resources/js/pages/contact/Index.tsx は両方の prop を Props インターフェースで宣言している。
  • tests/ContactController.test.ts は手つかずで緑、他もすべて緑。
bunx guren gate
git add -A
git commit -m "feat: add the contact page"

いまいる場所

  • リクエストひとつをすべての層で追い、各層を自分で書きました。
  • codegen がページから何を導出するか、ページ名と props がコンパイル時に検査されることを知りました。
  • 存在しないページをテストで仕様化し、委ね、rubric に照らして受け入れました。
  • 頼んだからではなく、開いたファイルが理由で rule がエージェントに届くのを見ました。

よくあるつまずき

  • pages.about.Index が存在しない。 ページを作ってから codegen が走っていません。bun run codegen を実行するか、bun run dev に任せてください。開発サーバーは、動作中にページが追加されると再生成します。
  • テストは通るのにブラウザは古いページを表示する。 最後の保存前に開発サーバーがレンダリングし、Inertia が古い props を保持しています。キャッシュを無効にしてリロードするか、bun run dev を動かしているターミナルで codegen のエラーを確認してください。
  • エージェントが HTML 入りの this.text() を返してきた。 動きますしテストも通ります。だからこそ rubric は、テストが検査することだけでなく、コントローラーがすべきことを書いています。ページをレンダリングするよう頼み直してください。このコースで何度もやることになる修正です。
  • guren check がコントローラーにテストが無いと警告する。 tests/<Name>Controller.test.ts を探しています。両方書きましたね。別のコントローラー名が出ているなら、それは第 3 章の仕事です。

演習

  1. /health はコントローラーではなくインラインのハンドラーです。ブランチを切って、現在時刻を返すインラインのルートをもう 1 本足してください。そして、コントローラーのアクションではなくこの書き方を選ぶと何を失うかを答えてください。
  2. this.inertia(pages.about.Index, …) は文字列ではなく生成されたコードからページを受け取ります。存在しないページに変えて、TypeScript のエラーを読んでください。そのエラーが pages.* の存在理由そのものです。読んだら戻してください。

次へ

第 3 章: posts テーブル では、最初のデータベーステーブルとモデル、それを読む 2 つのページを追加し、作成フォームをエージェントに委ねます。