From 1775d3659de0039ad2bf9b2921a35a53c7c11138 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 26 Dec 2025 15:21:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=BC=95=E6=93=8E=E4=B8=8E=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=99=A8=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 router 模块:实现基于路径首段索引子集匹配。 - 新增 handler 模块:集成 Axum 处理器,支持 Smart Body 协议与延迟模拟。 - 修复解析与匹配故障:修正 YAML 字段类型解析错误。 --- Cargo.lock | 79 ++++++++++++++++++++++++++ Cargo.toml | 3 + mocks/v1/auth/login.yaml | 2 + src/handler.rs | 87 +++++++++++++++++++++++++++++ src/lib.rs | 4 +- src/main.rs | 43 ++++++++++++-- src/router.rs | 93 +++++++++++++++++++++++++++++++ tests/integration_test.rs | 81 +++++++++++++++++++++++++-- tests/loader_test.rs | 114 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 src/handler.rs create mode 100644 src/router.rs create mode 100644 tests/loader_test.rs diff --git a/Cargo.lock b/Cargo.lock index 55a19bc..659de77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,12 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.178" @@ -345,9 +351,20 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "tracing", + "tracing-subscriber", "walkdir", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -541,6 +558,15 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.4.7" @@ -602,6 +628,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tokio" version = "1.48.0" @@ -679,9 +714,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -689,6 +736,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -703,6 +776,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index d7559ba..5b9b191 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ serde_json = "1.0.147" # 物理目录递归扫描工具 walkdir = "2.5.0" +tracing="0.1.44" +tracing-subscriber = "0.3.22" + # 性能优化:快速哈希(可选,用于路由匹配) #dashmap = "7.0.0-rc2" # 热加载支持(扩展功能) diff --git a/mocks/v1/auth/login.yaml b/mocks/v1/auth/login.yaml index 7625220..64ec57c 100644 --- a/mocks/v1/auth/login.yaml +++ b/mocks/v1/auth/login.yaml @@ -5,6 +5,8 @@ request: # 必须包含此 Header 才会匹配 headers: Content-Type: "application/json" + Authorization: "111" + host: "127.0.0.1:8080" response: status: 200 headers: diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..a094735 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,87 @@ +use axum::{ + body::Body, + extract::{Query, State}, + http::{HeaderMap, Method, Request, StatusCode}, + response::{IntoResponse, Response}, +}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio_util::io::ReaderStream; // 需在 Cargo.toml 确认有 tokio-util + +use crate::router::MockRouter; + +/// 共享的应用状态 +pub struct AppState { + pub router: MockRouter, +} + +/// 全局统一请求处理函数 +pub async fn mock_handler( + State(state): State>, + method: Method, + headers: HeaderMap, + Query(params): Query>, + req: Request, +) -> impl IntoResponse { + let path = req.uri().path(); + + // 1. 将 Axum HeaderMap 转换为简单的 HashMap 供 Router 使用 + 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) { + + // 3. 处理模拟延迟 + if let Some(ref settings) = rule.settings { + if let Some(delay) = settings.delay_ms { + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + } + } + + // 4. 构建响应基础信息 + let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK); + let mut response_builder = Response::builder().status(status); + + // 注入 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); + } + } + + // 5. 执行 Smart Body 协议逻辑 + if let Some(file_path) = rule.response.get_file_path() { + // A. 文件模式:异步打开文件并转换为流,实现低内存占用 + match tokio::fs::File::open(file_path).await { + Ok(file) => { + let stream = ReaderStream::new(file); + 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() + } + } + } else { + // B. 内联模式:直接返回字符串内容 + response_builder.body(Body::from(rule.response.body.clone())).unwrap() + } + } else { + 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/lib.rs b/src/lib.rs index 8ec8832..e13f385 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ // 声明模块并设为 pub,这样 tests/ 目录才能看到它们 pub mod config; -pub mod loader; \ No newline at end of file +pub mod loader; +pub mod router; +pub mod handler; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index f39d5e2..ef70ecd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,44 @@ -// 使用项目名(下划线形式)引用 lib 中的内容 +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use axum::{routing::any, Router}; use mock_server::loader::MockLoader; +use mock_server::router::MockRouter; +use mock_server::handler::{mock_handler, AppState}; #[tokio::main] async fn main() { - // 你的启动逻辑... - let index = MockLoader::load_all_from_dir(std::path::Path::new("./mocks")); - println!("服务启动中..."); + // 1. 初始化日志(建议添加,方便观察加载情况) + // 需要在 Cargo.toml 添加 tracing-subscriber = "0.3" + tracing_subscriber::fmt::init(); + + // 2. 递归加载所有的 YAML 配置文件 + let mocks_dir = Path::new("./mocks"); + if !mocks_dir.exists() { + println!("Warning: 'mocks/' directory not found. Creating it..."); + std::fs::create_dir_all(mocks_dir).unwrap(); + } + + println!("Scanning mocks directory..."); + let index = MockLoader::load_all_from_dir(mocks_dir); + + // 3. 构建路由引擎并包装为共享状态 + let router_engine = MockRouter::new(index); + let shared_state = Arc::new(AppState { + router: router_engine, + }); + + // 4. 配置 Axum 路由 + // 使用 any(mock_handler) 意味着它会接管所有 HTTP 方法和所有路径的请求 + let app = Router::new() + .fallback(any(mock_handler)) + .with_state(shared_state); + + // 5. 启动服务 + let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); + println!("🚀 Rust Mock Server is running on http://{}", addr); + println!("Ready to handle requests based on your YAML definitions."); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); } \ No newline at end of file diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..61166bd --- /dev/null +++ b/src/router.rs @@ -0,0 +1,93 @@ +use std::collections::HashMap; +use crate::config::MockRule; + +pub struct MockRouter { + // 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则 + pub index: HashMap>, +} + +impl MockRouter { + pub fn new(index: HashMap>) -> Self { + Self { index } + } + + /// 核心匹配函数:根据请求信息寻找匹配的 Mock 规则 + pub fn match_rule( + &self, + method: &str, + path: &str, + queries: &HashMap, + headers: &HashMap, + ) -> Option<&MockRule> { + // 1. 提取请求路径的首段作为索引 Key + let key = self.extract_first_segment(path); + println!("DEBUG: Request Key: '{}', Available Keys: {:?}", key, self.index.keys()); + // 2. 从 HashMap 中快速定位候选规则列表 (O(k) 复杂度) + if let Some(rules) = self.index.get(&key) { + // 3. 在候选集中进行线性深度匹配 + for rule in rules { + if self.is_match(rule, method, path, queries, headers) { + return Some(rule); + } + } + } + + None + } + + /// 细粒度匹配逻辑 + fn is_match( + &self, + rule: &MockRule, + method: &str, + path: &str, + queries: &HashMap, + headers: &HashMap, + ) -> 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); + 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); + return false; + } + println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id); + // B. Query 参数校验 (子集匹配原则) + if let Some(ref required_queries) = rule.request.query_params { + for (key, val) in required_queries { + if queries.get(key) != Some(val) { + return false; + } + } + } + + // C. Header 校验 (优化版) + if let Some(ref required_headers) = rule.request.headers { + for (key, val) in required_headers { + println!("{}:{}",key.clone(), val.clone()); + // 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值 + 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 !matched { + return false; + } + } + } + + true + } + + /// 与 Loader 保持一致的 Key 提取算法 + fn extract_first_segment(&self, path: &str) -> String { + path.trim_start_matches('/') + .split('/') + .next() + .unwrap_or("root") + .to_string() + } +} \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 852486b..588c2ba 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2,8 +2,10 @@ use std::fs::{self, File}; use std::io::Write; use tempfile::tempdir; // 替换为你的项目实际名称 -use mock_server::config::{MockSource, MockRule}; +use mock_server::config::{MockRule, MockSource}; use mock_server::loader::MockLoader; +use mock_server::router::MockRouter; +use std::collections::HashMap; /// 模块一:验证 Config 反序列化逻辑 #[test] @@ -65,10 +67,81 @@ fn test_loader_recursive_indexing() { // 验证逻辑: // 即使物理路径很深,索引 Key 必须是逻辑路径的首段 - assert!(index.contains_key("api"), "必须通过 /api/v1/login 提取出 'api' 键"); - assert!(index.contains_key("health"), "必须通过 /health 提取出 'health' 键"); + assert!( + index.contains_key("api"), + "必须通过 /api/v1/login 提取出 'api' 键" + ); + assert!( + index.contains_key("health"), + "必须通过 /health 提取出 'health' 键" + ); // 验证扁平化后的总数 let total: usize = index.values().map(|v| v.len()).sum(); assert_eq!(total, 2); -} \ No newline at end of file +} + +#[test] +fn test_router_matching_logic() { + // 1. 准备模拟数据(模拟 Loader 的输出) + let mut index = HashMap::new(); + + let rule_auth = serde_yaml::from_str::( + r#" + id: "auth_v1" + request: + method: "POST" + path: "/api/v1/login" + headers: { "Content-Type": "application/json" } + 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()], + ); + + let router = MockRouter::new(index); + + // 2. 测试场景 A:完全匹配 + 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); + 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(), "应该忽略路径末尾的斜杠"); + + // 4. 测试场景 C:Query 参数子集匹配 + let mut queries = HashMap::new(); + queries.insert("id".to_string(), "100".to_string()); + queries.insert("extra".to_string(), "unused".to_string()); // 额外的参数不应影响匹配 + + 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"); + + // 5. 测试场景 D:匹配失败(Method 错误) + let fail_method = router.match_rule("GET", "/api/v1/login", &HashMap::new(), &headers); + assert!(fail_method.is_none()); +} diff --git a/tests/loader_test.rs b/tests/loader_test.rs new file mode 100644 index 0000000..64a0735 --- /dev/null +++ b/tests/loader_test.rs @@ -0,0 +1,114 @@ +use std::fs::{self, File}; +use std::io::Write; +use tempfile::tempdir; +// 假设你的项目名在 Cargo.toml 中叫 mock_server +use mock_server::config::MockSource; +use mock_server::loader::MockLoader; + +#[test] +fn test_config_deserialization() { + // 测试 1:验证单接口 YAML 解析 + let single_yaml = r#" + id: "auth_v1" + request: + method: "POST" + path: "/api/v1/login" + response: + status: 200 + body: "inline_content" + "#; + let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口"); + assert_eq!(res.flatten().len(), 1); + // assert_eq!(res.flatten()[0].id, "auth_v1"); + + // 测试 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" } + "#; + let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组"); + assert_eq!(res_multi.flatten().len(), 2); +} + +#[test] +fn test_recursive_loading_logic() { + // 创建临时 Mock 目录结构 + let root_dir = tempdir().expect("创建临时目录失败"); + let root_path = root_dir.path(); + + // 构造物理层级:mocks/v1/user/ + let user_dir = root_path.join("v1/user"); + fs::create_dir_all(&user_dir).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 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 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"); +}