Guide/guides

ルーティングガイド

Guren は Hono の HTTP サーバー上に Laravel 風のルーティング DSL を提供します。推奨構成では routes/web.ts が registrar 関数を export し、アプリ起動時に app-local な Router へルートを登録します。

ルーティングガイド

Guren は Hono の HTTP サーバー上に Laravel 風のルーティング DSL を提供します。推奨構成では routes/web.ts が registrar 関数を export し、アプリ起動時に app-local な Router へルートを登録します。

基本の使い方

routes/web.ts を作成または編集し、Router と使用するコントローラーをインポートします。

import { Router } from '@guren/core'
import PostsController from '@/app/Http/Controllers/PostsController'

export function registerWebRoutes(router: Router): void {
  router.get('/', [PostsController, 'index'])
  router.post('/posts', [PostsController, 'store'])
}

各ルートはパスと以下のいずれかを受け取ります。

  • コントローラータプル [ControllerClass, 'method']
  • インラインハンドラー (ctx) => new Response('...')

利用できるメソッドは router.getrouter.postrouter.putrouter.patchrouter.deleterouter.query、そして汎用の router.on(method, path, handler) です。

QUERY メソッド

router.query() は HTTP QUERY メソッド(RFC 10008)のルートを登録します。GET と同じく安全(safe)かつ冪等ですが、POST のようにリクエストボディを持てます。URL に収まらない複雑な検索・フィルタ条件を受け取るエンドポイントに使ってください。

import { z } from 'zod'

router.query('/posts/search', {
  name: 'posts.search',
  body: z.object({ keywords: z.array(z.string()), limit: z.number().default(20) }),
}, [PostsController, 'search'])

利用前に知っておくべきこと:

  • ハンドラーで状態を変更してはいけません。 QUERY は安全なメソッドであり、Guren の CSRF 保護はその前提で QUERY をスキップします(ブラウザは CORS プリフライトなしに QUERY を送信できないため、ハンドラーが読み取り専用である限り CSRF の心配はありません)。それでも CSRF トークンを要求したい場合は、CSRF ミドルウェアの methods オプションに 'QUERY' を追加してください。
  • 呼び出しは fetch か生成された API クライアント(client.request('posts.search', { body }))で行います。HTML フォームや Inertia のフォームヘルパーは QUERY を送信できません。
  • デプロイ経路を確認してください。 Guren の fetch ベースのアダプター(Bun、Cloudflare Workers / Vercel プラグイン)は QUERY をブロックしませんが、プラットフォーム側の入口が QUERY を受け付けるかは確認が必要です。旧来のメソッドセット以外を拒否するプロキシや CDN があり、特に Lambda プラグインのアセット配信で前段に入る CloudFront は QUERY を転送しません。また、中間キャッシュによる QUERY レスポンスのキャッシュもまだ広くは実装されていません。
  • OpenAPI 3.1 は QUERY を表現できないため、guren openapi:generate は QUERY ルートを警告付きでスキップします。
  • クライアントに対応を広告するには、リソースの GET ハンドラーなどで Accept-Query レスポンスヘッダーを自分で設定してください(例: ctx.header('Accept-Query', 'application/json'))。

ルートグループ

router.group(prefix, callback) を使って、共通のパスプレフィックスとミドルウェアを適用できます。

router.group('/posts', (posts) => {
  posts.get('/', [PostsController, 'index'])
  posts.get('/:id', [PostsController, 'show'])
})

グループはネスト可能です。プレフィックスは自動的にトリミングされるため、/posts + /new/posts/new になります。

ミドルウェア

ルート単位のミドルウェア

ハンドラーに .middleware() をチェーンして適用できます。

import { Router, requireAuthenticated } from '@guren/core'
import { requireAdmin } from '@/app/Http/middleware/admin'

export function registerWebRoutes(baseRouter: Router): void {
  const router = baseRouter
    .aliasMiddleware('auth', requireAuthenticated())
    .aliasMiddleware('admin', requireAdmin())

  router.get('/admin', [AdminController, 'index']).middleware('auth', 'admin')
}

ミドルウェアエイリアス

ミドルウェア関数に短い名前を登録しておけば、ルート全体で文字列で参照できます。

import { Router, requireAuthenticated } from '@guren/core'
import { requireAdmin } from '@/app/Http/middleware/admin'

export function registerWebRoutes(baseRouter: Router): void {
  const router = baseRouter
    .aliasMiddleware('auth', requireAuthenticated())
    .aliasMiddleware('admin', requireAdmin())
}

rule

aliasMiddleware() は登録済みのエイリアス名を型に載せた新しい Routerを返します。戻り値を受け取らずに呼び出すと登録名が型に伝わらず、後続の .middleware('auth') が型エラーになります。上記のように必ずチェーンして受け取ってください。

エイリアスを登録すれば、ミドルウェアが受け入れられる場所ならどこでも文字列名で使えます。

router.get('/dashboard', [DashboardController, 'index']).middleware('auth')
router.get('/admin', [AdminController, 'index']).middleware('auth', 'admin')

ミドルウェアグループ

よく使うミドルウェアの組み合わせを一つの名前にまとめられます。グループのメンバーは、先にエイリアス登録済みの名前でなければなりません。

const router = new Router()
  .aliasMiddleware('auth', requireAuthenticated())
  .aliasMiddleware('admin', requireAdmin())
  .aliasMiddleware('session', createSessionMiddleware())
  .aliasMiddleware('csrf', createCsrfMiddleware())
  .aliasMiddleware('throttle', createRateLimitMiddleware({ limit: 60, windowMs: 60_000 }))
  .groupMiddleware('web', ['session', 'csrf'])
  .groupMiddleware('api', ['throttle'])

ミドルウェアグループをルートグループに適用します。

router.middleware('web').group((web) => {
  web.get('/', [HomeController, 'index'])
  web.get('/about', [PagesController, 'about'])
})

router.middleware('auth').group((auth) => {
  auth.get('/dashboard', [DashboardController, 'index'])
  auth.get('/settings', [SettingsController, 'index'])
})

ミドルウェアグループと個別のエイリアスは自由に組み合わせられます。

router.middleware('web', 'auth').group((group) => {
  group.get('/profile', [ProfileController, 'show'])
})

グローバル登録パターン、ビルトインヘルパー、セッションサポートの詳細は、専用のミドルウェアガイドをご覧ください。

ルートパラメータ

動的パラメータは Hono の構文に従います。

router.get('/posts/:id', [PostsController, 'show'])

コントローラー内では this.validateParams()this.ctx.req.param('id') でパラメータを読み取ります。

オプショナルセグメント(router.get('/posts/:id?', handler))や正規表現制約(router.get('/items/:id{[0-9]+}', handler))も Hono のパターンサポートで利用できます。複数セグメントにまたがるマッチには :path{.+} のような制約付きパラメータを使います。なお /:slug* は Hono のワイルドカード構文ではありません。slug* という名前(アスタリスク込み)の単一セグメントパラメータとして登録され、/files/x/y のような複数セグメントには 404 を返します。this.ctx.req.param('slug*') のようにアスタリスク込みのキーで読む必要があるため、使わないでください。

ルートモデルバインディング

毎回 findOrFail() を書きたくない場合は、ルートパラメータにモデルをバインドできます。ルートの bind オプションでバインディングを宣言し、コントローラーでは this.model() でレコードを受け取ります。

import { PostResource } from '@/app/Http/Resources/PostResource'
import { pages } from '@/.guren/pages.gen'

// routes/web.ts: :id は Post.findOrFail(id) で解決される
router.get('/posts/:id', { bind: { id: Post }, name: 'posts.show' }, [PostsController, 'show'])

async show() {
  const post = this.model(Post)  // PostRecord として型付け済み
  return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}

レコードが見つからなければ自動的に 404 を返します。

主キー以外のカラムでバインドする

モデルクラスだけを渡した場合は常に主キーで検索します。slug など別のユニークカラムで解決したいときは [Model, column] のタプルを渡してください。ルーターは Post.findOrFail(value, 'slug') を呼び、this.model(Post) は同じレコードを返します。

router.get('/posts/:slug', { bind: { slug: [Post, 'slug'] }, name: 'posts.show' }, [PostsController, 'show'])

async show() {
  const post = this.model(Post)  // slug で解決済み
  // ...
}

カラム名はただの文字列です。綴りを間違えると 404 ではなくクエリ自体が失敗するので、スキーマと揃えてください。同じパラメータをルーター(後述)とルートの両方でバインドした場合はルート側の bind が優先され、検索は 1 回だけ行われます。

ルーターレベルのバインディング

router.bind(param, ...) は、そのルーターに登録されたコントローラータプルのルートのうち、パスに同名パラメータを含むものすべてに対して一度でバインドします。bind オプションと同じモデル形式(Post または [Post, 'slug'])に加え、独自 resolver 関数も渡せます。

router.bind('post', Post)                    // 主キーで検索
router.bind('post', [Post, 'slug'])          // slug で検索
router.bind('post', async (value) => Post.where('slug', value).firstOrFail())  // 独自 resolver

router.get('/posts/:post', [PostsController, 'show'])

ルーターレベルのバインディングで解決された値は、コンテキストの後ろに位置引数としてパスパラメータの順に渡されます。モデルバインディング(Post[Post, 'slug'])は this.model(Post) でも受け取れますが、独自 resolver の値は参照するためのモデルクラスがないため位置引数でのみ受け取れます。

import type { Context } from '@guren/core'

async show(_ctx: Context, post: PostRecord) {
  return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}

note

バインドされた値は Hono のコンテキストには格納されません。this.ctx.get('post')undefined を返すので、this.model(Post) か位置引数を使ってください。バインディングが解決されるのはコントローラータプルのルートだけです。インラインハンドラーは Hono の (ctx, next) を受け取るため、レコードは自分で取得してください。

ブートストラップ

src/app.ts で registrar を createApp() に渡します。

// src/app.ts
import { createApp } from '@guren/core'
import registerWebRoutes from '@/routes/web'

const app = createApp({
  routes: registerWebRoutes,
})

カスタムハンドラー

インラインハンドラーを使えば、コントローラーなしで Hono の Context を直接扱えます。

router.get('/health', (ctx) => ctx.json({ ok: true }))

ヘルスチェックや Webhook のような軽量エンドポイントに便利です。

Tips

  • routes/web.ts は HTTP 定義に集中させましょう。ビジネスロジックはコントローラーやサービスに移してください。
  • 大規模なアプリでは、ルートを追加ファイル(例: routes/admin.ts)に分割し、src/app.ts で registrar を合成します。
  • 分かりやすいコントローラーメソッド名(indexshowstoreupdatedestroy)を使うと、フレームワーク全体の規約と揃います。
  • ミドルウェアエイリアスを活用すると、ルートファイルがすっきりし、あちこちでミドルウェア関数をインポートする必要がなくなります。

ルーティング DSL を使えば、複雑な HTTP 構造を表現しながら、エントリーポイントをクリーンで宣言的に保てます。

ルートコントラクト

第 2 引数にオプションオブジェクトを渡すと、Zod スキーマとメタデータをルートに付与できます。フレームワークはこれらのスキーマをリクエストバリデーション、コード生成、OpenAPI ドキュメント生成に使用します。スキーマは zod 4 API(import { z } from 'zod')で書いてください — zod v3 API で書かれたスキーマは構造読み取り系ツールが警告付きで拒否します(バリデーション を参照)。

import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(1),
  body: z.string().min(1),
})

const PostIdParams = z.object({
  id: z.coerce.number().int().positive(),
})

router.post('/posts', {
  body: CreatePostSchema,
  name: 'posts.store',
}, [PostsController, 'store'])

router.get('/posts/:id', {
  params: PostIdParams,
  name: 'posts.show',
}, [PostsController, 'show'])

利用可能なコントラクトフィールド:

フィールド 用途
name URL 生成・コード生成用のルート名
params パスパラメータの Zod スキーマ
query クエリパラメータの Zod スキーマ
body リクエストボディの Zod スキーマ
output レスポンスボディの Zod スキーマ
resource Resource クラスによるレスポンスヒント(スキーマなしで API クライアントを型付け)
bind ルートモデルバインディングマップ。{ id: Post }(主キー)または { slug: [Post, 'slug'] }(別カラム)
middlewares ミドルウェアハンドラーの配列

note

同じクエリキーが繰り返された場合、query スキーマには配列として渡されます(?tag=a&tag=b{ tag: ['a', 'b'] })。1 回だけ出現するキーは文字列のままです。詳細は配列形式のクエリパラメータを参照してください。

Resource レスポンスヒント

API リソースで応答するルートには、すでにレスポンス型があります。コード生成が Resource クラスから .guren/data.gen.ts に抽出する型です。そうしたルートに output スキーマを書くと、同じ形を Zod で二重定義することになり、2 つのコピーが乖離していきます。代わりに Resource そのものを宣言してください:

import { PostResource } from '@/app/Http/Resources/PostResource'

router.query('/posts/search', {
  name: 'posts.search',
  body: PostSearchSchema,
  resource: { data: [PostResource] },
}, [PostController, 'search'])

ヒントはコントローラーが組み立てる JSON をそのまま写します。単一リソースはクラスそのもの(resource: PostResource)、コレクションは要素 1 つの配列(resource: [PostResource])、エンベロープはプレーンオブジェクトで表します。{ data: [PostResource] }this.json({ data: PostResource.collection(posts) }) に対応します。ネストは任意の深さで書けます。

guren codegen は各クラスをプロジェクトルートと各 modules/<name>/app/Http/Resources と突き合わせ、組み立てた形(この例では { data: Data.Post[] })で生成 API クライアントの json() を型付けします。output と違ってリクエスト時には何も実行されません。ヒントはあくまで宣言であり、見つからない Resource クラスを指した場合はコード生成が警告してレスポンスを型無しのままにする、という形でのみ検査されます。両方指定した場合は、実際に強制される側である output が優先されます。

note

ヒントの末端はすべて Resource クラスである必要があります。Resource と通常の型付きオブジェクトが混ざるエンベロープ(たとえばページネーションレスポンスの metalinks)は今のところ表現できません。そうしたルートには output スキーマを使ってください。

OpenAPI メタデータ

ルートコントラクトには軽量な OpenAPI アノテーションも指定できます。これらはルート定義に保存され、オプションの @guren/openapi プラグインで OpenAPI 3.1 ドキュメントを生成する際に使用されます。

router.post('/posts', {
  body: CreatePostSchema,
  output: PostResponseSchema,
  name: 'posts.store',
  summary: 'Create a post',
  description: 'Creates a new blog post.',
  tags: ['Posts'],
}, [PostsController, 'store'])

router.get('/posts/:id', {
  params: PostIdParams,
  name: 'posts.show',
  summary: 'Get a post',
  tags: ['Posts'],
  deprecated: false,
}, [PostsController, 'show'])

利用可能な OpenAPI フィールド:

フィールド 用途
summary string ドキュメント UI に表示される短い説明
description string エンドポイントの詳細な説明
tags string[] ドキュメント UI でエンドポイントをグループ化
operationId string 自動生成されるオペレーション ID を上書き
deprecated boolean エンドポイントを非推奨としてマーク

スペックドキュメントの生成については CLI リファレンスの OpenAPI セクションを参照してください。

エージェントツール

名前付きのルートに agent メタデータを宣言すると、そのルートは MCP ツールとして AI エージェントに公開されます。ツールの入力スキーマ、出力スキーマ、認可はすべて上記のコントラクトから導出されるため、同じ内容を書き直す必要はありません。

// メソッドチェーン
router
  .post('/posts', { body: CreatePostSchema, output: PostResponseSchema }, [PostsController, 'store'])
  .name('posts.store')
  .agent({ description: 'Create a blog post as the authenticated user.' })

// ルートコントラクトのキーとして
router.post('/posts', {
  name: 'posts.store',
  body: CreatePostSchema,
  agent: { description: 'Create a blog post as the authenticated user.' },
}, [PostsController, 'store'])

resource() はアクションごとに同じメタデータを受け取ります。列挙しなかったアクションは公開されません:

router.resource('/posts', PostsController, {
  agent: {
    index: { description: 'List posts.' },
    show: { description: 'Fetch one post by id.' },
  },
})

公開はルート単位のオプトインです。ツール名はルート名がそのまま使われるため、.name() のないルートはツールになれません。ルートオプションの agent.agent() チェーンを両方書くと登録時に例外になります。宣言は1箇所だけにしてください。

エージェントから何が見えるかは bunx guren tool:list で確認できます。メタデータの各フィールド、入出力の導出ルール、MCP エンドポイント、トークンスコープ、監査ログについてはエージェントインターフェースガイドを参照してください。

OpenAPI ドキュメント生成

オプションの @guren/openapi パッケージをインストールして、ルート定義からスペックを生成します。

bun add @guren/openapi
bunx guren openapi:generate

ルートファイルを読み取り、ルートコントラクトから Zod スキーマと OpenAPI メタデータを抽出し、OpenAPI 3.1 JSON ドキュメントを .guren/openapi.gen.json に書き出します。

CLI オプション

# タイトルとバージョンを指定
bunx guren openapi:generate --title "Blog API" --version "1.0.0"

# 出力パスを変更
bunx guren openapi:generate --out docs/openapi.json

# サーバー URL を含める
bunx guren openapi:generate --server "https://api.example.com"

# 既存ファイルを上書き
bunx guren openapi:generate --force

ランタイムでのドキュメントマウント

OpenAPI スペックとインタラクティブなドキュメント UI をアプリケーションから直接配信することもできます。

import { createApp } from '@guren/core'
import { mountOpenApiDocs } from '@guren/openapi'

const app = createApp({ routes: registerWebRoutes })

mountOpenApiDocs(app, {
  title: 'Blog API',
  version: '1.0.0',
})

以下の 2 つのエンドポイントがマウントされます。

パス 説明
/openapi.json 生成された OpenAPI 3.1 JSON ドキュメント
/docs インタラクティブな API ドキュメント UI(Scalar)

パスは jsonPathdocsPath オプションでカスタマイズできます。

mountOpenApiDocs(app, {
  title: 'Blog API',
  version: '1.0.0',
  jsonPath: '/api/openapi.json',
  docsPath: '/api/docs',
})

Application インスタンスにマウントする場合、ルート定義はルーターから自動的に読み取られます。素の Hono インスタンスの場合は definitions を明示的に渡してください。

servers オプションには配列だけでなく関数も渡せます。マウントされたドキュメントはリクエストごとに生成され、関数もそのたびに呼ばれるため、マウント時点ではまだ分からないアドレスを載せられます。たとえば PORT=0 の場合、ポートは OS が割り当てるため listen() が返るまで確定せず、固定の配列ではドキュメントも、そこから生成したクライアントも、何も待ち受けていないアドレスを指したままになります。

mountOpenApiDocs(app, {
  title: 'Blog API',
  version: '1.0.0',
  servers: () => [app.address?.url ?? 'http://localhost:3000'],
})

await app.listen({ port: 0 })

app.addresslisten() がこのアプリをバインドしたアドレスで、バインド前は undefined です。関数の中でこれを読むことで、エントリポイントを経由せずに済みます。アドレスを生み出したアプリへ、わざわざ外から渡し直す必要がありません。素の Hono インスタンスにマウントする場合は尋ねる先の Application がないため、そのアプリが知っている方法で関数の戻り値を組み立ててください。