拡張点自作ガイド
fandhe-backend のコアは、拡張点を 4 種の trait(Middleware / UpgradeHandler / RequestGate / Interceptor)に集約する。前 3 者は定義が crates/core/src/extension.rs にある同期 trait、Interceptor は crates/core/src/interceptor.rs に定義されるレスポンダ系シームである。新機能を 追加するときは、まずこの 4 種のいずれかに載るかを検討する (.claude/rules/coding-rust.md の設計原則)。tutorial.md は Middleware の一例のみを扱うため、 本ページでは 4 種の契約を比較した上で、それぞれの自作方法と守るべき規約を示す。
各 trait の完全な実装例(doc test として cargo test --doc -p fandhe-backend-core で検証される)は crates/core/src/extension.rs の doc comment を正とする。 本ページのコード断片は要点の抜粋であり、二重管理をしない(README.md の原則)。
4 拡張点の契約比較
| trait | 呼ばれるタイミング | できること | できないこと |
|---|---|---|---|
RequestGate | ルーティング・アップグレード判定より前 | GateOutcome::Allow / Reject による早期拒否(認証・認可・同意ゲート等、拒否応答は Retry-After 等ヘッダ付きも可) | 判定根拠データ(JWT クレーム等)をコアへ持ち出すこと |
UpgradeHandler | RequestGate 通過後、既定 Handler より前 | 長時間接続(WebSocket 等)への委譲判定(matches が bool を返す) | フレーミング・接続奪取後の読み書き(プラグイン側の責務) |
Middleware | on_request: ヘッド受理後・ルーティング前 / on_response: レスポンス送出後 | ロギング・メトリクス等の観測 | リクエスト・レスポンスの変更(head は不変参照のみ) |
Interceptor | intercept: UpgradeHandler 通過後・パスインターセプト型プラグインより前 / map_response: 既定 Handler 確定後・レスポンス後処理型プラグインより前 | Some(response) によるリダイレクト等の応答確定、確定済み Response の書き換え | RequestGate 拒否応答・パースエラー応答・Upgrade 委譲失敗応答への適用(fail-closed 除外) |
登録は Server の builder メソッドで行い、いずれも複数登録できる。
| trait | 登録メソッド | 複数登録時の評価 | 実例プラグイン |
|---|---|---|---|
RequestGate | Server::gate | 登録順に評価し、最初の Reject を優先 | plugin-hub-wiring(TenantGate) |
UpgradeHandler | Server::upgrade_handler | 登録順に matches を評価 | plugin-websocket |
Middleware | Server::middleware | 登録順に on_request / on_response を呼ぶ | plugin-tracing |
Interceptor | Server::interceptor | intercept は登録順に評価し最初の Some を優先、map_response は登録順に逐次適用 | examples/with-interceptor |
同期契約(4 trait 共通)
4 trait はいずれも同期 API である。async fn を trait に持ち込むと Box<dyn Middleware> 等の trait object としてコアループが拡張点を保持する構成 (dyn 互換性)が壊れるためである。既定ハンドラ(Handler::handle)のみが async 契約であり、この非対称は意図的な設計である(crates/core/src/server.rs の Handler doc・docs/design/async-handler.md を参照)。
RequestGate を自作する
check はリクエストヘッドを検査し、GateOutcome::Allow(続行)または GateOutcome::Reject { response }(早期拒否、response は検証済み [Response])を返す。Retry-After 等ヘッダ付き拒否応答を返せるよう、 検証済み Response を直接運ぶ設計になっている。
use fandhe_backend_core::{GateContext, GateOutcome, RequestGate};
use fandhe_backend_http::request::RequestHead;
/// `X-Api-Key` ヘッダの有無だけを見る例(フェイルクローズ)。
struct ApiKeyGate;
impl RequestGate for ApiKeyGate {
fn name(&self) -> &'static str {
"api-key-gate"
}
fn check(&self, head: &RequestHead, _ctx: &GateContext) -> GateOutcome {
match head.header("x-api-key") {
Some(_) => GateOutcome::Allow,
// 判定不能・情報欠落時は必ず Reject(フェイルクローズ)。
// ヘッダ不要な最小構成は `GateOutcome::reject` ヘルパで足りる。
None => GateOutcome::reject(401, Vec::new()),
}
}
}
let server = Server::new().handler(router).gate(ApiKeyGate);Retry-After 等のヘッダを付与したい場合は、Response の検証済み構築 API (with_header / with_content_type)で組み立ててから Reject へ渡す。
use fandhe_backend_core::{GateContext, GateOutcome, RequestGate};
use fandhe_backend_http::request::RequestHead;
use fandhe_backend_http::response::Response;
struct RateLimitGate;
impl RequestGate for RateLimitGate {
fn name(&self) -> &'static str {
"rate-limit-gate"
}
fn check(&self, _head: &RequestHead, _ctx: &GateContext) -> GateOutcome {
let response = Response::new(429, b"{\"error\":\"rate limited\"}".to_vec())
.with_content_type("application/json")
.with_header("Retry-After", "30")
.expect("リテラル値は構築時検証を通る");
GateOutcome::Reject { response }
}
}守るべき契約は次のとおり。
- フェイルクローズ: 判定に必要な情報が欠落・不正な場合、あるいは判定不能な 場合は必ず
Rejectを返し、疑わしきは通過させない(.claude/rules/security.mdの認可既定拒否の方針) Rejectが運ぶresponseはResponseの構築時検証(CR/LF/NUL 拒否・Content-Length/Connection/Transfer-Encodingの予約名拒否)を経た値の みで、任意文字列を無検証でヘッダ・ステータス行へ書き出す経路は存在しない (レスポンス分割・ヘッダインジェクション対策)- 拒否レスポンス送出後も、登録済み
Middlewareのon_responseは呼ばれる (観測の一貫性) checkの第 2 引数ctx: &GateContextは accept したソケットの 実 peer address をctx.peer_addr() -> Option<SocketAddr>で提供する (IP ベース認可・レート制限のキー等に利用可能)。tokio::io::duplex等の 非ソケット経路ではNoneになるフェイルクローズ契約であり、peer address に基づく判定を行う実装はNoneの場合も必ずRejectを返すこと (疑わしきは通過させない)- リバースプロキシ・ロードバランサ配下では
peer_addrはプロキシ自身の アドレスになる(X-Forwarded-For/Forwardedヘッダはクライアント申告値 であり偽装可能なため別物)。実 peer address を注入したい呼び出し元向けにhandle_connection_with_peer_addrが公開 API として用意されている (詳細はGateContextの doc・docs/design/gate-peer-addr.md参照)
プロダクション水準の実例は crates/plugin-hub-wiring の TenantGate (JWT 検証・テナント境界強制を RequestGate だけで実現)を参照する。
UpgradeHandler の役割
UpgradeHandler がコアに公開するのは委譲判定のみである。matches が true を返すと、コアは当該接続の以降の処理を Upgrade 型プラグイン (plugin-websocket 等)へ委譲する。ハンドシェイク検証・フレーミング・ アップグレード後の読み書きは trait の責務外であり、プラグイン側に閉じる。
use fandhe_backend_core::UpgradeHandler;
use fandhe_backend_http::request::RequestHead;
struct WebSocketUpgrade;
impl UpgradeHandler for WebSocketUpgrade {
fn name(&self) -> &'static str {
"websocket-upgrade"
}
fn matches(&self, head: &RequestHead) -> bool {
head.header("upgrade")
.is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
}
}自作時の注意は次のとおり。
- 実例である
websocketfeature では、利用者はUpgradeHandlerを直接書かずServer::websocket(config)を呼ぶ。コアが内部でアダプタを登録し、委譲成立後の 処理はfandhe-backend-plugin-websocketが担う matchesがtrueを返したのに委譲先の Upgrade 型プラグインが存在しない場合 (feature 無効・未登録)、コアは黙って落とさず 501 を返して接続を閉じる。 自作のUpgradeHandlerを単独で登録しても長時間接続処理は成立しない点に注意する- 委譲が成立した接続では
Middleware::on_responseは呼ばれない(委譲時は 呼ばない契約)。Middleware実装側は「on_requestが必ずon_responseを 伴う」と仮定してはならない
Middleware の非同期 I/O 規約
Middleware は同期 API だが、実装内で同期ブロッキング I/O を行ってはならない。 on_request / on_response はコアのリクエストループから直接呼ばれるため、 ここでのブロッキングはスループットに直結する(実測で最大 25% の劣化を確認済み。 AGENTS.md の「規約: ミドルウェア非同期 I/O 必須化」を参照)。
ロギング等で I/O が必要な場合は、次のパターンに従う。
- チャネル送信パターン:
on_request/on_responseでは非同期チャネル (tokio::sync::mpsc等)への送信・アトミック操作等の非ブロッキング操作に 留め、実際のファイル・ネットワーク I/O は別タスクで行う - カウンタ等の軽量な状態は
AtomicUsize等の内部可変性で持ち、&selfの 不変参照のみで完結させる(ロック保持も避ける) name()が返す識別名・ログ出力にリクエスト内容(トークン・PII)を含めない (.claude/rules/security.md)
プロダクション実装は crates/plugin-tracing を参照する。tracing-appender の non-blocking writer(非同期・バッファ済み I/O)へ記録を委ね、on_response の 1 点に記録を集約している(Middleware trait には request/response を跨いで per-request 状態を運ぶ経路がないため)。
Interceptor(ユーザー向けインターセプト・レスポンス改変)
先行 3 拡張点(RequestGate / UpgradeHandler / Middleware)はいずれも 「リクエストを弾く」「観測する」「長時間接続へ委譲する」だけで、リダイレクトを返す・ 確定済みレスポンスの body を差し替えることができない。この 2 用途向けに Interceptor trait(crates/core/src/interceptor.rs)を追加した。先行 3 拡張点 いずれでも表現できない場合に限り新規 trait を追加するという原則の下で 4 種目として 採用されたが、Handler と同じ「レスポンダ系シーム」として feature ゲートなしで 常時利用できる(詳細な設計判断は docs/design/interceptor-extension-point.md)。
use fandhe_backend_core::interceptor::Interceptor;
use fandhe_backend_http::request::RequestHead;
use fandhe_backend_http::response::Response;
/// `/old` を `/new` へ 301 で正規化する例。
struct RedirectOld;
impl Interceptor for RedirectOld {
fn name(&self) -> &'static str {
"redirect-old"
}
fn intercept(&self, head: &RequestHead, _body: &[u8]) -> Option<Response> {
if head.path() == "/old" {
Response::redirect(301, "/new").ok()
} else {
None
}
}
}登録は Server::interceptor(...)(複数登録可、RequestGate/Middleware と同じ builder パターン)。
| フック | 呼ばれるタイミング | できること |
|---|---|---|
intercept | UpgradeHandler 通過後・plugin::try_intercept(static 等)より前 | Some(response) で応答を確定させ、以降のプラグイン評価・Handler をスキップ |
map_response | 最終応答(intercept/プラグイン/Handler いずれか)確定後・CORS/圧縮より前 | 確定済み Response を任意に書き換えて返す |
- 複数
Interceptorを登録した場合、interceptは登録順に評価し最初のSomeが勝つ(以降は呼ばれない)。map_responseは登録順に逐次適用する (各実装が前段の戻り値を受け取る) interceptがplugin::try_intercept(static/graphql等の設定登録型プラグイン) より前に評価されるため、利用者は登録済みプラグインの応答をインターセプトで 先取りできる(末尾スラッシュ 301 正規化のユースケース)map_responseは CORS ヘッダ付与・gzip 圧縮より前に適用されるため、書き換え後の body に対して圧縮・ヘッダ付与が効く。ストリーミング応答(Handler::handle_streaming) でも評価順序は同じ(map_response→ CORS ヘッダ付与 → 明示 opt-in 時のみチャンク単位の gzip 圧縮(HTTP/1.1 chunked 経路限定))だが、適用対象はヘッドのみである(後述)RequestGate拒否応答・パースエラー応答・Upgrade 委譲失敗応答には適用されない (fail-closed。既存のfinalize_responseと同一の除外方針)- ストリーミング応答(
Handler::handle_streaming)には、ヘッド確定時に 1 回だけ 登録順で適用される。反映されるのはステータス・Content-Type・追加ヘッダのみで、 chunked framing はコアが直接組み立てるためmap_responseが返したResponseの body は反映されず破棄される契約(詳細はInterceptor契約リファレンスの「ストリーミング応答への適用」節を参照) intercept/map_responseともMiddlewareと同じ同期契約(同期ブロッキング I/O 禁止)。カスタム 404 ページ等の静的コンテンツは起動時にメモリへプリロードしておく- リダイレクト(
intercept)とレスポンスヘッダ付与(map_response)の最小配線例はexamples/with-interceptor/(独立してcargo runできるサンプル、サイト版)を参照
セキュリティ・制約
- 4 trait とも
Send + Sync境界が必須である。拡張点の実装は複数ワーカー スレッドから共有参照される(この境界を欠くとビルドが通らない) Middlewareはheadを変更してはならない契約だが、コアはこれを型では 強制しない。実装者が守る規約として doc に明記されているGateOutcomeは許可/拒否の判定結果のみを運び、JWT クレーム等のプラグイン 固有データをコアへ持ち込まない(依存方向は常に「プラグイン → コア」の一方向)- 拡張点の評価順序(
RequestGate→UpgradeHandler→Interceptor::intercept→ パスインターセプト型プラグイン → 既定Handler→Interceptor::map_response→ レスポンス後処理型プラグイン)は固定であり、利用者側で変更できない
関連ドキュメント
- 段階的なチュートリアル(
Middlewareの実装から feature 有効化まで):tutorial.md - feature 構成別の実行可能サンプル:
feature-samples.md - プラグイン境界パターンの設計判断:
docs/design/plugin-boundary.md Interceptor(3 拡張点で表現できないリダイレクト・レスポンス改変)の設計判断:docs/design/interceptor-extension-point.mdInterceptorの契約リファレンス(公開 API 一覧・評価順序・セキュリティ観点):../api/interceptor-api.md- 既定
Handlerの async 化(3 拡張点を同期に据え置く判断):docs/design/async-handler.md