diff --git a/mocks/v1/auth/login.yaml b/mocks/v1/auth/login.yaml index 64ec57c..3ab6dad 100644 --- a/mocks/v1/auth/login.yaml +++ b/mocks/v1/auth/login.yaml @@ -7,6 +7,11 @@ request: Content-Type: "application/json" Authorization: "111" host: "127.0.0.1:8080" + body: > + { + "username":"user", + "password":"123" + } response: status: 200 headers: diff --git a/mocks/v1/auth/login_out.yaml b/mocks/v1/auth/login_out.yaml new file mode 100644 index 0000000..05e4930 --- /dev/null +++ b/mocks/v1/auth/login_out.yaml @@ -0,0 +1,50 @@ +- 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/products/export.yaml b/mocks/v1/products/export.yaml index 6c57b51..faed5af 100644 --- a/mocks/v1/products/export.yaml +++ b/mocks/v1/products/export.yaml @@ -2,6 +2,7 @@ id: "prod_export_pdf" request: method: "GET" path: "/api/v1/products/report" + body: '{"username":"user","password":"123"}' response: status: 200 headers: diff --git a/src/config.rs b/src/config.rs index 63aad78..b2ce262 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,9 +5,9 @@ use std::collections::HashMap; #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum MockSource { - /// 对应“一接口一文件”模式 + /// 对应“一个接口一个文件”模式 Single(MockRule), - /// 对应“一文件多接口”模式 + /// 对应“一个文件多个接口”模式 Multiple(Vec), } @@ -39,6 +39,8 @@ pub struct RequestMatcher { pub query_params: Option>, /// 选填:只有请求包含这些 Header 时才匹配 pub headers: Option>, + // 修改点:从 String 改为 Option + pub body: Option, } /// 响应内容定义 diff --git a/src/handler.rs b/src/handler.rs index a094735..17029ca 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -23,19 +23,37 @@ pub async fn mock_handler( Query(params): Query>, req: Request, ) -> impl IntoResponse { - let path = req.uri().path(); - + // let path = req.uri().path(); + // 1. 【关键】将需要的数据克隆出来,断开与 req 的借用关系 + let path = req.uri().path().to_string(); + let method_str = method.as_str().to_string(); // 1. 将 Axum HeaderMap 转换为简单的 HashMap 供 Router 使用 + + // 2. 现在可以安全地消耗 req 了,因为上面没有指向 req 内部的引用了 + let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await { + Ok(bytes) => bytes, + Err(_) => { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from("Read body error")) + .unwrap(); + } + }; + + let incoming_json: Option = serde_json::from_slice(&body_bytes).ok(); + let mut req_headers = HashMap::new(); for (name, value) in headers.iter() { if let Ok(v) = value.to_str() { req_headers.insert(name.as_str().to_string(), v.to_string()); } } - // 2. 执行匹配逻辑 - if let Some(rule) = state.router.match_rule(method.as_str(), path, ¶ms, &req_headers) { - + if let Some(rule) = + state + .router + .match_rule(&method_str, &path, ¶ms, &req_headers, &incoming_json) + { // 3. 处理模拟延迟 if let Some(ref settings) = rule.settings { if let Some(delay) = settings.delay_ms { @@ -49,7 +67,6 @@ pub async fn mock_handler( // 注入 YAML 定义的 Header if let Some(ref h) = rule.response.headers { - for (k, v) in h { // println!("{}:{}",k.clone(), v.clone()); response_builder = response_builder.header(k, v); @@ -65,23 +82,26 @@ pub async fn mock_handler( let body = Body::from_stream(stream); response_builder.body(body).unwrap() } - Err(_) => { - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from(format!("Mock Error: File not found at {}", file_path))) - .unwrap() - } + Err(_) => Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from(format!( + "Mock Error: File not found at {}", + file_path + ))) + .unwrap(), } } else { // B. 内联模式:直接返回字符串内容 - response_builder.body(Body::from(rule.response.body.clone())).unwrap() + response_builder + .body(Body::from(rule.response.body.clone())) + .unwrap() } } else { - println!("请求头{:?}",req_headers.clone()); + println!("请求头{:?}", req_headers.clone()); // 匹配失败返回 404 Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("No mock rule matched this request")) .unwrap() } -} \ No newline at end of file +} diff --git a/src/router.rs b/src/router.rs index 61166bd..c68dbcd 100644 --- a/src/router.rs +++ b/src/router.rs @@ -18,6 +18,7 @@ impl MockRouter { path: &str, queries: &HashMap, headers: &HashMap, + incoming_body: &Option, // 修改 1: 增加参数 ) -> Option<&MockRule> { // 1. 提取请求路径的首段作为索引 Key let key = self.extract_first_segment(path); @@ -26,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) { + if self.is_match(rule, method, path, queries, headers,incoming_body) { return Some(rule); } } @@ -43,6 +44,7 @@ impl MockRouter { path: &str, queries: &HashMap, headers: &HashMap, + incoming_body: &Option, // 修改 3: 增加参数 ) -> bool { // A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠) if rule.request.method.to_uppercase() != method.to_uppercase() { @@ -78,6 +80,27 @@ impl MockRouter { } } } + // 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 但请求为空 + } + } true } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 588c2ba..5355fae 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,7 +1,8 @@ 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::loader::MockLoader; use mock_server::router::MockRouter; @@ -10,18 +11,23 @@ use std::collections::HashMap; /// 模块一:验证 Config 反序列化逻辑 #[test] fn test_config_parsing_scenarios() { - // 场景 A: 验证单接口配置 (Inline 模式) + // 场景 A: 验证单接口配置 (增加 body 结构化校验) let yaml_single = r#" id: "auth_v1" - request: { method: "POST", path: "/api/v1/login" } + request: + method: "POST" + path: "/api/v1/login" + body: { "user": "admin" } response: { status: 200, body: "welcome" } "#; let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败"); let rules = source_s.flatten(); assert_eq!(rules.len(), 1); - assert_eq!(rules[0].request.path, "/api/v1/login"); + // 验证 body 是否被成功解析为 Value::Object + assert!(rules[0].request.body.is_some()); + assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin"); - // 场景 B: 验证多接口配置 (Collection 模式) + // 场景 B: 验证多接口配置 (原有逻辑不变) let yaml_multi = r#" - id: "api_1" request: { method: "GET", path: "/health" } @@ -33,7 +39,7 @@ fn test_config_parsing_scenarios() { let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败"); assert_eq!(source_m.flatten().len(), 2); - // 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 + // 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 (原有逻辑不变) let yaml_file = r#" id: "export_api" request: { method: "GET", path: "/download" } @@ -44,48 +50,34 @@ fn test_config_parsing_scenarios() { assert!(rule.response.body.starts_with("file://")); } -/// 模块二:验证 Loader 递归扫描与索引构建 +/// 模块二:验证 Loader 递归扫描与索引构建 (不涉及 Matcher 逻辑,基本保持不变) #[test] fn test_loader_recursive_indexing() { let temp_root = tempdir().expect("无法创建临时目录"); let root_path = temp_root.path(); - // 创建多级目录结构 let auth_path = root_path.join("v1/auth"); fs::create_dir_all(&auth_path).unwrap(); - // 1. 在深层目录写入一个单接口文件 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(); - // 2. 在根目录写入一个多接口文件 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 index = MockLoader::load_all_from_dir(root_path); - // 验证逻辑: - // 即使物理路径很深,索引 Key 必须是逻辑路径的首段 - assert!( - index.contains_key("api"), - "必须通过 /api/v1/login 提取出 'api' 键" - ); - assert!( - index.contains_key("health"), - "必须通过 /health 提取出 'health' 键" - ); - - // 验证扁平化后的总数 + assert!(index.contains_key("api")); + assert!(index.contains_key("health")); let total: usize = index.values().map(|v| v.len()).sum(); assert_eq!(total, 2); } #[test] fn test_router_matching_logic() { - // 1. 准备模拟数据(模拟 Loader 的输出) let mut index = HashMap::new(); + // 1. 准备带有 Body 的规则 let rule_auth = serde_yaml::from_str::( r#" id: "auth_v1" @@ -93,55 +85,76 @@ fn test_router_matching_logic() { method: "POST" path: "/api/v1/login" headers: { "Content-Type": "application/json" } + body: { "code": 123 } response: { status: 200, body: "token_123" } "#, ) - .unwrap() - .flatten(); - - let rule_user = serde_yaml::from_str::( - r#" - id: "get_user" - request: - method: "GET" - path: "/api/user/info" - query_params: { "id": "100" } - response: { status: 200, body: "user_info" } - "#, - ) - .unwrap() - .flatten(); - - // 构建 HashMap 索引(模仿 Loader 的行为) - index.insert( - "api".to_string(), - vec![rule_auth[0].clone(), rule_user[0].clone()], - ); + .unwrap() + .flatten(); + index.insert("api".to_string(), vec![rule_auth[0].clone()]); let router = MockRouter::new(index); - // 2. 测试场景 A:完全匹配 + // 2. 测试场景 A:完全匹配 (包括 Body) let mut headers = HashMap::new(); headers.insert("Content-Type".to_string(), "application/json".to_string()); - let matched = router.match_rule("POST", "/api/v1/login", &HashMap::new(), &headers); + // 构造请求 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:路径末尾斜杠归一化 (Trailing Slash) - let matched_slash = router.match_rule("POST", "/api/v1/login/", &HashMap::new(), &headers); - assert!(matched_slash.is_some(), "应该忽略路径末尾的斜杠"); + // 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:Query 参数子集匹配 - let mut queries = HashMap::new(); - queries.insert("id".to_string(), "100".to_string()); - queries.insert("extra".to_string(), "unused".to_string()); // 额外的参数不应影响匹配 + // 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 matched_query = router.match_rule("GET", "/api/user/info", &queries, &HashMap::new()); - assert!(matched_query.is_some()); - assert_eq!(matched_query.unwrap().id, "get_user"); + let mut index2 = HashMap::new(); + index2.insert("api".to_string(), vec![rule_str_body[0].clone()]); + let router2 = MockRouter::new(index2); - // 5. 测试场景 D:匹配失败(Method 错误) - let fail_method = router.match_rule("GET", "/api/v1/login", &HashMap::new(), &headers); - assert!(fail_method.is_none()); -} + 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