From 061ceff4b8d8a6a2907fbf264141fe163487143e Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 27 Mar 2026 17:33:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20YAML=20=E5=9D=97?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=20body=20=E5=8C=B9=E9=85=8D=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalize_yaml_body 函数在解析 JSON 前添加 trim() 处理,解决 YAML `|` 和 `>` 语法产生的前导空格问题 - 修复 multiple_login.yaml 中 response body 格式错误(YAML 对象改为 JSON 字符串) --- .claude/settings.local.json | 3 +- mocks/v1/auth/login.yaml | 28 -- mocks/v1/auth/login_out.yaml | 50 --- mocks/v1/auth/multiple_login.yaml | 118 +++++++ mocks/v1/auth/register.yaml | 26 ++ mocks/v1/auth/single_login.yaml | 29 ++ mocks/v1/data/export.yaml | 30 ++ mocks/v1/health.yaml | 15 + mocks/v1/products/export.yaml | 8 +- mocks/v1/system/health.yaml | 16 - mocks/v1/upload.yaml | 23 -- mocks/v1/user/avatar.yaml | 73 +++++ mocks/v1/user/download.yaml | 13 + mocks/v1/user/echo.yaml | 13 + mocks/v1/user/login_form.yaml | 24 ++ mocks/v1/user/profile.yaml | 24 ++ mocks/v1/user/search.yaml | 11 - src/handler.rs | 147 ++++++++- src/lib.rs | 2 +- src/loader.rs | 2 +- src/{config.rs => models.rs} | 61 +++- src/router.rs | 177 +++++++++-- storage/v1/hello.pdf | Bin 0 -> 23457 bytes storage/v1/user_data.json | 20 ++ tests/integration_test.rs | 208 +++++-------- tests/loader_test.rs | 495 ++++++++++++++++++++++++------ tests/v2_integration_test.rs | 238 ++++++++++++++ 27 files changed, 1439 insertions(+), 415 deletions(-) delete mode 100644 mocks/v1/auth/login.yaml delete mode 100644 mocks/v1/auth/login_out.yaml create mode 100644 mocks/v1/auth/multiple_login.yaml create mode 100644 mocks/v1/auth/register.yaml create mode 100644 mocks/v1/auth/single_login.yaml create mode 100644 mocks/v1/data/export.yaml create mode 100644 mocks/v1/health.yaml delete mode 100644 mocks/v1/system/health.yaml delete mode 100644 mocks/v1/upload.yaml create mode 100644 mocks/v1/user/avatar.yaml create mode 100644 mocks/v1/user/download.yaml create mode 100644 mocks/v1/user/echo.yaml create mode 100644 mocks/v1/user/login_form.yaml create mode 100644 mocks/v1/user/profile.yaml delete mode 100644 mocks/v1/user/search.yaml rename src/{config.rs => models.rs} (50%) create mode 100644 storage/v1/hello.pdf create mode 100644 storage/v1/user_data.json create mode 100644 tests/v2_integration_test.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ea1c057..b1073c4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(find:*)", - "Bash(cargo search:*)" + "Bash(cargo search:*)", + "Bash(cd:*)" ] } } diff --git a/mocks/v1/auth/login.yaml b/mocks/v1/auth/login.yaml deleted file mode 100644 index a24501a..0000000 --- a/mocks/v1/auth/login.yaml +++ /dev/null @@ -1,28 +0,0 @@ -id: "auth_login_001" -request: - method: "POST" - path: "/api/v1/auth/login" - # 必须包含此 Header 才会匹配 - headers: - Content-Type: "application/json" - Authorization: "111" - host: "127.0.0.1:8080" - body: > - { - "username":"user", - "password":"123" - } -response: - status: 200 - headers: - Content-Type: "application/json" - X-Mock-Engine: "Rust-Gemini-v1.2" - # 直接内联 JSON 字符串 - body: > - { - "code": 0, - "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6" }, - "msg": "success" - } -settings: - delay_ms: 2000 # 模拟真实网络延迟 \ No newline at end of file diff --git a/mocks/v1/auth/login_out.yaml b/mocks/v1/auth/login_out.yaml deleted file mode 100644 index 05e4930..0000000 --- a/mocks/v1/auth/login_out.yaml +++ /dev/null @@ -1,50 +0,0 @@ -- id: "auth_login_out_001" - request: - method: "POST" - path: "/api/v1/auth/login_out" - # 必须包含此 Header 才会匹配 - headers: - Content-Type: "application/json" - Authorization: "111" - host: "127.0.0.1:8080" - body: - type: true - response: - status: 200 - headers: - Content-Type: "application/json" - X-Mock-Engine: "Rust-Gemini-v1.2" - # 直接内联 JSON 字符串 - body: > - { - "code": 0, - "data": "退出成功", - "msg": "success" - } - settings: - delay_ms: 200 # 模拟真实网络延迟 -- id: "auth_login_out_002" - request: - method: "POST" - path: "/api/v1/auth/login_out" - # 必须包含此 Header 才会匹配 - headers: - Content-Type: "application/json" - Authorization: "111" - host: "127.0.0.1:8080" - body: - type: false - response: - status: 200 - headers: - Content-Type: "application/json" - X-Mock-Engine: "Rust-Gemini-v1.2" - # 直接内联 JSON 字符串 - body: > - { - "code": 1, - "data": "退出失败", - "msg": "success" - } - settings: - delay_ms: 200 # 模拟真实网络延迟 \ No newline at end of file diff --git a/mocks/v1/auth/multiple_login.yaml b/mocks/v1/auth/multiple_login.yaml new file mode 100644 index 0000000..00c6ce5 --- /dev/null +++ b/mocks/v1/auth/multiple_login.yaml @@ -0,0 +1,118 @@ +# 用户登录 - JSON 格式 +- name: "user_login_002" + request: + path: "/v1/auth/login" + method: "POST" + headers: + Content-Type: "application/json" + Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6" + host: "127.0.0.1:8080" + body: > + { + "username": "user002", + "password": "password123" + } + response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "登录成功", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6", + "userId": 10002, + "username": "user002", + "role": "administrator" + } + } + settings: + delay_ms: 2000 # 模拟真实网络延迟 + +- name: "user_login_003" + request: + path: "/v1/auth/login" + method: "POST" + headers: + Content-Type: "application/json" + Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6" + host: "127.0.0.1:8080" + body: | + { + "username": "user003", + "password": "password123" + } + response: + status: 200 + headers: + Content-Type: "application/json" + body: > + { + "code": 0, + "message": "登录成功", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6", + "userId": 10003, + "username": "user003", + "role": "administrator" + } + } + settings: + delay_ms: 2000 # 模拟真实网络延迟 + +- name: "user_login_004" + request: + path: "/v1/auth/login" + method: "POST" + headers: + Content-Type: "application/json" + Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6" + host: "127.0.0.1:8080" + body: | + { + "username": "user004", + "password": "password123" + } + response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "登录成功", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6", + "userId": 10004, + "username": "user004", + "role": "administrator" + } + } + +- name: "user_login_005" + request: + path: "/v1/auth/login" + method: "POST" + headers: + Content-Type: "application/json" + Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6" + host: "127.0.0.1:8080" + body: + username: "user005" + password: "password123" + response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "登录成功", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6", + "userId": 10005, + "username": "user005", + "role": "administrator" + } + } \ No newline at end of file diff --git a/mocks/v1/auth/register.yaml b/mocks/v1/auth/register.yaml new file mode 100644 index 0000000..af5a64e --- /dev/null +++ b/mocks/v1/auth/register.yaml @@ -0,0 +1,26 @@ +# 用户注册 - JSON 格式 +name: "user_register" +request: + method: "POST" + path: "/v1/auth/register" + headers: + Content-Type: "application/json" + body: + username: "newuser" + password: "newpass123" + email: "newuser@example.com" +response: + status: 201 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "注册成功", + "data": { + "userId": 10002, + "username": "newuser", + "email": "newuser@example.com", + "createdAt": "2026-03-27T10:00:00Z" + } + } diff --git a/mocks/v1/auth/single_login.yaml b/mocks/v1/auth/single_login.yaml new file mode 100644 index 0000000..19000e5 --- /dev/null +++ b/mocks/v1/auth/single_login.yaml @@ -0,0 +1,29 @@ +# 用户登录 - JSON 格式 +name: "user_login_001" +request: + path: "/v1/auth/login" + method: "POST" + headers: + Content-Type: "application/json" + Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6" + host: "127.0.0.1:8080" + body: + username: "user001" + password: "password123" +response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "登录成功", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6", + "userId": 10001, + "username": "user001", + "role": "administrator" + } + } +settings: + delay_ms: 2000 # 模拟真实网络延迟 diff --git a/mocks/v1/data/export.yaml b/mocks/v1/data/export.yaml new file mode 100644 index 0000000..bcdc0bb --- /dev/null +++ b/mocks/v1/data/export.yaml @@ -0,0 +1,30 @@ +# XML 数据导出 - XML 格式 +name: "data_export" +request: + method: "POST" + path: "/v1/data/export" + headers: + Content-Type: "application/xml" + body: | + + + 10001 + xml + +response: + status: 200 + headers: + Content-Type: "application/xml" + body: | + + + 0 + 导出成功 + + + 10001 + 管理员 + admin@example.com + + + diff --git a/mocks/v1/health.yaml b/mocks/v1/health.yaml new file mode 100644 index 0000000..688a534 --- /dev/null +++ b/mocks/v1/health.yaml @@ -0,0 +1,15 @@ +# 健康检查 - GET 无 Body +name: "health_check" +request: + method: "GET" + path: "/v1/health" +response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "status": "healthy", + "version": "2.0.0", + "timestamp": "2026-03-27T10:00:00Z" + } diff --git a/mocks/v1/products/export.yaml b/mocks/v1/products/export.yaml index faed5af..ea6abca 100644 --- a/mocks/v1/products/export.yaml +++ b/mocks/v1/products/export.yaml @@ -1,12 +1,12 @@ -id: "prod_export_pdf" +name: "prod_export_pdf" request: method: "GET" - path: "/api/v1/products/report" - body: '{"username":"user","password":"123"}' + path: "/v1/products/report" + body: '{"username":"user001","password":"password123"}' response: status: 200 headers: Content-Type: "application/pdf" Content-Disposition: "attachment; filename=report.pdf" # 智能协议:引擎会自动识别前缀并异步读取磁盘文件 - body: "file://./storage/reports/annual_2024.pdf" \ No newline at end of file + body: "file://./storage/v1/hello.pdf" \ No newline at end of file diff --git a/mocks/v1/system/health.yaml b/mocks/v1/system/health.yaml deleted file mode 100644 index a1a2d4b..0000000 --- a/mocks/v1/system/health.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# 使用 YAML 数组语法定义多个规则 -- id: "sys_ping" - request: - method: "GET" - path: "/api/v1/ping" - response: - status: 200 - body: "pong" - -- id: "sys_version" - request: - method: "GET" - path: "/api/v1/version" - response: - status: 200 - body: '{"version": "1.2.0-smart"}' \ No newline at end of file diff --git a/mocks/v1/upload.yaml b/mocks/v1/upload.yaml deleted file mode 100644 index 8a69423..0000000 --- a/mocks/v1/upload.yaml +++ /dev/null @@ -1,23 +0,0 @@ -id: "upload_file" -request: - method: "POST" - path: "/api/v1/upload" - headers: - Content-Type: "multipart/form-data" -response: - status: 200 - headers: - Content-Type: "application/json" - body: > - { - "code": 0, - "data": { - "filename": "example.txt", - "path": "storage/2024-01-15/example.txt", - "size": 1024, - "url": "/storage/2024-01-15/example.txt" - }, - "msg": "upload success" - } -settings: - delay_ms: 100 diff --git a/mocks/v1/user/avatar.yaml b/mocks/v1/user/avatar.yaml new file mode 100644 index 0000000..1337114 --- /dev/null +++ b/mocks/v1/user/avatar.yaml @@ -0,0 +1,73 @@ +# 上传头像 - Multipart 格式(数组形式,只匹配字段名) +- name: "user_upload_avatar_001" + request: + method: "POST" + path: "/v1/user/avatar" + headers: + Content-Type: "multipart/form-data" + body: + - "avatar1" + - "description1" + response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "头像上传成功", + "data": { + "url": "https://cdn.example.com/v1/avatars/10001.jpg", + "size": 204800, + "filename": "avatar.jpg" + } + } + +- name: "user_upload_avatar_002" + request: + method: "POST" + path: "/v1/user/avatar" + headers: + Content-Type: "multipart/form-data" + body: + avatar2: "avatar" + description2: "description" + response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "头像上传成功", + "data": { + "url": "https://cdn.example.com/v1/avatars/10002.jpg", + "size": 204800, + "filename": "avatar.jpg" + } + } +- name: "user_upload_avatar_003" + request: + method: "POST" + path: "/v1/user/avatar" + headers: + Content-Type: "multipart/form-data" + body: > + { + "avatar3": "avatar" + "description3": "description" + } + response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "头像上传成功", + "data": { + "url": "https://cdn.example.com/v1/avatars/10003.jpg", + "size": 204800, + "filename": "avatar.jpg" + } + } \ No newline at end of file diff --git a/mocks/v1/user/download.yaml b/mocks/v1/user/download.yaml new file mode 100644 index 0000000..ef8a024 --- /dev/null +++ b/mocks/v1/user/download.yaml @@ -0,0 +1,13 @@ +# 下载用户数据文件 - file:// 协议 +name: "user_download" +request: + method: "GET" + path: "/v1/user/download" + query_params: + format: "json" +response: + status: 200 + headers: + Content-Type: "application/octet-stream" + Content-Disposition: "attachment; filename=user_data.json" + body: "file://./storage/v1/user_data.json" diff --git a/mocks/v1/user/echo.yaml b/mocks/v1/user/echo.yaml new file mode 100644 index 0000000..a83478d --- /dev/null +++ b/mocks/v1/user/echo.yaml @@ -0,0 +1,13 @@ +# 文本回显 - Text 格式 +name: "user_echo" +request: + method: "POST" + path: "/v1/user/echo" + headers: + Content-Type: "text/plain" + body: "Hello V1 Mock Server" +response: + status: 200 + headers: + Content-Type: "text/plain" + body: "Echo from V1: Hello V1 Mock Server" diff --git a/mocks/v1/user/login_form.yaml b/mocks/v1/user/login_form.yaml new file mode 100644 index 0000000..ea9a072 --- /dev/null +++ b/mocks/v1/user/login_form.yaml @@ -0,0 +1,24 @@ +# 表单登录 - Form 格式 +name: "_user_login_form" +request: + method: "POST" + path: "/v1/user/login/form" + headers: + Content-Type: "application/x-www-form-urlencoded" + body: + username: "formuser" + password: "formpass" +response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "表单登录成功", + "data": { + "token": "v2_form_token_xyz", + "userId": 20001, + "username": "formuser" + } + } diff --git a/mocks/v1/user/profile.yaml b/mocks/v1/user/profile.yaml new file mode 100644 index 0000000..3c8277e --- /dev/null +++ b/mocks/v1/user/profile.yaml @@ -0,0 +1,24 @@ +# 获取用户信息 - GET 无 Body,需要 Authorization Header +name: "user_profile" +request: + method: "GET" + path: "/v1/user/profile" + headers: + Authorization: "Bearer v1_test_token" +response: + status: 200 + headers: + Content-Type: "application/json" + body: | + { + "code": 0, + "message": "获取成功", + "data": { + "userId": 10001, + "username": "admin", + "email": "admin@example.com", + "nickname": "管理员", + "avatar": "https://example.com/avatars/admin.jpg", + "createdAt": "2025-01-01T00:00:00Z" + } + } diff --git a/mocks/v1/user/search.yaml b/mocks/v1/user/search.yaml deleted file mode 100644 index d4848fb..0000000 --- a/mocks/v1/user/search.yaml +++ /dev/null @@ -1,11 +0,0 @@ -id: "user_search_admin" -request: - method: "GET" - path: "/api/v1/users" - # 请求中必须包含 role=admin 且 status=active - query_params: - role: "admin" - status: "active" -response: - status: 200 - body: '{"users": [{"id": 1, "name": "SuperAdmin"}]}' \ No newline at end of file diff --git a/src/handler.rs b/src/handler.rs index 2c5d3f2..a91ea5e 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::sync::{Arc, RwLock}; // 必须引入 RwLock use tokio_util::io::ReaderStream; +use crate::models::Payload; use crate::router::MockRouter; /// 共享的应用状态,router 现在由 RwLock 保护以支持热重载 @@ -15,6 +16,131 @@ pub struct AppState { pub router: RwLock, } +/// 提取请求的 Content-Type(去掉参数部分,如 boundary) +fn extract_content_type(headers: &HeaderMap) -> Option { + headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(';').next().unwrap_or(s).trim().to_lowercase()) +} + +/// 根据 Content-Type 解析 Body(始终以请求的 Content-Type 为准) +fn parse_body(content_type: Option<&str>, bytes: &[u8]) -> Payload { + if bytes.is_empty() { + return Payload::None; + } + + match content_type { + Some(ct) if ct.contains("application/json") => { + serde_json::from_slice(bytes) + .map(Payload::Json) + .unwrap_or_else(|_| { + // JSON 解析失败,降级为文本 + Payload::Text(String::from_utf8_lossy(bytes).to_string()) + }) + } + Some(ct) if ct.contains("xml") => { + Payload::Xml(String::from_utf8_lossy(bytes).to_string()) + } + Some(ct) if ct.contains("form-urlencoded") => { + Payload::Form(parse_urlencoded(bytes)) + } + Some(ct) if ct.contains("multipart/form-data") => { + Payload::Multipart(extract_multipart_data(bytes)) + } + _ => { + Payload::Text(String::from_utf8_lossy(bytes).to_string()) + } + } +} + +/// 解析 urlencoded 格式 +fn parse_urlencoded(bytes: &[u8]) -> HashMap { + let body = String::from_utf8_lossy(bytes); + let mut map = HashMap::new(); + for pair in body.split('&') { + if let Some((key, value)) = pair.split_once('=') { + let decoded_key = urlencoding_decode(key); + let decoded_value = urlencoding_decode(value); + map.insert(decoded_key, decoded_value); + } + } + map +} + +/// URL 解码(简单实现) +fn urlencoding_decode(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '+' { + result.push(' '); + } else if c == '%' { + let hex: String = chars.by_ref().take(2).collect(); + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + result.push(byte as char); + } else { + result.push('%'); + result.push_str(&hex); + } + } else { + result.push(c); + } + } + result +} + +/// 从 multipart body 中提取键值对 +fn extract_multipart_data(bytes: &[u8]) -> HashMap { + let body = String::from_utf8_lossy(bytes); + let mut map = HashMap::new(); + + // 分割 boundary + let lines: Vec<&str> = body.lines().collect(); + let mut current_name: Option = None; + let mut current_value = String::new(); + let mut in_value = false; + + for line in &lines { + // 检测 Content-Disposition 行,提取 name + if line.contains("Content-Disposition") && line.contains("name=") { + // 保存上一个字段的值 + if let Some(name) = current_name.take() { + map.insert(name, current_value.trim().to_string()); + current_value.clear(); + } + + // 提取 name 属性 + if let Some(start) = line.find("name=\"") { + let start = start + 6; + if let Some(end) = line[start..].find('"') { + current_name = Some(line[start..start + end].to_string()); + in_value = false; + } + } + } else if line.starts_with("Content-Type") { + // 跳过 Content-Type 行 + continue; + } else if line.is_empty() { + // 空行后面是值 + in_value = true; + } else if in_value { + // 收集值内容 + if !current_value.is_empty() { + current_value.push('\n'); + } + current_value.push_str(line); + } + } + + // 保存最后一个字段 + if let Some(name) = current_name { + map.insert(name, current_value.trim().to_string()); + } + + map +} + /// 全局统一请求处理函数 pub async fn mock_handler( State(state): State>, // State 必须是第一个或靠前的参数 @@ -27,7 +153,10 @@ pub async fn mock_handler( let path = req.uri().path().to_string(); let method_str = method.as_str().to_string(); - // 2. 读取请求 body(用于 body 字段匹配) + // 2. 提取请求的 Content-Type + let req_content_type = extract_content_type(&headers); + + // 3. 读取请求 body let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await { Ok(bytes) => bytes, Err(_) => { @@ -37,9 +166,11 @@ pub async fn mock_handler( .unwrap(); } }; - let incoming_json: Option = serde_json::from_slice(&body_bytes).ok(); - // 3. 将 Axum HeaderMap 转换为简单的 HashMap + // 4. 根据【请求的 Content-Type】解析 body + let parsed_body = parse_body(req_content_type.as_deref(), &body_bytes); + + // 5. 将 Axum HeaderMap 转换为简单的 HashMap let mut req_headers = HashMap::new(); for (name, value) in headers.iter() { if let Ok(v) = value.to_str() { @@ -47,22 +178,22 @@ pub async fn mock_handler( } } - // 4. 执行匹配逻辑:先获取读锁 (Read Lock) + // 6. 执行匹配逻辑:先获取读锁 (Read Lock) let maybe_rule = { let router = state.router.read().expect("Failed to acquire read lock"); - router.match_rule(&method_str, &path, ¶ms, &req_headers, &incoming_json).cloned() + router.match_rule(&method_str, &path, ¶ms, &req_headers, &parsed_body).cloned() // 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁 }; if let Some(rule) = maybe_rule { - // 5. 处理模拟延迟 + // 7. 处理模拟延迟 if let Some(ref settings) = rule.settings { if let Some(delay) = settings.delay_ms { tokio::time::sleep(std::time::Duration::from_millis(delay)).await; } } - // 6. 构建响应 + // 8. 构建响应 let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK); let mut response_builder = Response::builder().status(status); @@ -72,7 +203,7 @@ pub async fn mock_handler( } } - // 7. Smart Body 逻辑 + // 9. Smart Body 逻辑 if let Some(file_path) = rule.response.get_file_path() { match tokio::fs::File::open(file_path).await { Ok(file) => { diff --git a/src/lib.rs b/src/lib.rs index e13f385..8753663 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ // 声明模块并设为 pub,这样 tests/ 目录才能看到它们 -pub mod config; +pub mod models; pub mod loader; pub mod router; pub mod handler; \ No newline at end of file diff --git a/src/loader.rs b/src/loader.rs index 0a68dea..6cba720 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖 -use crate::config::{MockRule, MockSource}; // 假设 config.rs 中定义了这两个类型 +use crate::models::{MockRule, MockSource}; pub struct MockLoader; diff --git a/src/config.rs b/src/models.rs similarity index 50% rename from src/config.rs rename to src/models.rs index b2ce262..60e1c2f 100644 --- a/src/config.rs +++ b/src/models.rs @@ -1,13 +1,58 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// 解析后的请求 Body +#[derive(Debug, Clone)] +pub enum Payload { + /// JSON 格式 + Json(serde_json::Value), + /// XML 格式 + Xml(String), + /// URL 编码表单 + Form(HashMap), + /// Multipart 表单(键值对) + Multipart(HashMap), + /// 纯文本 + Text(String), + /// 无 Body + None, +} + +impl Payload { + /// 转换为字符串(用于兜底比较) + pub fn to_compare_string(&self) -> String { + match self { + Payload::Json(v) => v.to_string(), + Payload::Xml(s) | Payload::Text(s) => s.clone(), + Payload::Form(map) | Payload::Multipart(map) => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by_key(|(k, _)| *k); + pairs.iter().map(|(k, v)| format!("{}={}", k, v)).collect::>().join("&") + } + Payload::None => String::new(), + } + } + + /// 获取对应的 Content-Type 名称 + pub fn content_type_name(&self) -> &'static str { + match self { + Payload::Json(_) => "application/json", + Payload::Xml(_) => "application/xml", + Payload::Form(_) => "application/x-www-form-urlencoded", + Payload::Multipart(_) => "multipart/form-data", + Payload::Text(_) => "text/plain", + Payload::None => "none", + } + } +} + /// 顶层包装:支持单对象或数组,自动打平 #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum MockSource { - /// 对应“一个接口一个文件”模式 + /// 对应"一个接口一个文件"模式 Single(MockRule), - /// 对应“一个文件多个接口”模式 + /// 对应"一个文件多个接口"模式 Multiple(Vec), } @@ -22,16 +67,16 @@ impl MockSource { } /// 核心 Mock 规则定义 -#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct MockRule { - pub id: String, + pub name: String, pub request: RequestMatcher, pub response: MockResponse, pub settings: Option, } /// 请求匹配条件 -#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct RequestMatcher { pub method: String, pub path: String, @@ -39,12 +84,12 @@ pub struct RequestMatcher { pub query_params: Option>, /// 选填:只有请求包含这些 Header 时才匹配 pub headers: Option>, - // 修改点:从 String 改为 Option + /// 选填:请求体匹配条件 pub body: Option, } /// 响应内容定义 -#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct MockResponse { pub status: u16, pub headers: Option>, @@ -53,7 +98,7 @@ pub struct MockResponse { } /// 模拟器行为设置 -#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct MockSettings { /// 模拟网络延迟(毫秒) pub delay_ms: Option, diff --git a/src/router.rs b/src/router.rs index c68dbcd..132a94c 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use crate::config::MockRule; +use crate::models::{MockRule, Payload}; pub struct MockRouter { // 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则 @@ -18,7 +18,7 @@ impl MockRouter { path: &str, queries: &HashMap, headers: &HashMap, - incoming_body: &Option, // 修改 1: 增加参数 + payload: &Payload, ) -> Option<&MockRule> { // 1. 提取请求路径的首段作为索引 Key let key = self.extract_first_segment(path); @@ -27,7 +27,7 @@ impl MockRouter { if let Some(rules) = self.index.get(&key) { // 3. 在候选集中进行线性深度匹配 for rule in rules { - if self.is_match(rule, method, path, queries, headers,incoming_body) { + if self.is_match(rule, method, path, queries, headers, payload) { return Some(rule); } } @@ -44,18 +44,19 @@ impl MockRouter { path: &str, queries: &HashMap, headers: &HashMap, - incoming_body: &Option, // 修改 3: 增加参数 + payload: &Payload, ) -> bool { // A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠) if rule.request.method.to_uppercase() != method.to_uppercase() { - println!("DEBUG: [ID:{}] Method Mismatch: YAML={}, Req={}", rule.id, rule.request.method, method); + println!("DEBUG: [NAME:{}] Method Mismatch: YAML={}, Req={}", rule.name, rule.request.method, method); return false; } if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') { - println!("DEBUG: [ID:{}] Path Mismatch: YAML='{}', Req='{}'", rule.id, rule.request.path, path); + println!("DEBUG: [NAME:{}] Path Mismatch: YAML='{}', Req='{}'", rule.name, rule.request.path, path); return false; } - println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id); + println!("DEBUG: [NAME:{}] Method and Path matched! Checking headers...", rule.name); + // B. Query 参数校验 (子集匹配原则) if let Some(ref required_queries) = rule.request.query_params { for (key, val) in required_queries { @@ -64,47 +65,72 @@ impl MockRouter { } } } - - // C. Header 校验 (优化版) + + // C. Header 校验 (大小写不敏感,Content-Type 使用前缀匹配) if let Some(ref required_headers) = rule.request.headers { for (key, val) in required_headers { - println!("{}:{}",key.clone(), val.clone()); - // 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值 + let key_lower = key.to_lowercase(); let matched = headers.iter().any(|(k, v)| { - // k.to_lowercase() == key.to_lowercase() && v.contains(val) - k.to_lowercase() == key.to_lowercase() && v==val + if k.to_lowercase() != key_lower { + return false; + } + // Content-Type 使用前缀匹配(因为可能包含 boundary 等参数) + if key_lower == "content-type" { + v.to_lowercase().starts_with(&val.to_lowercase()) + } else { + v == val + } }); - if !matched { return false; } } } - // D. 智能 Body 全量比对逻辑 - if let Some(ref required_val) = rule.request.body { - match incoming_body { - Some(actual_val) => { - // 实现你的想法:尝试将 YAML 中的 String 转换为 Object 再对比 - let final_required = if let Some(s) = required_val.as_str() { - // 如果能解析成 JSON,就用解析后的对象,否则用原始字符串 Value - serde_json::from_str::(s).unwrap_or_else(|_| required_val.clone()) - } else { - required_val.clone() - }; - // 执行全量相等比对 - if final_required != *actual_val { - println!("DEBUG: [ID:{}] Body Mismatch", rule.id); - return false; - } - } - None => return false, // YAML 要求有 Body 但请求为空 - } + // D. Body 匹配(根据 Payload 类型智能比较) + if let Some(ref yaml_body) = rule.request.body { + return self.match_body(yaml_body, payload); } true } + /// Body 匹配逻辑 + fn match_body(&self, yaml_body: &serde_json::Value, payload: &Payload) -> bool { + // 将 YAML body 规范化:如果是字符串,尝试解析为 JSON + let normalized_body = normalize_yaml_body(yaml_body); + + match payload { + Payload::Json(actual) => { + // JSON 对象比较 + &normalized_body == actual + } + Payload::Xml(actual) => { + // XML 字符串比较(规范化后比较) + normalized_body.as_str() + .map(|expected| normalize_xml(expected) == normalize_xml(actual)) + .unwrap_or(false) + } + Payload::Form(actual) => { + // Form 键值对比较(子集匹配) + compare_form_with_yaml(&normalized_body, actual) + } + Payload::Multipart(actual_data) => { + // Multipart 匹配:支持键值对或字段名列表 + compare_multipart_with_yaml(&normalized_body, actual_data) + } + Payload::Text(actual) => { + // 字符串比较(去掉首尾空白) + normalized_body.as_str() + .map(|expected| expected.trim() == actual.trim()) + .unwrap_or_else(|| normalized_body.to_string().trim() == actual.trim()) + } + Payload::None => { + false // YAML 配置了 body,但请求没有 body + } + } + } + /// 与 Loader 保持一致的 Key 提取算法 fn extract_first_segment(&self, path: &str) -> String { path.trim_start_matches('/') @@ -113,4 +139,87 @@ impl MockRouter { .unwrap_or("root") .to_string() } -} \ No newline at end of file +} + +/// 规范化 YAML body:如果是字符串,尝试解析为 JSON +fn normalize_yaml_body(yaml_body: &serde_json::Value) -> serde_json::Value { + if let Some(s) = yaml_body.as_str() { + // 先 trim 去掉前导/尾随空格,再尝试解析为 JSON + let trimmed = s.trim(); + if let Ok(parsed) = serde_json::from_str::(trimmed) { + return parsed; + } + } + yaml_body.clone() +} + +/// 规范化 XML 字符串:去掉声明、多余空白、格式化为紧凑形式 +fn normalize_xml(xml: &str) -> String { + let mut result = xml.to_string(); + + // 去掉 XML 声明 + if let Some(pos) = result.find("?>") { + if result[..pos].contains(">() + .chunks(1) + .map(|c| c[0]) + .collect::(); + + // 分割成行,去掉每行首尾空白,过滤空行 + result = result + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .collect::(); + + result +} + +/// Form 比较:YAML 中的键值对必须是请求的子集 +fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap) -> bool { + let yaml_map = match yaml_body.as_object() { + Some(obj) => obj, + None => return false, + }; + + for (key, yaml_val) in yaml_map { + let expected = yaml_val.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_val.to_string()); + if actual.get(key) != Some(&expected) { + return false; + } + } + true +} + +/// Multipart 比较:对象和数组形式都只匹配字段名是否存在 +fn compare_multipart_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap) -> bool { + // 方式 1:对象形式 - 只匹配键名是否存在(忽略值) + if let Some(yaml_map) = yaml_body.as_object() { + for key in yaml_map.keys() { + if !actual.contains_key(key) { + return false; + } + } + return true; + } + + // 方式 2:数组形式 - 只匹配字段名是否存在 + if let Some(yaml_array) = yaml_body.as_array() { + for yaml_field in yaml_array { + let field_name = yaml_field.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_field.to_string()); + if !actual.contains_key(&field_name) { + return false; + } + } + return true; + } + + false +} diff --git a/storage/v1/hello.pdf b/storage/v1/hello.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f2260e52d900d664106bb37d75a8f3aa797e0a0a GIT binary patch literal 23457 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 D9$AFl literal 0 HcmV?d00001 diff --git a/storage/v1/user_data.json b/storage/v1/user_data.json new file mode 100644 index 0000000..673d10c --- /dev/null +++ b/storage/v1/user_data.json @@ -0,0 +1,20 @@ +{ + "exportInfo": { + "exportedAt": "2026-03-27T10:00:00Z", + "version": "2.0.0" + }, + "user": { + "id": 10001, + "username": "admin", + "email": "admin@example.com", + "nickname": "管理员", + "role": "administrator", + "createdAt": "2025-01-01T00:00:00Z", + "lastLoginAt": "2026-03-27T09:30:00Z" + }, + "preferences": { + "theme": "dark", + "language": "zh-CN", + "notifications": true + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 5355fae..4717950 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,160 +1,92 @@ use std::fs::{self, File}; use std::io::Write; use tempfile::tempdir; -// 确保 Cargo.toml 中有 serde_json -use serde_json::json; -use mock_server::config::{MockRule, MockSource}; +use mock_server::models::MockSource; use mock_server::loader::MockLoader; -use mock_server::router::MockRouter; -use std::collections::HashMap; -/// 模块一:验证 Config 反序列化逻辑 #[test] -fn test_config_parsing_scenarios() { - // 场景 A: 验证单接口配置 (增加 body 结构化校验) - let yaml_single = r#" - id: "auth_v1" +fn test_config_deserialization() { + // 测试 1:验证单接口 YAML 解析 + let single_yaml = r#" + name: "auth_v1" request: method: "POST" path: "/api/v1/login" - body: { "user": "admin" } - response: { status: 200, body: "welcome" } + response: + status: 200 + body: "inline_content" "#; - let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败"); - let rules = source_s.flatten(); - assert_eq!(rules.len(), 1); - // 验证 body 是否被成功解析为 Value::Object - assert!(rules[0].request.body.is_some()); - assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin"); + let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口"); + assert_eq!(res.flatten().len(), 1); - // 场景 B: 验证多接口配置 (原有逻辑不变) - let yaml_multi = r#" - - id: "api_1" - request: { method: "GET", path: "/health" } - response: { status: 200, body: "ok" } - - id: "api_2" - request: { method: "GET", path: "/version" } - response: { status: 200, body: "1.0" } + // 测试 2:验证多接口 YAML 数组解析 + let multi_yaml = r#" + - name: "api_1" + request: { method: "GET", path: "/1" } + response: { status: 200, body: "b1" } + - name: "api_2" + request: { method: "GET", path: "/2" } + response: { status: 200, body: "b2" } "#; - let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败"); - assert_eq!(source_m.flatten().len(), 2); - - // 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 (原有逻辑不变) - let yaml_file = r#" - id: "export_api" - request: { method: "GET", path: "/download" } - response: { status: 200, body: "file://./storage/data.zip" } - "#; - let source_f: MockSource = serde_yaml::from_str(yaml_file).unwrap(); - let rule = &source_f.flatten()[0]; - assert!(rule.response.body.starts_with("file://")); + let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组"); + assert_eq!(res_multi.flatten().len(), 2); } -/// 模块二:验证 Loader 递归扫描与索引构建 (不涉及 Matcher 逻辑,基本保持不变) #[test] -fn test_loader_recursive_indexing() { - let temp_root = tempdir().expect("无法创建临时目录"); - let root_path = temp_root.path(); +fn test_recursive_loading_logic() { + // 创建临时 Mock 目录结构 + let root_dir = tempdir().expect("创建临时目录失败"); + let root_path = root_dir.path(); - let auth_path = root_path.join("v1/auth"); - fs::create_dir_all(&auth_path).unwrap(); + // 构造物理层级:mocks/v1/user/ + let user_dir = root_path.join("v1/user"); + fs::create_dir_all(&user_dir).unwrap(); - let mut f1 = File::create(auth_path.join("login.yaml")).unwrap(); - writeln!(f1, "id: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap(); + // 在深层目录创建单接口文件 + let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap(); + writeln!( + file1, + r#" + name: "user_profile" + request: + method: "GET" + path: "/api/v1/user/profile" + response: + status: 200 + body: "profile_data" + "# + ) + .unwrap(); - let mut f2 = File::create(root_path.join("sys.yaml")).unwrap(); - writeln!(f2, "- id: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap(); + // 在根目录创建多接口文件 + let mut file2 = File::create(root_path.join("system.yaml")).unwrap(); + writeln!( + file2, + r#" + - name: "sys_health" + request: {{ method: "GET", path: "/health" }} + response: {{ status: 200, body: "ok" }} + - name: "sys_version" + request: {{ method: "GET", path: "/version" }} + response: {{ status: 200, body: "1.0.0" }} + "# + ) + .unwrap(); + // 执行加载 let index = MockLoader::load_all_from_dir(root_path); - assert!(index.contains_key("api")); - assert!(index.contains_key("health")); - let total: usize = index.values().map(|v| v.len()).sum(); - assert_eq!(total, 2); + // 断言结果: + // 1. 检查 Key 是否根据路径首段正确提取 + assert!(index.contains_key("api"), "索引应包含 'api' 键"); + assert!(index.contains_key("health"), "索引应包含 'health' 键"); + assert!(index.contains_key("version"), "索引应包含 'version' 键"); + + // 2. 检查规则总数 + let total_rules: usize = index.values().map(|v| v.len()).sum(); + assert_eq!(total_rules, 3, "总规则数应为 3"); + + // 3. 检查深层文件是否被正确读取 + let api_rules = &index["api"]; + assert_eq!(api_rules[0].name, "user_profile"); } - -#[test] -fn test_router_matching_logic() { - let mut index = HashMap::new(); - - // 1. 准备带有 Body 的规则 - let rule_auth = serde_yaml::from_str::( - r#" - id: "auth_v1" - request: - method: "POST" - path: "/api/v1/login" - headers: { "Content-Type": "application/json" } - body: { "code": 123 } - response: { status: 200, body: "token_123" } - "#, - ) - .unwrap() - .flatten(); - - index.insert("api".to_string(), vec![rule_auth[0].clone()]); - let router = MockRouter::new(index); - - // 2. 测试场景 A:完全匹配 (包括 Body) - let mut headers = HashMap::new(); - headers.insert("Content-Type".to_string(), "application/json".to_string()); - - // 构造请求 Body - let incoming_body = Some(json!({ "code": 123 })); - - let matched = router.match_rule( - "POST", - "/api/v1/login", - &HashMap::new(), - &headers, - &incoming_body // 传入新参数 - ); - assert!(matched.is_some()); - assert_eq!(matched.unwrap().id, "auth_v1"); - - // 3. 测试场景 B:Body 不匹配 - let wrong_body = Some(json!({ "code": 456 })); - let matched_fail = router.match_rule( - "POST", - "/api/v1/login", - &HashMap::new(), - &headers, - &wrong_body - ); - assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功"); - - // 4. 测试场景 C:智能字符串转换验证 (YAML 是字符串,请求是对象) - let rule_str_body = serde_yaml::from_str::( - r#" - id: "str_match" - request: - method: "POST" - path: "/api/str" - body: '{"type": "json_in_string"}' # 这里 YAML 解析为 Value::String - response: { status: 200, body: "ok" } - "#).unwrap().flatten(); - - let mut index2 = HashMap::new(); - index2.insert("api".to_string(), vec![rule_str_body[0].clone()]); - let router2 = MockRouter::new(index2); - - let incoming_obj = Some(json!({ "type": "json_in_string" })); // 请求是 JSON 对象 - let matched_str = router2.match_rule( - "POST", - "/api/str", - &HashMap::new(), - &HashMap::new(), - &incoming_obj - ); - assert!(matched_str.is_some(), "应该支持将 YAML 字符串 body 转换为对象进行匹配"); - - // 5. 测试场景 D:末尾斜杠兼容性测试 - let matched_slash = router.match_rule( - "POST", - "/api/v1/login/", - &HashMap::new(), - &headers, - &incoming_body - ); - assert!(matched_slash.is_some()); -} \ No newline at end of file diff --git a/tests/loader_test.rs b/tests/loader_test.rs index 64a0735..9b22b08 100644 --- a/tests/loader_test.rs +++ b/tests/loader_test.rs @@ -1,114 +1,425 @@ use std::fs::{self, File}; use std::io::Write; use tempfile::tempdir; -// 假设你的项目名在 Cargo.toml 中叫 mock_server -use mock_server::config::MockSource; +use serde_json::json; +use mock_server::models::{MockRule, MockSource, Payload}; use mock_server::loader::MockLoader; +use mock_server::router::MockRouter; +use std::collections::HashMap; +/// 模块一:验证 Config 反序列化逻辑 #[test] -fn test_config_deserialization() { - // 测试 1:验证单接口 YAML 解析 - let single_yaml = r#" - id: "auth_v1" +fn test_config_parsing_scenarios() { + // 场景 A: 验证单接口配置 (增加 body 结构化校验) + let yaml_single = r#" + name: "auth_v1" request: method: "POST" path: "/api/v1/login" - response: - status: 200 - body: "inline_content" + body: { "user": "admin" } + response: { status: 200, body: "welcome" } "#; - let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口"); - assert_eq!(res.flatten().len(), 1); - // assert_eq!(res.flatten()[0].id, "auth_v1"); + let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败"); + let rules = source_s.flatten(); + assert_eq!(rules.len(), 1); + // 验证 body 是否被成功解析为 Value::Object + assert!(rules[0].request.body.is_some()); + assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin"); - // 测试 2:验证多接口 YAML 数组解析 - let multi_yaml = r#" - - id: "api_1" - request: { method: "GET", path: "/1" } - response: { status: 200, body: "b1" } - - id: "api_2" - request: { method: "GET", path: "/2" } - response: { status: 200, body: "b2" } + // 场景 B: 验证多接口配置 + let yaml_multi = r#" + - name: "api_1" + request: { method: "GET", path: "/health" } + response: { status: 200, body: "ok" } + - name: "api_2" + request: { method: "GET", path: "/version" } + response: { status: 200, body: "1.0" } "#; - let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组"); - assert_eq!(res_multi.flatten().len(), 2); + let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败"); + assert_eq!(source_m.flatten().len(), 2); + + // 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 + let yaml_file = r#" + name: "export_api" + request: { method: "GET", path: "/download" } + response: { status: 200, body: "file://./storage/data.zip" } + "#; + let source_f: MockSource = serde_yaml::from_str(yaml_file).unwrap(); + let rule = &source_f.flatten()[0]; + assert!(rule.response.body.starts_with("file://")); } +/// 模块二:验证 Loader 递归扫描与索引构建 #[test] -fn test_recursive_loading_logic() { - // 创建临时 Mock 目录结构 - let root_dir = tempdir().expect("创建临时目录失败"); - let root_path = root_dir.path(); +fn test_loader_recursive_indexing() { + let temp_root = tempdir().expect("无法创建临时目录"); + let root_path = temp_root.path(); - // 构造物理层级:mocks/v1/user/ - let user_dir = root_path.join("v1/user"); - fs::create_dir_all(&user_dir).unwrap(); + let auth_path = root_path.join("v1/auth"); + fs::create_dir_all(&auth_path).unwrap(); - // 在深层目录创建单接口文件 - let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap(); - writeln!( - file1, - r#" - id: "user_profile" - request: - method: "GET" - path: "/api/v1/user/profile" - response: - status: 200 - body: "profile_data" - "# - ) - .unwrap(); + let mut f1 = File::create(auth_path.join("single_login.yaml")).unwrap(); + writeln!(f1, "name: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap(); - // 在根目录创建多接口文件 - let mut file2 = File::create(root_path.join("system.yaml")).unwrap(); - writeln!( - file2, - r#" - - id: "sys_health" - request: {{ method: "GET", path: "/health" }} - response: {{ status: 200, body: "ok" }} - - id: "sys_version" - request: {{ method: "GET", path: "/version" }} - response: {{ status: 200, body: "1.0.0" }} - "# - ) - .unwrap(); -// writeln!( -// file2, -// r#" -// - id: "sys_health" -// request: -// method: "GET" -// path: "/health" -// response: -// status: 200 -// body: "ok" -// - id: "sys_version" -// request: -// method: "GET" -// path: "/version" -// response: -// status: 200 -// body: "1.0.0" -// "# -// ) -// .unwrap(); + let mut f2 = File::create(root_path.join("sys.yaml")).unwrap(); + writeln!(f2, "- name: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap(); - // 执行加载 let index = MockLoader::load_all_from_dir(root_path); - // 断言结果: - // 1. 检查 Key 是否根据路径首段正确提取(/api/v1/... -> api, /health -> health) - assert!(index.contains_key("api"), "索引应包含 'api' 键"); - assert!(index.contains_key("health"), "索引应包含 'health' 键"); - assert!(index.contains_key("version"), "索引应包含 'version' 键"); - - // 2. 检查规则总数 - let total_rules: usize = index.values().map(|v| v.len()).sum(); - assert_eq!(total_rules, 3, "总规则数应为 3"); - - // 3. 检查深层文件是否被正确读取 - let api_rules = &index["api"]; - assert_eq!(api_rules[0].id, "user_profile"); + assert!(index.contains_key("api")); + assert!(index.contains_key("health")); + let total: usize = index.values().map(|v| v.len()).sum(); + assert_eq!(total, 2); +} + +/// 模块三:验证 Router 匹配逻辑(使用新的 Payload 类型) +#[test] +fn test_router_matching_logic() { + let mut index = HashMap::new(); + + // 1. 准备带有 Body 的规则 + let rule_auth = serde_yaml::from_str::( + r#" + name: "auth_v1" + request: + method: "POST" + path: "/api/v1/login" + headers: { "Content-Type": "application/json" } + body: { "code": 123 } + response: { status: 200, body: "token_123" } + "#, + ) + .unwrap() + .flatten(); + + index.insert("api".to_string(), vec![rule_auth[0].clone()]); + let router = MockRouter::new(index); + + // 2. 测试场景 A:JSON 完全匹配 + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + + let payload = Payload::Json(json!({ "code": 123 })); + + let matched = router.match_rule( + "POST", + "/api/v1/login", + &HashMap::new(), + &headers, + &payload, + ); + assert!(matched.is_some()); + assert_eq!(matched.unwrap().name, "auth_v1"); + + // 3. 测试场景 B:Body 不匹配 + let wrong_payload = Payload::Json(json!({ "code": 456 })); + let matched_fail = router.match_rule( + "POST", + "/api/v1/login", + &HashMap::new(), + &headers, + &wrong_payload, + ); + assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功"); + + // 4. 测试场景 C:末尾斜杠兼容性测试 + let matched_slash = router.match_rule( + "POST", + "/api/v1/login/", + &HashMap::new(), + &headers, + &payload, + ); + assert!(matched_slash.is_some()); +} + +/// 模块四:验证不同 Payload 类型的匹配 +#[test] +fn test_payload_type_matching() { + let mut index = HashMap::new(); + + // XML 规则 + let xml_rule = serde_yaml::from_str::( + r#" + name: "xml_api" + request: + method: "POST" + path: "/api/xml" + body: "test" + response: { status: 200, body: "ok" } + "#, + ) + .unwrap() + .flatten(); + + // Form 规则 + let form_rule = serde_yaml::from_str::( + r#" + name: "form_api" + request: + method: "POST" + path: "/api/form" + body: { "username": "admin", "password": "123456" } + response: { status: 200, body: "login_ok" } + "#, + ) + .unwrap() + .flatten(); + + // Text 规则 + let text_rule = serde_yaml::from_str::( + r#" + name: "text_api" + request: + method: "POST" + path: "/api/text" + body: "plain text content" + response: { status: 200, body: "text_ok" } + "#, + ) + .unwrap() + .flatten(); + + index.insert("api".to_string(), vec![xml_rule[0].clone(), form_rule[0].clone(), text_rule[0].clone()]); + let router = MockRouter::new(index); + + // 测试 XML 匹配 + let xml_payload = Payload::Xml("test".to_string()); + let xml_matched = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_payload); + assert!(xml_matched.is_some(), "XML 应该匹配"); + assert_eq!(xml_matched.unwrap().name, "xml_api"); + + // 测试 Form 匹配 + let mut form_data = HashMap::new(); + form_data.insert("username".to_string(), "admin".to_string()); + form_data.insert("password".to_string(), "123456".to_string()); + let form_payload = Payload::Form(form_data); + let form_matched = router.match_rule("POST", "/api/form", &HashMap::new(), &HashMap::new(), &form_payload); + assert!(form_matched.is_some(), "Form 应该匹配"); + assert_eq!(form_matched.unwrap().name, "form_api"); + + // 测试 Text 匹配 + let text_payload = Payload::Text("plain text content".to_string()); + let text_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &text_payload); + assert!(text_matched.is_some(), "Text 应该匹配"); + assert_eq!(text_matched.unwrap().name, "text_api"); + + // 测试 None 不匹配 + let none_payload = Payload::None; + let none_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &none_payload); + assert!(none_matched.is_none(), "None 不应该匹配有 body 的规则"); +} + +/// 模块五:验证 Payload 方法 +#[test] +fn test_payload_methods() { + // 测试 content_type_name + assert_eq!(Payload::Json(json!({})).content_type_name(), "application/json"); + assert_eq!(Payload::Xml("".to_string()).content_type_name(), "application/xml"); + assert_eq!(Payload::Form(HashMap::new()).content_type_name(), "application/x-www-form-urlencoded"); + assert_eq!(Payload::Multipart(HashMap::new()).content_type_name(), "multipart/form-data"); + assert_eq!(Payload::Text("".to_string()).content_type_name(), "text/plain"); + assert_eq!(Payload::None.content_type_name(), "none"); + + // 测试 to_compare_string + let json_payload = Payload::Json(json!({"a": 1, "b": 2})); + assert!(json_payload.to_compare_string().contains("\"a\":1")); + + let text_payload = Payload::Text("hello world".to_string()); + assert_eq!(text_payload.to_compare_string(), "hello world"); + + let none_payload = Payload::None; + assert_eq!(none_payload.to_compare_string(), ""); + + // 测试 Form 排序后的字符串 + let mut form_map = HashMap::new(); + form_map.insert("b".to_string(), "2".to_string()); + form_map.insert("a".to_string(), "1".to_string()); + let form_payload = Payload::Form(form_map); + assert_eq!(form_payload.to_compare_string(), "a=1&b=2"); + + // 测试 Multipart 排序后的字符串 + let mut multipart_map = HashMap::new(); + multipart_map.insert("b".to_string(), "2".to_string()); + multipart_map.insert("a".to_string(), "1".to_string()); + let multipart_payload = Payload::Multipart(multipart_map); + assert_eq!(multipart_payload.to_compare_string(), "a=1&b=2"); +} + +/// 模块六:验证 Multipart 匹配 +#[test] +fn test_multipart_matching() { + let mut index = HashMap::new(); + + // 数组形式:只匹配字段名 + let array_rule = serde_yaml::from_str::( + r#" +name: "upload_array" +request: + method: "POST" + path: "/api/upload/array" + body: ["file", "description"] +response: { status: 200, body: "ok" } +"#, + ) + .unwrap() + .flatten(); + + // 对象形式:匹配键值对 + let object_rule = serde_yaml::from_str::( + r#" +name: "upload_object" +request: + method: "POST" + path: "/api/upload/object" + body: { "username": "admin", "role": "user" } +response: { status: 200, body: "ok" } +"#, + ) + .unwrap() + .flatten(); + + index.insert("api".to_string(), vec![array_rule[0].clone(), object_rule[0].clone()]); + let router = MockRouter::new(index); + + // 测试数组形式:匹配字段名 + let mut full_data = HashMap::new(); + full_data.insert("file".to_string(), "file_content".to_string()); + full_data.insert("description".to_string(), "test file".to_string()); + let full_payload = Payload::Multipart(full_data); + let matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &full_payload); + assert!(matched.is_some(), "包含所有字段应该匹配"); + + // 测试数组形式:缺少字段 + let mut partial_data = HashMap::new(); + partial_data.insert("file".to_string(), "file_content".to_string()); + let partial_payload = Payload::Multipart(partial_data); + let not_matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &partial_payload); + assert!(not_matched.is_none(), "缺少字段不应该匹配"); + + // 测试对象形式:键名匹配(值被忽略) + let mut correct_data = HashMap::new(); + correct_data.insert("username".to_string(), "any_value".to_string()); // 值不重要 + correct_data.insert("role".to_string(), "any_role".to_string()); + let correct_payload = Payload::Multipart(correct_data); + let object_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &correct_payload); + assert!(object_matched.is_some(), "键名匹配应该成功"); + + // 测试对象形式:键名不匹配 + let mut wrong_data = HashMap::new(); + wrong_data.insert("username".to_string(), "admin".to_string()); + wrong_data.insert("other_field".to_string(), "value".to_string()); // 缺少 role 字段 + let wrong_payload = Payload::Multipart(wrong_data); + let wrong_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &wrong_payload); + assert!(wrong_matched.is_none(), "缺少键名不应该成功"); +} + +/// 模块七:验证 YAML 块语法(折叠 `>` 和字面量 `|`) +#[test] +fn test_yaml_block_syntax() { + let mut index = HashMap::new(); + + // 使用折叠语法 `>` 的规则 + let folded_rule = serde_yaml::from_str::( + r#" +name: "folded_api" +request: + method: "POST" + path: "/api/folded" + body: > + {"username":"admin","password":"123456"} +response: + status: 200 + body: "ok" +"#, + ) + .unwrap() + .flatten(); + + // 使用字面量语法 `|` 的规则 + let literal_rule = serde_yaml::from_str::( + r#" +name: "literal_api" +request: + method: "POST" + path: "/api/literal" + body: | + {"username":"test","password":"abcdef"} +response: + status: 200 + body: "ok" +"#, + ) + .unwrap() + .flatten(); + + index.insert("api".to_string(), vec![folded_rule[0].clone(), literal_rule[0].clone()]); + let router = MockRouter::new(index); + + // 测试折叠语法:YAML 中 `>` 会把内容合并成一行,但仍是字符串 + // 程序应该能将字符串解析为 JSON 后匹配 + let folded_payload = Payload::Json(json!({"username":"admin","password":"123456"})); + let folded_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &folded_payload); + assert!(folded_matched.is_some(), "折叠语法应该匹配 JSON 请求"); + assert_eq!(folded_matched.unwrap().name, "folded_api"); + + // 测试字面量语法:YAML 中 `|` 保留换行 + let literal_payload = Payload::Json(json!({"username":"test","password":"abcdef"})); + let literal_matched = router.match_rule("POST", "/api/literal", &HashMap::new(), &HashMap::new(), &literal_payload); + assert!(literal_matched.is_some(), "字面量语法应该匹配 JSON 请求"); + assert_eq!(literal_matched.unwrap().name, "literal_api"); + + // 测试不匹配的情况 + let wrong_payload = Payload::Json(json!({"username":"wrong","password":"wrong"})); + let wrong_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &wrong_payload); + assert!(wrong_matched.is_none(), "错误的 body 不应该匹配"); +} + +/// 模块八:验证 XML 格式化匹配 +#[test] +fn test_xml_normalized_matching() { + let mut index = HashMap::new(); + + // YAML 中的 XML(带格式化) + let xml_rule = serde_yaml::from_str::( + r#" +name: "xml_api" +request: + method: "POST" + path: "/api/xml" + body: | + + 1001 + 张三 + +response: + status: 200 + body: "ok" +"#, + ) + .unwrap() + .flatten(); + + index.insert("api".to_string(), vec![xml_rule[0].clone()]); + let router = MockRouter::new(index); + + // 测试 1:完全相同的格式化 XML + let formatted_xml = Payload::Xml("\n 1001\n 张三\n".to_string()); + let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &formatted_xml); + assert!(matched1.is_some(), "格式化 XML 应该匹配"); + + // 测试 2:紧凑格式的 XML + let compact_xml = Payload::Xml("1001张三".to_string()); + let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml); + assert!(matched2.is_some(), "紧凑格式 XML 应该匹配"); + + // 测试 3:带 XML 声明的请求 + let xml_with_decl = Payload::Xml("1001张三".to_string()); + let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl); + assert!(matched3.is_some(), "带声明的 XML 应该匹配"); + + // 测试 4:内容不同,不应该匹配 + let wrong_xml = Payload::Xml("1002李四".to_string()); + let matched4 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml); + assert!(matched4.is_none(), "内容不同的 XML 不应该匹配"); } diff --git a/tests/v2_integration_test.rs b/tests/v2_integration_test.rs new file mode 100644 index 0000000..fd4dfd1 --- /dev/null +++ b/tests/v2_integration_test.rs @@ -0,0 +1,238 @@ +use std::path::Path; +use std::collections::HashMap; +use serde_json::json; +use mock_server::models::Payload; +use mock_server::loader::MockLoader; +use mock_server::router::MockRouter; + +/// 加载 v1 目录的 mock 规则 +fn load_v2_mocks() -> HashMap> { + let v2_path = Path::new("../mocks/v1"); + MockLoader::load_all_from_dir(v2_path) +} + +// ========== 模块一:验证所有 YAML 文件正确加载 ========== + +#[test] +fn test_v2_load_all_mocks() { + let index = load_v2_mocks(); + + // 验证索引键存在 + assert!(index.contains_key("v1"), "应包含 'v2' 索引键"); + + // 验证规则总数 + let total: usize = index.values().map(|v| v.len()).sum(); + assert_eq!(total, 9, "v2 目录应有 9 个 mock 规则"); +} + +// ========== 模块二:JSON Payload 测试 ========== + +#[test] +fn test_v2_json_login() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + + let payload = Payload::Json(json!({ + "username": "admin", + "password": "password123" + })); + + let matched = router.match_rule("POST", "/v1/auth/login", &HashMap::new(), &headers, &payload); + + assert!(matched.is_some(), "JSON 登录应匹配成功"); + let rule = matched.unwrap(); + assert_eq!(rule.name, "v2_user_login"); + assert_eq!(rule.response.status, 200); +} + +#[test] +fn test_v2_json_register() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + + let payload = Payload::Json(json!({ + "username": "newuser", + "password": "newpass123", + "email": "newuser@example.com" + })); + + let matched = router.match_rule("POST", "/v1/auth/register", &HashMap::new(), &headers, &payload); + + assert!(matched.is_some(), "JSON 注册应匹配成功"); + let rule = matched.unwrap(); + assert_eq!(rule.name, "v2_user_register"); + assert_eq!(rule.response.status, 201); +} + +// ========== 模块三:Form Payload 测试 ========== + +#[test] +fn test_v2_form_login() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/x-www-form-urlencoded".to_string()); + + let mut form_data = HashMap::new(); + form_data.insert("username".to_string(), "formuser".to_string()); + form_data.insert("password".to_string(), "formpass".to_string()); + let payload = Payload::Form(form_data); + + let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload); + + assert!(matched.is_some(), "Form 登录应匹配成功"); + assert_eq!(matched.unwrap().name, "v2_user_login_form"); +} + +// ========== 模块四:Text Payload 测试 ========== + +#[test] +fn test_v2_text_echo() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "text/plain".to_string()); + + let payload = Payload::Text("Hello V2 Mock Server".to_string()); + + let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload); + + assert!(matched.is_some(), "Text 回显应匹配成功"); + let rule = matched.unwrap(); + assert_eq!(rule.name, "v2_user_echo"); + assert!(rule.response.body.contains("Echo from V2")); +} + +// ========== 模块五:XML Payload 测试 ========== + +#[test] +fn test_v2_xml_export() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/xml".to_string()); + + let xml_body = r#"10001xml"#; + let payload = Payload::Xml(xml_body.to_string()); + + let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload); + + assert!(matched.is_some(), "XML 导出应匹配成功"); + assert_eq!(matched.unwrap().name, "v2_data_export"); +} + +// ========== 模块六:Multipart Payload 测试 ========== + +#[test] +fn test_v2_multipart_upload() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "multipart/form-data".to_string()); + + let mut multipart_data = HashMap::new(); + multipart_data.insert("avatar".to_string(), "file_content".to_string()); + multipart_data.insert("description".to_string(), "user avatar".to_string()); + let payload = Payload::Multipart(multipart_data); + + let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload); + + assert!(matched.is_some(), "Multipart 上传应匹配成功"); + assert_eq!(matched.unwrap().name, "v2_user_upload_avatar"); +} + +// ========== 模块七:None Payload 测试 (GET 无 Body) ========== + +#[test] +fn test_v2_health_check() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let payload = Payload::None; + + let matched = router.match_rule("GET", "/v1/health", &HashMap::new(), &HashMap::new(), &payload); + + assert!(matched.is_some(), "健康检查应匹配成功"); + let rule = matched.unwrap(); + assert_eq!(rule.name, "v2_health_check"); + assert_eq!(rule.response.status, 200); +} + +#[test] +fn test_v2_get_profile() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), "Bearer v2_test_token".to_string()); + + let payload = Payload::None; + + let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload); + + assert!(matched.is_some(), "获取用户信息应匹配成功"); + assert_eq!(matched.unwrap().name, "v2_user_profile"); +} + +// ========== 模块八:Query Params 测试 ========== + +#[test] +fn test_v2_query_params() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + let mut query = HashMap::new(); + query.insert("format".to_string(), "json".to_string()); + + let payload = Payload::None; + + let matched = router.match_rule("GET", "/v1/user/download", &query, &HashMap::new(), &payload); + + assert!(matched.is_some(), "带 query params 的下载应匹配成功"); + let rule = matched.unwrap(); + assert_eq!(rule.name, "v2_user_download"); + assert!(rule.response.body.starts_with("file://")); +} + +// ========== 模块九:Header 匹配测试 ========== + +#[test] +fn test_v2_header_required() { + let index = load_v2_mocks(); + let router = MockRouter::new(index); + + // 无 Authorization header 不应匹配 + let payload = Payload::None; + let no_match = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &HashMap::new(), &payload); + assert!(no_match.is_none(), "无 Authorization 不应匹配"); + + // 有正确的 Authorization header 应匹配 + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), "Bearer v2_test_token".to_string()); + let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload); + assert!(matched.is_some(), "有 Authorization 应匹配"); +} + +// ========== 模块十:name 字段验证 ========== + +#[test] +fn test_v2_all_rules_have_name() { + let index = load_v2_mocks(); + + for (key, rules) in &index { + for rule in rules { + assert!(!rule.name.is_empty(), "规则 name 不能为空"); + assert!(rule.name.starts_with("v2_"), "v2 规则 name 应以 'v2_' 开头"); + } + } +}