fandhe-frontend-app 0.2.6

fandhe-frontend-app: SSR/SSG/CSR 三モード共通のモード非依存コンポーネントライブラリ(fandhe-frontend-core のみに依存)。
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//! v1 共通コアのパスマッチングルーター(TASK-7.2b、イシュー #407 で
//! `fandhe-frontend-server` から `fandhe-frontend-app` へ移設)。
//!
//! PoC-3 では axum の `Router` を直接利用していたが、本モジュールは
//! **外部クレートに一切依存しないパスマッチング実装**として製品化した
//! ものである。マッチング仕様は `docs/api/router-path-matching.md`
//! (TASK-7.2a)で確定済みであり、本モジュールはその仕様どおりに実装
//! している。仕様と実装に乖離が生じた場合は `docs/api/router-path-matching.md`
//! を正として本モジュールを追従修正すること。
//!
//! # 移設の経緯(イシュー #407)
//!
//! server(SSR/SSG)・wasm-full(CSR/ハイドレーション)の双方がルート表を
//! 共有できるよう、エンジンを `fandhe-frontend-app`(`server`・`wasm-full` の双方から
//! 依存可能な唯一の層、`structure.toml` の `allowed_dependents` 参照)へ
//! 移設した。`fandhe-frontend-server` は [`crate::routes`] と同様、`fandhe_frontend_server::router`
//! で本モジュールを再エクスポートし、`server/tests/router_resolution.rs`
//! 等の既存呼び出し元は無修正のまま利用継続できる(公開 API パス非破壊)。
//! ルート表自体(パターン + ハンドラ + タイトル)の単一定義は
//! [`crate::routes`] が本モジュールを使って構築する。
//!
//! # 呼び出し文脈
//!
//! - HTTP・HTML を一切知らない。ハンドラ型 `H` はジェネリクスとして完全に
//!   分離しており、SSR(`fandhe-frontend-server` の SSR エントリ、TASK-6.1c)・SSG(静的
//!   書き出しバイナリ)・CSR(`fandhe-frontend-wasm-full` の `nav` モジュール、#407)・
//!   単一バイナリ配布(TASK-9.1)のいずれの上位層からも同一の [`Router`] /
//!   [`Router::resolve`] を呼び出せることを想定する。
//! - 本モジュールの出力([`Params`])は生文字列のまま返す。HTML へ出力する
//!   際は呼び出し元が必ず `fandhe_frontend_core::text` / `fandhe_frontend_core::el` の attrs 経由で
//!   既定エスケープ(REQ-1)を通すこと。本モジュール自身は `format!` 等で
//!   HTML 文字列を組み立てない。
//!
//! # マッチング仕様(v1)
//!
//! 詳細は `docs/api/router-path-matching.md` を参照。要点は以下のとおり。
//!
//! - セグメント単位の完全一致。`:name` は空でない 1 セグメントを捕捉する。
//! - 登録順の先勝ち(優先度規則は v1 対象外)。
//! - `?` 以降のクエリ文字列は照合前に切り落とす。
//! - 末尾スラッシュは正規化しない厳格一致(`/items/1/` と `/items/1` は別物)。
//! - ワイルドカード(`*path`)・パーセントデコード・HTTP メソッド別
//!   ディスパッチは v1 のスコープ外(`docs/api/router-path-matching.md` §4 参照)。
//!
//! # セキュリティ不変条件
//!
//! - 照合は文字列比較のみでファイルシステムへ一切触れない
//!   (パストラバーサルの影響面を持たない)。
//! - 登録ルート数 × リクエストパスのセグメント数に比例する線形走査のみ
//!   (正規表現・再帰・バックトラックを一切使わない。DoS への耐性)。
//! - 不正なパターン登録は `panic!` させず `Result::Err` として返す
//!   (ライブラリコードでの panic 回避規約、`coding-rust.md`)。

use std::fmt;

/// パターン文字列をパースした 1 セグメント分の内部表現。
///
/// `route()` 登録時にのみ生成し、`resolve()` の照合ループで使う。
#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
    /// 固定文字列との完全一致を要求するセグメント(例: `"items"`)。
    Static(String),
    /// `:name` 形式。空でない 1 セグメントを捕捉し `Params` へ格納する。
    Param(String),
}

/// 登録済み 1 ルート分(パース済みパターン + ハンドラ)。
struct Route<H> {
    segments: Vec<Segment>,
    handler: H,
}

/// パターン登録済みルートの集合。
///
/// 登録順に先勝ちで解決する(`resolve()` 参照)。ハンドラ型 `H` は本モジュール
/// が一切関知しない不透明な値であり、HTTP レスポンス生成等の責務は呼び出し元
/// (`fandhe-frontend-server` の SSR エントリ等)が担う。
pub struct Router<H> {
    routes: Vec<Route<H>>,
}

impl<H> fmt::Debug for Router<H> {
    /// ハンドラの中身は表示しない(`H` に `Debug` 境界を強制しないための
    /// 簡略表示)。テストの `unwrap_err()` 等、`Result<Self, _>` を扱う
    /// コードが `Router<H>: Debug` を要求するために用意している。
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Router")
            .field("route_count", &self.routes.len())
            .finish()
    }
}

impl<H> Default for Router<H> {
    fn default() -> Self {
        Self { routes: Vec::new() }
    }
}

impl<H> Router<H> {
    /// 空のルーターを作る。
    pub fn new() -> Self {
        Self::default()
    }

    /// パターンを 1 件登録する。
    ///
    /// パターンは `/` から始まる必要があり、`:name` セグメントでパスパラメータ
    /// を宣言できる(例: `"/items/:id"`)。ビルダー形式で `?` チェーンできる
    /// よう `self` を消費して `Result<Self, _>` を返す。
    ///
    /// # Errors
    ///
    /// パターンが `/` から始まらない・空セグメントを含む(連続スラッシュ・
    /// 末尾スラッシュ)・`:` の直後にパラメータ名がない・同一パターン内で
    /// パラメータ名が重複する、のいずれかに該当する場合に
    /// [`RouterError`] を返す。`panic!` はしない。
    ///
    /// # Examples
    ///
    /// ```
    /// use fandhe_frontend_app::router::Router;
    ///
    /// let router: Router<&str> = Router::new()
    ///     .route("/", "home")?
    ///     .route("/items/:id", "item_detail")?
    ///     .route("/search", "search")?;
    /// # Ok::<(), fandhe_frontend_app::router::RouterError>(())
    /// ```
    pub fn route(mut self, pattern: &str, handler: H) -> Result<Self, RouterError> {
        let segments = parse_pattern(pattern)?;
        self.routes.push(Route { segments, handler });
        Ok(self)
    }

    /// リクエストパスを解決する。
    ///
    /// `?` 以降のクエリ文字列は照合前に切り落とす。登録順に先勝ちで走査し、
    /// 最初に一致したルートのハンドラ参照とパスパラメータを返す。一致する
    /// ルートがなければ `None`(`panic!` はしない)。
    ///
    /// # Examples
    ///
    /// ```
    /// use fandhe_frontend_app::router::Router;
    ///
    /// let router: Router<&str> = Router::new().route("/items/:id", "item_detail")?;
    /// let m = router.resolve("/items/42?ref=top").expect("matches");
    /// assert_eq!(*m.handler, "item_detail");
    /// assert_eq!(m.params.get("id"), Some("42"));
    /// # Ok::<(), fandhe_frontend_app::router::RouterError>(())
    /// ```
    pub fn resolve(&self, path: &str) -> Option<RouteMatch<'_, H>> {
        let path_without_query = match path.split_once('?') {
            Some((before, _)) => before,
            None => path,
        };
        let request_segments = split_path(path_without_query)?;

        for route in &self.routes {
            if route.segments.len() != request_segments.len() {
                continue;
            }
            if let Some(params) = match_segments(&route.segments, &request_segments) {
                return Some(RouteMatch {
                    handler: &route.handler,
                    params,
                });
            }
        }
        None
    }
}

/// ルート解決結果。一致したハンドラへの参照と抽出済みパスパラメータを返す。
pub struct RouteMatch<'a, H> {
    /// 一致したルートに登録されているハンドラへの参照。
    pub handler: &'a H,
    /// `:name` セグメントから抽出したパスパラメータ。
    pub params: Params,
}

/// `:name` セグメントから抽出したパスパラメータ。
///
/// 値は URL デコードされていない生文字列のまま保持する。HTML へ出力する際は
/// 呼び出し元が必ず `fandhe_frontend_core::text` / `fandhe_frontend_core::el` の attrs 経由で既定
/// エスケープ(REQ-1)を通すこと。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Params(Vec<(String, String)>);

impl Params {
    /// パラメータ名から値を取得する。登録されていなければ `None`。
    pub fn get(&self, name: &str) -> Option<&str> {
        self.0
            .iter()
            .find(|(k, _)| k == name)
            .map(|(_, v)| v.as_str())
    }

    /// 登録済みパラメータを `(name, value)` のイテレータとして返す。
    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
    }
}

/// パターン登録時の不正入力を表すエラー。
///
/// パターン文字列はフレームワーク利用者(開発者)が `route()` 呼び出し時に
/// 与えるものであり、エンドユーザー入力ではない。そのためメッセージに機微な
/// 実行時情報は含まない(該当パターン文字列そのものを添えるのみ)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouterError {
    /// パターンが `/` から始まっていない。
    MissingLeadingSlash(String),
    /// 連続スラッシュ・末尾スラッシュ等による空セグメントを含む。
    EmptySegment(String),
    /// `:` の直後にパラメータ名がない(例: `"/items/:"`)。
    EmptyParamName(String),
    /// 同一パターン内でパラメータ名が重複している。
    DuplicateParamName {
        /// 対象のパターン文字列。
        pattern: String,
        /// 重複していたパラメータ名。
        name: String,
    },
}

impl fmt::Display for RouterError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            RouterError::MissingLeadingSlash(pattern) => {
                write!(f, "route pattern must start with '/': {pattern:?}")
            }
            RouterError::EmptySegment(pattern) => {
                write!(f, "route pattern contains an empty segment: {pattern:?}")
            }
            RouterError::EmptyParamName(pattern) => {
                write!(
                    f,
                    "route pattern has a ':' segment with no parameter name: {pattern:?}"
                )
            }
            RouterError::DuplicateParamName { pattern, name } => {
                write!(
                    f,
                    "route pattern declares parameter {name:?} more than once: {pattern:?}"
                )
            }
        }
    }
}

impl std::error::Error for RouterError {}

/// パターン文字列を [`Segment`] 列にパースする。
///
/// `route()` からのみ呼ばれる。`"/"`(ルート)は空のセグメント列を返す。
fn parse_pattern(pattern: &str) -> Result<Vec<Segment>, RouterError> {
    let rest = pattern
        .strip_prefix('/')
        .ok_or_else(|| RouterError::MissingLeadingSlash(pattern.to_string()))?;

    if rest.is_empty() {
        // "/" 単体はセグメントなしのルートパターンとして扱う。
        return Ok(Vec::new());
    }

    let mut segments = Vec::new();
    let mut seen_params: Vec<&str> = Vec::new();

    for part in rest.split('/') {
        if part.is_empty() {
            return Err(RouterError::EmptySegment(pattern.to_string()));
        }
        if let Some(name) = part.strip_prefix(':') {
            if name.is_empty() {
                return Err(RouterError::EmptyParamName(pattern.to_string()));
            }
            if seen_params.contains(&name) {
                return Err(RouterError::DuplicateParamName {
                    pattern: pattern.to_string(),
                    name: name.to_string(),
                });
            }
            seen_params.push(name);
            segments.push(Segment::Param(name.to_string()));
        } else {
            segments.push(Segment::Static(part.to_string()));
        }
    }

    Ok(segments)
}

/// リクエストパス(クエリ除去済み)をセグメント列に分解する。
///
/// パターンと異なり、リクエストパスは利用者が任意の文字列を送り得るため
/// `Result` ではなく `Option` で表現する(`/` で始まらない場合や空セグメントを
/// 含む場合は、単に一致するルートがない = `None` として扱い、`panic!` は
/// しない)。空セグメントを許容しないことで「末尾スラッシュ・連続スラッシュを
/// 含むパスはどのパターンとも一致しない」という厳格一致(v1 仕様)を実現する。
fn split_path(path: &str) -> Option<Vec<&str>> {
    let rest = path.strip_prefix('/')?;
    if rest.is_empty() {
        return Some(Vec::new());
    }
    let segments: Vec<&str> = rest.split('/').collect();
    if segments.iter().any(|s| s.is_empty()) {
        return None;
    }
    Some(segments)
}

/// パースされたパターンとリクエストパスのセグメント列を照合する。
///
/// 呼び出し元(`resolve()`)が事前に長さ一致を確認済みであることを前提とする。
fn match_segments(pattern: &[Segment], request: &[&str]) -> Option<Params> {
    let mut params = Vec::new();
    for (segment, actual) in pattern.iter().zip(request.iter()) {
        match segment {
            Segment::Static(expected) => {
                if expected != actual {
                    return None;
                }
            }
            Segment::Param(name) => {
                // ルール上パターン側の空パラメータ名は route() で拒否済みだが、
                // request 側の空セグメントは split_path で既に除去されている
                // ため、ここでの actual は常に非空。
                params.push((name.clone(), (*actual).to_string()));
            }
        }
    }
    Some(Params(params))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// REQ-7 の受け入れ基準(PoC-3 の 3 ルート相当)が解決できることを固定する。
    #[test]
    fn resolves_req7_baseline_routes() {
        let router: Router<&str> = Router::new()
            .route("/", "home")
            .unwrap()
            .route("/items/:id", "item_detail")
            .unwrap()
            .route("/search", "search")
            .unwrap();

        let home = router.resolve("/").expect("root should match");
        assert_eq!(*home.handler, "home");
        assert_eq!(home.params.get("id"), None);

        let search = router.resolve("/search").expect("search should match");
        assert_eq!(*search.handler, "search");
    }

    #[test]
    fn extracts_param_from_items_id() {
        let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();

        let matched = router.resolve("/items/2").expect("should match");
        assert_eq!(*matched.handler, "item_detail");
        assert_eq!(matched.params.get("id"), Some("2"));
    }

    #[test]
    fn query_string_is_stripped_before_matching() {
        let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();

        let matched = router
            .resolve("/items/2?ref=list&utm=abc")
            .expect("should match ignoring query string");
        assert_eq!(matched.params.get("id"), Some("2"));
    }

    #[test]
    fn unregistered_path_does_not_match() {
        let router: Router<&str> = Router::new()
            .route("/", "home")
            .unwrap()
            .route("/items/:id", "item_detail")
            .unwrap();

        assert!(router.resolve("/nope").is_none());
    }

    #[test]
    fn extra_trailing_segment_does_not_match() {
        let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();

        assert!(router.resolve("/items/1/extra").is_none());
    }

    #[test]
    fn trailing_slash_is_not_normalized_and_does_not_match() {
        let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();

        // v1 は厳格一致。"/items/1/" と "/items/1" は別物として扱う。
        assert!(router.resolve("/items/1/").is_none());
    }

    #[test]
    fn xss_payload_like_path_is_captured_as_raw_string() {
        // fandhe-frontend-app の demo_items()[1] と同種の XSS ペイロードをパスパラメータに
        // 見立てたテスト。router は生文字列のまま返すのみでエスケープは行わ
        // ない契約であることを固定する(既定エスケープは描画側の責務)。
        let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();

        // パスセグメントは '/' で区切られるため、セグメント内に '/' を含まない
        // XSS ペイロード(onerror ハンドラ形式)を用いる。
        let payload = "<img src=x onerror=alert(1)>";
        let path = format!("/items/{payload}");
        let matched = router.resolve(&path).expect("should match");
        assert_eq!(matched.params.get("id"), Some(payload));

        // 描画側(fandhe-frontend-core::text)を通すと既定エスケープされることを確認し、
        // router がエスケープ責務を持たないことの実証にする。
        let escaped = fandhe_frontend_core::render(&fandhe_frontend_core::text(
            matched.params.get("id").unwrap(),
        ));
        assert!(!escaped.contains("<img"));
        assert!(escaped.contains("&lt;img"));
    }

    /// `Params::iter()` が登録済みの全パスパラメータを `(name, value)` として
    /// 走査できることを固定する(`get()` は他テストで網羅済みだが `iter()` 単体は
    /// 未検証だったため補完する)。
    #[test]
    fn params_iter_yields_all_registered_pairs() {
        let router: Router<&str> = Router::new()
            .route("/items/:id/reviews/:review_id", "review_detail")
            .unwrap();

        let matched = router.resolve("/items/2/reviews/9").expect("should match");
        let pairs: Vec<(&str, &str)> = matched.params.iter().collect();

        assert_eq!(pairs, vec![("id", "2"), ("review_id", "9")]);
    }

    #[test]
    fn duplicate_items_first_registration_wins() {
        let router: Router<&str> = Router::new()
            .route("/items/:id", "first")
            .unwrap()
            .route("/items/:id", "second")
            .unwrap();

        let matched = router.resolve("/items/9").expect("should match");
        assert_eq!(*matched.handler, "first");
    }

    #[test]
    fn rejects_pattern_without_leading_slash() {
        let router: Router<&str> = Router::new();
        let err = router.route("items", "x").unwrap_err();
        assert_eq!(err, RouterError::MissingLeadingSlash("items".to_string()));
    }

    #[test]
    fn rejects_pattern_with_empty_segment() {
        let router: Router<&str> = Router::new();
        let err = router.route("/items//id", "x").unwrap_err();
        assert_eq!(err, RouterError::EmptySegment("/items//id".to_string()));
    }

    #[test]
    fn rejects_pattern_with_empty_param_name() {
        let router: Router<&str> = Router::new();
        let err = router.route("/items/:", "x").unwrap_err();
        assert_eq!(err, RouterError::EmptyParamName("/items/:".to_string()));
    }

    #[test]
    fn rejects_pattern_with_duplicate_param_name() {
        let router: Router<&str> = Router::new();
        let err = router.route("/items/:id/reviews/:id", "x").unwrap_err();
        assert_eq!(
            err,
            RouterError::DuplicateParamName {
                pattern: "/items/:id/reviews/:id".to_string(),
                name: "id".to_string(),
            }
        );
    }

    /// 同一クレート(fandhe-frontend-app)の Item / demo_items() を実データに見立て、
    /// router が抽出した id で一覧から該当データを引けることを確認する
    /// (fandhe-frontend-server の SSR エントリ・fandhe-frontend-wasm-full の nav が行う想定の
    /// 一連の流れの縮小版)。
    #[test]
    fn resolved_param_can_look_up_item() {
        let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();
        let items = crate::demo_items();

        let matched = router.resolve("/items/2").expect("should match");
        let found = items
            .iter()
            .find(|item| Some(item.id.as_str()) == matched.params.get("id"));

        assert!(found.is_some());
    }
}