Guide/guides

ミドルウェアガイド

ミドルウェアは、リクエストの前後で処理を挟む仕組みです。コントローラーのアクションの前や後に処理を足したり、アクションの代わりにレスポンスを返したりできます。Guren は Hono のミドルウェアの仕組みをそのまま使っており、付ける場所は次の 3 つです。

ミドルウェアガイド

ミドルウェアは、リクエストの前後で処理を挟む仕組みです。コントローラーのアクションの前や後に処理を足したり、アクションの代わりにレスポンスを返したりできます。Guren は Hono のミドルウェアの仕組みをそのまま使っており、付ける場所は次の 3 つです。

付ける場所 適用される範囲 節
アプリケーション(app.use()) すべてのリクエスト グローバルミドルウェア
ルートやルートグループ(.middleware()) そのルート ルートミドルウェア
名前(aliasMiddleware() / groupMiddleware()) その名前を使うルート ミドルウェアエイリアス、ミドルウェアグループ

createApp({ auth }) で作ったアプリでは、セッションと CSRF のミドルウェアがすでにすべてのリクエストにマウントされています。認証コンテキスト(requireAuthenticated が読むもの)は、どのアプリにもマウントされます(ビルトインヘルパーを参照)。

グローバルミドルウェア

// src/app.ts
import { createApp, defineMiddleware } from '@guren/core'

const requestTimer = defineMiddleware(async (ctx, next) => {
  const started = performance.now()
  await next()
  const duration = Math.round(performance.now() - started)
  console.log(`${ctx.req.method} ${ctx.req.path} -> ${ctx.res.status} (${duration}ms)`)
})

const app = createApp()
app.use('*', requestTimer)

グローバルミドルウェアは、ルートがマウントされる前に付けておく必要があります。そのため、サービスプロバイダーから付ける場合は boot() ではなく register() の中で app.use() を呼びます。ルートは register() と boot() の間にマウントされるので、boot() で付けたミドルウェアはそれらのルートでは実行されません。プロバイダーでの書き方はアーキテクチャを参照してください。

ルートミドルウェア

import { Router, requireAuthenticated } from '@guren/core'
import DashboardController from '@/app/Http/Controllers/DashboardController'

export function registerWebRoutes(router: Router): void {
  router.get('/dashboard', [DashboardController, 'index']).middleware(
    requireAuthenticated({ redirectTo: '/login' }),
  )
}

ルートミドルウェアは、付けたルートだけに適用されます。グループに付けた場合は、そのグループ内のすべてのルートが対象です。.middleware() にはハンドラー関数と登録済みのエイリアス名のどちらも渡せ、両方を混ぜても構いません。ただし、混ぜると実行の順番が変わります(実行順を参照)。

ミドルウェアエイリアス

ミドルウェアの関数に短い名前を付けて登録しておくと、ルートファイルでは関数を毎回 import せずに名前で指定できます。例の requireAdmin は、アプリが自分で定義したミドルウェアです(カスタムミドルウェアを書くを参照)。

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.post('/settings', [SettingsController, 'update']).middleware('auth', 'admin')

エイリアスで登録したミドルウェアは、guren audit の報告にも名前で表示されます。フレームワークのガードと認可のミドルウェアはどちらの渡し方でも認識されますが、自分で書いたミドルウェアを関数のまま渡すと guren audit からは見えません。

ミドルウェアグループ

グループは、いっしょに使う複数のエイリアスを 1 つの名前にまとめたものです。グループには、登録済みのエイリアスか別のグループだけを入れられます。

const router = baseRouter
  .aliasMiddleware('auth', requireAuthenticated())
  .aliasMiddleware('admin', requireAdmin())
  .aliasMiddleware('throttle', createRateLimitMiddleware({ limit: 60, windowMs: 60_000 }))
  .groupMiddleware('backoffice', ['auth', 'admin'])
  .groupMiddleware('api', ['throttle'])

グループをまとめてルートに付けるには router.middleware() を使います。

router.middleware('backoffice').group((backoffice) => {
  backoffice.get('/admin', [AdminController, 'index'])
  backoffice.get('/admin/users', [AdminUserController, 'index'])
})

router.middleware('api').group((api) => {
  api.get('/api/posts', [ApiPostController, 'index'])
  api.post('/api/posts', [ApiPostController, 'store'])
})

グループと個別のエイリアスは組み合わせられます。

router.middleware('api', 'auth').group((group) => {
  group.get('/api/me', [ProfileController, 'show'])
  group.put('/api/me', [ProfileController, 'update'])
})

実行順

ルートのミドルウェアは、次の順に実行されます。

  1. ルートのチェーンにあるエイリアス名とグループ名(外側のグループから)
  2. インラインのハンドラー関数(外側のグループから)
  3. ルートの body、params、query スキーマの検証
  4. コントローラーのアクション

名前で指定したものが関数より先に動く規則は、1 回の .middleware() の中でも、グループをまたいでも同じです。そのため、外側のグループに書いたインラインハンドラーは、内側のグループの名前付きミドルウェアより後に実行され、コードの見た目とは逆の順になります。

flowchart LR
  subgraph Written["書いた順"]
    direction TB
    W1["外側グループ<br/>.middleware(inlineA)"]
    W2["内側グループ<br/>.middleware('auth')"]
    W3["ルート<br/>.middleware(inlineB)"]
    W1 --> W2 --> W3
  end
  subgraph Actual["実際に走る順"]
    direction TB
    A1["'auth'<br/>名前付きはすべて先"]
    A2["inlineA<br/>外側グループのインライン"]
    A3["inlineB<br/>ルートのインライン"]
    A4["ルートコントラクトの検証<br/>body / params / query スキーマ"]
    A5["コントローラーのアクション"]
    A1 --> A2 --> A3 --> A4 --> A5
  end
  Written --> Actual

前後関係が問題になる場合は、すべてをエイリアスとして登録してください。コントローラーのアクションでは最後の 2 段は並べ替えられず、ルートに付けたスキーマはすべてのミドルウェアが終わったあと、アクションの直前に検証されます。

ビルトインヘルパー

ヘルパー 役割 自動でマウントされる条件
defineMiddleware Hono のミドルウェア関数に Guren 用の型を付ける —
createSessionMiddleware 各リクエストにセッションを付ける createApp({ auth }) のとき
createCsrfMiddleware 状態を変えるリクエストの CSRF トークンを検査する createApp({ auth }) のとき
attachAuthContext 各リクエストに認証コンテキストを付ける 常に
requireAuthenticated / requireGuest ログイン済みのユーザーだけ/ゲストだけを通す なし(ルートに付ける)
createRateLimitMiddleware クライアントごとのリクエスト数を制限する なし(ルートに付ける)

セッションや CSRF のミドルウェアを自動でマウントさせたくない場合は、auth: { autoSession: false } や auth: { autoCsrf: false } を渡します。

createSessionMiddleware

リクエストのコンテキストにセッションを付けます。セッション ID は署名付きの Cookie で受け渡し、データはストアを渡さなければメモリ(MemorySessionStore)に保存します。auth オプションを使うアプリでは自動でマウントされるので、自分でマウントする必要はありません。

import { createSessionMiddleware } from '@guren/core'

app.use('*', createSessionMiddleware())

セッションは getSessionFromContext(ctx) か ctx.get('guren:session') で取り出します。

store には SessionStore そのものか、それを返す関数を渡します。関数はリクエストのたびに呼ばれるので、ランタイムのバインディング(Workers)や接続(Redis)が必要なストアを起動時に組み立てずに済みます。組み立てが重い場合は自分でメモ化してください。複数のストアを宣言して環境ごとに切り替えるには SessionManager を使います(認証を参照)。

認証ガード

requireAuthenticated と requireGuest は、アプリケーションがすべてのリクエストに付けている認証コンテキストを読みます。そのため、ルートにはガードを付けるだけで済みます。

import { Router, requireAuthenticated, requireGuest } from '@guren/core'

const router = new Router()
  .aliasMiddleware('auth', requireAuthenticated({ redirectTo: '/login' }))
  .aliasMiddleware('guest', requireGuest({ redirectTo: '/dashboard' }))

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

redirectTo はブラウザーからのリクエストにだけ効きます。エージェントのツール呼び出し(エージェントインターフェースを参照)はリダイレクトをたどれないので、どちらのガードも JSON で拒否を返します。ステータスは requireAuthenticated が 401、requireGuest が 403 で、status を渡した場合はその値です。このとき、redirectTo と一緒に設定した responseFactory も使われません。

CSRF 保護

CSRF ミドルウェアは、状態を変えるリクエスト(POST、PUT、PATCH、DELETE)のトークンを検査します。createApp({ auth }) がすべてのリクエストにマウントするので、手で付ける必要があるのは auth オプションを使わないアプリです。

const router = new Router().aliasMiddleware('csrf', createCsrfMiddleware())

router.middleware('csrf').group((csrf) => {
  csrf.post('/posts', [PostsController, 'store'])
})

フォームやリクエストへのトークンの入れ方は CSRF 保護を参照してください。

レート制限

ルートやグループにレート制限を付けます。

import { createRateLimitMiddleware } from '@guren/core'

const router = new Router().aliasMiddleware('throttle', createRateLimitMiddleware({ limit: 60, windowMs: 60_000 }))

router.middleware('throttle').group((throttle) => {
  throttle.post('/api/login', [AuthController, 'login'])
})

キー、ストア、レスポンスヘッダーについてはレート制限を参照してください。

カスタムミドルウェアを書く

ミドルウェアは defineMiddleware で作ります。ログイン中のユーザーは認証コンテキストから読みます。

import { AUTH_CONTEXT_KEY, defineMiddleware } from '@guren/core'
import type { AuthContext } from '@guren/core'

export const requireSubscription = defineMiddleware(async (ctx, next) => {
  const auth = ctx.get(AUTH_CONTEXT_KEY) as AuthContext | undefined
  const user = await auth?.user<{ isSubscribed: boolean }>()

  if (!user?.isSubscribed) {
    return ctx.json({ error: 'Subscription required' }, 403)
  }

  await next()
})

app/Http/middleware/ に置いてエイリアスとして登録すれば、名前で使えます。

import { Router } from '@guren/core'
import { requireSubscription } from '@/app/Http/middleware/subscription'

const router = new Router().aliasMiddleware('subscribed', requireSubscription)

router.middleware('subscribed').group((group) => {
  group.get('/premium', [PremiumController, 'index'])
})