sz-rust-http-facade 0.6.7

HTTP facade for sz-rust framework — response, error, and request primitives
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
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
//! 请求模块 — postData/getData/file/upload
//!
//! 对齐 PHP `$this->request->post()` / `$this->request->get()` / `$this->request->param()`。
//! 强制 POST,不使用 GET 分支(遵循项目规范)。
//!
//! ## PHP 对齐
//!
//! | PHP 方法 | 行为 | Rust 等价 |
//! |---------|------|-----------|
//! | `$this->request->param()` | 合并 POST + GET + route 参数 | [`fetch_post_data`](合并 body + query) |
//! | `$this->request->post()` | 仅 POST body | [`fetch_body_data`] |
//! | `$this->request->get()` | 仅 GET query | [`fetch_query_data`] |
//! | `postData($key)` | `param($key.'/a')`(强制数组) | [`fetch_post_data_by_key`] |
//! | `getData($key)` | `get($key)` | [`fetch_query_data_by_key`] |
//!
//! ## 注意
//!
//! - `param()` 在 PHP 中还会合并 route 参数(`{id}` 等),在 axum 中这些由 handler 参数捕获,
//!   所以本模块仅合并 body + query。
//! - `/a` 强制数组在 Rust 中不适用(强类型语言),调用方需自行处理类型转换。
//! - 本模块提供低层数据获取;上层控制器在 controller 模块实现 `postData()` 方法时封装。
//!
//! ## 用法
//!
//! ```ignore
//! use sz_rust_http_facade::request::fetch_post_data;
//! use axum::http::Request;
//! use axum::body::Body;
//!
//! async fn handler(req: Request<Body>) {
//!     // 合并 body + query
//!     let data = fetch_post_data(req).await.unwrap();
//!     println!("{}", data);
//! }
//! ```

use std::collections::HashMap;

use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt;
use serde_json::{Map, Value};

/// 从请求中获取合并参数(body + query)
///
/// 对齐 PHP `$this->request->param()`:合并 POST body 和 GET query,
/// body 中的字段优先级高于 query。
///
/// ## 参数
///
/// - `req`:`axum::http::Request<Body>`
///
/// ## 返回
///
/// - `Ok(Value::Object)`:合并后的 JSON Object
/// - `Err(String)`:body 不可读或 JSON 解析失败
///
/// ## 行为
///
/// 1. 解析 query string 为 `Value::Object`(每个键值对均为字符串)
/// 2. 读取 body bytes
/// 3. 如果 Content-Type 为 `application/json`,解析 body 为 JSON 并合并到 query
/// 4. 如果 Content-Type 为 `application/x-www-form-urlencoded`,解析为表单并合并
/// 5. body 字段覆盖 query 字段
pub async fn fetch_post_data(req: Request<Body>) -> Result<Value, String> {
    // 1. 解析 query
    let query_map = parse_query(req.uri().query().unwrap_or(""));

    // 2. 读取 body
    let (parts, body) = req.into_parts();
    let bytes = body
        .collect()
        .await
        .map_err(|e| format!("read body failed: {e}"))?
        .to_bytes();

    // 3. 根据 Content-Type 解析 body
    let content_type = parts
        .headers
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_lowercase();

    let mut result = Map::new();

    // query 先写入(低优先级)
    for (k, v) in query_map {
        result.insert(k, Value::String(v));
    }

    // body 后写入(高优先级,覆盖 query)
    if !bytes.is_empty() {
        if content_type.contains("application/json") {
            let body_value: Value =
                serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON body: {e}"))?;
            if let Value::Object(body_map) = body_value {
                for (k, v) in body_map {
                    result.insert(k, v);
                }
            } else {
                // 非 object 的 body,整体作为 "data" 字段
                result.insert("data".to_string(), body_value);
            }
        } else if content_type.contains("application/x-www-form-urlencoded") {
            let body_str = String::from_utf8_lossy(&bytes);
            let body_map = parse_query(&body_str);
            for (k, v) in body_map {
                result.insert(k, Value::String(v));
            }
        } else {
            // 未知 Content-Type:尝试当 JSON 解析,失败则作为 raw 字符串
            if let Ok(body_value) = serde_json::from_slice::<Value>(&bytes) {
                if let Value::Object(body_map) = body_value {
                    for (k, v) in body_map {
                        result.insert(k, v);
                    }
                } else {
                    result.insert("data".to_string(), body_value);
                }
            } else {
                // 当作 raw 字符串
                let raw = String::from_utf8_lossy(&bytes).to_string();
                if !raw.is_empty() {
                    result.insert("data".to_string(), Value::String(raw));
                }
            }
        }
    }

    Ok(Value::Object(result))
}

/// 从请求中获取单个合并参数(body + query)
///
/// 对齐 PHP `postData($key)`:返回单个字段的值。
pub async fn fetch_post_data_by_key(
    req: Request<Body>,
    key: &str,
) -> Result<Option<Value>, String> {
    let data = fetch_post_data(req).await?;
    Ok(data.get(key).cloned())
}

/// 从请求中仅获取 body 参数(不含 query)
///
/// 对齐 PHP `$this->request->post()`。
pub async fn fetch_body_data(req: Request<Body>) -> Result<Value, String> {
    let (parts, body) = req.into_parts();
    let bytes = body
        .collect()
        .await
        .map_err(|e| format!("read body failed: {e}"))?
        .to_bytes();

    let content_type = parts
        .headers
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_lowercase();

    let mut result = Map::new();

    if bytes.is_empty() {
        return Ok(Value::Object(result));
    }

    if content_type.contains("application/json") {
        let body_value: Value =
            serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON body: {e}"))?;
        if let Value::Object(body_map) = body_value {
            for (k, v) in body_map {
                result.insert(k, v);
            }
        } else {
            result.insert("data".to_string(), body_value);
        }
    } else if content_type.contains("application/x-www-form-urlencoded") {
        let body_str = String::from_utf8_lossy(&bytes);
        let body_map = parse_query(&body_str);
        for (k, v) in body_map {
            result.insert(k, Value::String(v));
        }
    } else if let Ok(body_value) = serde_json::from_slice::<Value>(&bytes) {
        if let Value::Object(body_map) = body_value {
            for (k, v) in body_map {
                result.insert(k, v);
            }
        } else {
            result.insert("data".to_string(), body_value);
        }
    } else {
        let raw = String::from_utf8_lossy(&bytes).to_string();
        result.insert("data".to_string(), Value::String(raw));
    }

    Ok(Value::Object(result))
}

/// 从请求中仅获取 query 参数
///
/// 对齐 PHP `$this->request->get()` / `getData()`。
pub fn fetch_query_data(req: &Request<Body>) -> Value {
    let query_map = parse_query(req.uri().query().unwrap_or(""));
    let mut result = Map::new();
    for (k, v) in query_map {
        result.insert(k, Value::String(v));
    }
    Value::Object(result)
}

/// 从请求中仅获取 query 参数的单个字段
///
/// 对齐 PHP `getData($key)`。
pub fn fetch_query_data_by_key(req: &Request<Body>, key: &str) -> Option<Value> {
    let query_map = parse_query(req.uri().query().unwrap_or(""));
    query_map.get(key).map(|v| Value::String(v.clone()))
}

/// 解析 query string 为 `HashMap<String, String>`
///
/// 支持 `key=value&key2=value2` 格式,URL 解码。
pub fn parse_query(query: &str) -> HashMap<String, String> {
    let mut result = HashMap::new();
    if query.is_empty() {
        return result;
    }

    for pair in query.split('&') {
        if pair.is_empty() {
            continue;
        }
        let mut split = pair.splitn(2, '=');
        let key = url_decode(split.next().unwrap_or(""));
        let value = url_decode(split.next().unwrap_or(""));
        result.insert(key, value);
    }

    result
}

/// 简单 URL 解码(支持 %XX 与 + → space)
///
/// 对齐 PHP `urldecode` 语义:`%XX` 按字节解码后整体按 UTF-8 还原,
/// 支持多字节字符(如中文 `%E9%B2%9C` → `鲜`)。
/// 无效 UTF-8 字节序列以 U+FFFD 替换(容错,不 panic)。
pub fn url_decode(s: &str) -> String {
    let mut bytes: Vec<u8> = Vec::with_capacity(s.len());
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        match c {
            '+' => bytes.push(b' '),
            '%' => {
                let h1 = chars.next();
                let h2 = chars.next();
                if let (Some(a), Some(b)) = (h1, h2) {
                    if let Ok(byte) = u8::from_str_radix(&format!("{a}{b}"), 16) {
                        bytes.push(byte);
                    } else {
                        // 非法十六进制:保留原样
                        bytes.push(b'%');
                        push_char_utf8(&mut bytes, a);
                        push_char_utf8(&mut bytes, b);
                    }
                } else {
                    bytes.push(b'%');
                }
            }
            _ => push_char_utf8(&mut bytes, c),
        }
    }

    String::from_utf8_lossy(&bytes).into_owned()
}

/// 将字符按 UTF-8 编码追加到字节缓冲
fn push_char_utf8(bytes: &mut Vec<u8>, c: char) {
    let mut buf = [0u8; 4];
    bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::http::{Method, Request, StatusCode};

    fn make_json_request(body: &str, query: Option<&str>) -> Request<Body> {
        let uri = match query {
            Some(q) => format!("/?{q}"),
            None => "/".to_string(),
        };
        Request::builder()
            .method(Method::POST)
            .uri(&uri)
            .header("content-type", "application/json")
            .body(Body::from(body.to_string()))
            .unwrap()
    }

    fn make_form_request(body: &str, query: Option<&str>) -> Request<Body> {
        let uri = match query {
            Some(q) => format!("/?{q}"),
            None => "/".to_string(),
        };
        Request::builder()
            .method(Method::POST)
            .uri(&uri)
            .header("content-type", "application/x-www-form-urlencoded")
            .body(Body::from(body.to_string()))
            .unwrap()
    }

    // ====================================================================
    // parse_query / url_decode 单元测试
    // ====================================================================

    #[test]
    fn test_parse_query_empty() {
        let m = parse_query("");
        assert!(m.is_empty());
    }

    #[test]
    fn test_parse_query_single_pair() {
        let m = parse_query("key=value");
        assert_eq!(m.get("key"), Some(&"value".to_string()));
        assert_eq!(m.len(), 1);
    }

    #[test]
    fn test_parse_query_multiple_pairs() {
        let m = parse_query("a=1&b=2&c=3");
        assert_eq!(m.get("a"), Some(&"1".to_string()));
        assert_eq!(m.get("b"), Some(&"2".to_string()));
        assert_eq!(m.get("c"), Some(&"3".to_string()));
    }

    #[test]
    fn test_parse_query_no_value() {
        let m = parse_query("key");
        assert_eq!(m.get("key"), Some(&"".to_string()));
    }

    #[test]
    fn test_parse_query_url_encoded() {
        let m = parse_query("name=hello%20world&email=a%40b.com");
        assert_eq!(m.get("name"), Some(&"hello world".to_string()));
        assert_eq!(m.get("email"), Some(&"a@b.com".to_string()));
    }

    #[test]
    fn test_parse_query_plus_for_space() {
        let m = parse_query("q=hello+world");
        assert_eq!(m.get("q"), Some(&"hello world".to_string()));
    }

    #[test]
    fn test_parse_query_skip_empty_pairs() {
        let m = parse_query("a=1&&b=2&");
        assert_eq!(m.len(), 2);
        assert_eq!(m.get("a"), Some(&"1".to_string()));
        assert_eq!(m.get("b"), Some(&"2".to_string()));
    }

    #[test]
    fn test_url_decode_basic() {
        assert_eq!(url_decode("hello"), "hello");
        assert_eq!(url_decode("hello%20world"), "hello world");
        assert_eq!(url_decode("a%40b"), "a@b");
        assert_eq!(url_decode("a+b"), "a b");
    }

    #[test]
    fn test_url_decode_utf8_multibyte() {
        // 对齐 PHP urldecode:%XX 字节序列按 UTF-8 还原(多字节中文)
        assert_eq!(url_decode("%E9%B2%9C%E8%A7%86%E8%BE%BE"), "鲜视达");
        assert_eq!(url_decode("%E5%B7%A5%E5%85%B7%E7%AE%B1"), "工具箱");
        // 混合:普通字符 + 中文编码 + 空格
        assert_eq!(
            url_decode("q=%E9%B2%9C%E8%A7%86%E8%BE%BE+plus"),
            "q=鲜视达 plus"
        );
    }

    #[test]
    fn test_url_decode_invalid_utf8_lossy() {
        // 无效 UTF-8 序列不 panic,替换为 U+FFFD(容错语义)
        let decoded = url_decode("%FF%FE");
        assert!(decoded.contains('\u{FFFD}'));
    }

    #[test]
    fn test_url_decode_trailing_percent() {
        // 末尾单独的 % 应当保留
        assert_eq!(url_decode("100%"), "100%");
    }

    // ====================================================================
    // fetch_post_data 集成测试
    // ====================================================================

    #[tokio::test]
    async fn test_fetch_post_data_json_body_only() {
        let req = make_json_request(r#"{"name":"alice","age":30}"#, None);
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["name"], "alice");
        assert_eq!(data["age"], 30);
    }

    #[tokio::test]
    async fn test_fetch_post_data_query_only() {
        let req = make_json_request("", Some("page=1&size=10"));
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["page"], "1");
        assert_eq!(data["size"], "10");
    }

    #[tokio::test]
    async fn test_fetch_post_data_body_overrides_query() {
        // body 与 query 同 key 时,body 优先
        let req = make_json_request(r#"{"page":99}"#, Some("page=1&size=10"));
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["page"], 99); // 来自 body
        assert_eq!(data["size"], "10"); // 来自 query
    }

    #[tokio::test]
    async fn test_fetch_post_data_form_urlencoded() {
        let req = make_form_request("name=bob&age=25", None);
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["name"], "bob");
        assert_eq!(data["age"], "25");
    }

    #[tokio::test]
    async fn test_fetch_post_data_empty_body() {
        let req = make_json_request("", None);
        let data = fetch_post_data(req).await.unwrap();
        assert!(data.as_object().unwrap().is_empty());
    }

    #[tokio::test]
    async fn test_fetch_post_data_invalid_json() {
        let req = make_json_request("{invalid}", None);
        let result = fetch_post_data(req).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_fetch_post_data_by_key() {
        let req = make_json_request(r#"{"name":"alice","age":30}"#, None);
        let name = fetch_post_data_by_key(req, "name").await.unwrap();
        assert_eq!(name, Some(Value::String("alice".to_string())));
    }

    #[tokio::test]
    async fn test_fetch_post_data_by_key_missing() {
        let req = make_json_request(r#"{"name":"alice"}"#, None);
        let age = fetch_post_data_by_key(req, "age").await.unwrap();
        assert_eq!(age, None);
    }

    #[tokio::test]
    async fn test_fetch_post_data_array_value_in_body() {
        let req = make_json_request(r#"{"ids":[1,2,3]}"#, None);
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["ids"], serde_json::json!([1, 2, 3]));
    }

    #[tokio::test]
    async fn test_fetch_post_data_nested_object_in_body() {
        let req = make_json_request(r#"{"user":{"name":"alice","age":30}}"#, None);
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["user"]["name"], "alice");
        assert_eq!(data["user"]["age"], 30);
    }

    // ====================================================================
    // fetch_body_data 单元测试
    // ====================================================================

    #[tokio::test]
    async fn test_fetch_body_data_json() {
        let req = make_json_request(r#"{"name":"alice"}"#, Some("ignored=1"));
        let data = fetch_body_data(req).await.unwrap();
        assert_eq!(data["name"], "alice");
        // query 应当被忽略
        assert!(data.get("ignored").is_none());
    }

    #[tokio::test]
    async fn test_fetch_body_data_empty() {
        let req = make_json_request("", None);
        let data = fetch_body_data(req).await.unwrap();
        assert!(data.as_object().unwrap().is_empty());
    }

    // ====================================================================
    // fetch_query_data 单元测试
    // ====================================================================

    #[test]
    fn test_fetch_query_data_basic() {
        let req = Request::builder()
            .method(Method::GET)
            .uri("/?page=1&size=10")
            .body(Body::empty())
            .unwrap();
        let data = fetch_query_data(&req);
        assert_eq!(data["page"], "1");
        assert_eq!(data["size"], "10");
    }

    #[test]
    fn test_fetch_query_data_no_query() {
        let req = Request::builder()
            .method(Method::GET)
            .uri("/")
            .body(Body::empty())
            .unwrap();
        let data = fetch_query_data(&req);
        assert!(data.as_object().unwrap().is_empty());
    }

    #[test]
    fn test_fetch_query_data_by_key_found() {
        let req = Request::builder()
            .method(Method::GET)
            .uri("/?page=1&size=10")
            .body(Body::empty())
            .unwrap();
        assert_eq!(
            fetch_query_data_by_key(&req, "page"),
            Some(Value::String("1".to_string()))
        );
        assert_eq!(
            fetch_query_data_by_key(&req, "size"),
            Some(Value::String("10".to_string()))
        );
    }

    #[test]
    fn test_fetch_query_data_by_key_not_found() {
        let req = Request::builder()
            .method(Method::GET)
            .uri("/?page=1")
            .body(Body::empty())
            .unwrap();
        assert_eq!(fetch_query_data_by_key(&req, "missing"), None);
    }

    // ====================================================================
    // 集成测试:完整请求流程
    // ====================================================================

    #[tokio::test]
    async fn test_post_data_via_axum_handler() {
        use axum::routing::post;
        use tower::ServiceExt;

        async fn handler(req: Request<Body>) -> (StatusCode, String) {
            let data = fetch_post_data(req).await.unwrap();
            let name = data["name"].as_str().unwrap_or("unknown");
            let age = data["age"].as_i64().unwrap_or(0);
            (StatusCode::OK, format!("{name} is {age}"))
        }

        let router = axum::Router::new().route("/", post(handler));

        let req = Request::builder()
            .method(Method::POST)
            .uri("/")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"name":"alice","age":30}"#))
            .unwrap();
        let resp = router.oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);

        use http_body_util::BodyExt;
        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(&bytes[..], b"alice is 30");
    }

    // ====================================================================
    // PHP 一致性测试(R5: PHP/Rust 行为对比)
    //
    // 对齐 PHP `SzController::postData($key = null)` 与 `getData($key = null)`:
    // - `postData()` 调用 `$this->request->param('')`,合并 route + GET + POST,
    //   POST 优先级最高;ThinkPHP 8 自动解析 application/json body 到 POST 参数
    // - `postData($key)` 调用 `param($key.'/a')`,返回单字段值(强制数组类型,
    //   Rust 中用 `Option<Value>` 代替,调用方自行处理类型)
    // - `getData()` 调用 `$this->request->get('')`,仅返回 query 参数
    // - `getData($key)` 调用 `get($key)`,返回单字段值
    // ====================================================================

    #[tokio::test]
    async fn test_php_consistency_post_data_merges_body_and_query() {
        // PHP `postData()`:body 优先级 > query(对齐 ThinkPHP `param()` 合并顺序)
        // 场景:body 含 page=99,query 含 page=1&size=10
        // 预期:page 来自 body(99),size 来自 query("10")
        let req = make_json_request(r#"{"page":99}"#, Some("page=1&size=10"));
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["page"], 99, "body 应覆盖 query 同名字段");
        assert_eq!(data["size"], "10", "query 字段应保留");
    }

    #[tokio::test]
    async fn test_php_consistency_post_data_form_urlencoded_body() {
        // PHP `postData()`:application/x-www-form-urlencoded body 也应被解析
        // 场景:form-urlencoded body 含 name=bob&age=25
        // 预期:name="bob", age="25"(form 字段值类型为字符串)
        let req = make_form_request("name=bob&age=25", None);
        let data = fetch_post_data(req).await.unwrap();
        assert_eq!(data["name"], "bob");
        assert_eq!(data["age"], "25");
    }

    #[tokio::test]
    async fn test_php_consistency_post_data_by_key_returns_value() {
        // PHP `postData($key)`:返回单字段值(对齐 `param($key.'/a')`)
        // 场景:body 含 name=alice&age=30,取 name
        // 预期:Some("alice")
        let req = make_json_request(r#"{"name":"alice","age":30}"#, None);
        let name = fetch_post_data_by_key(req, "name").await.unwrap();
        assert_eq!(name, Some(Value::String("alice".to_string())));
    }

    #[test]
    fn test_php_consistency_get_data_returns_only_query() {
        // PHP `getData()`:仅返回 query 参数(对齐 `$this->request->get('')`)
        // 场景:GET 请求含 page=1&size=10
        // 预期:返回 {page:"1", size:"10"},不包含 body
        let req = Request::builder()
            .method(Method::GET)
            .uri("/?page=1&size=10")
            .body(Body::empty())
            .unwrap();
        let data = fetch_query_data(&req);
        assert_eq!(data["page"], "1");
        assert_eq!(data["size"], "10");
        // 不应有 body 字段
        assert!(data.get("body").is_none());
    }

    #[test]
    fn test_php_consistency_get_data_by_key_returns_query_value() {
        // PHP `getData($key)`:返回 query 中单字段值(对齐 `get($key)`)
        // 场景:GET 请求含 page=1&size=10
        // 预期:page=Some("1"), missing=None
        let req = Request::builder()
            .method(Method::GET)
            .uri("/?page=1&size=10")
            .body(Body::empty())
            .unwrap();
        assert_eq!(
            fetch_query_data_by_key(&req, "page"),
            Some(Value::String("1".to_string()))
        );
        assert_eq!(fetch_query_data_by_key(&req, "missing"), None);
    }
}