From 748cfa8e7fa7c372d281edb4948c56d0c170a9ca Mon Sep 17 00:00:00 2001 From: CNWei Date: Thu, 25 Dec 2025 18:01:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E7=B3=BB=E7=BB=9F=E4=B8=8E=E5=A4=9A=E7=BA=A7?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=80=92=E5=BD=92=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 定义支持“一接口一文件”与“一文件多接口”的数据模型 - 实现基于路径首段的 HashMap 索引构建逻辑 - 新增集成测试 tests/integration_test.rs,验证 YAML 解析与目录递归加载 - 优化 Cargo.toml 配置,解决连字符项目名引用问题 --- Cargo.lock | 113 +++++++++++++++++++++++++++++++++- Cargo.toml | 9 ++- mocks/v1/auth/login.yaml | 21 +++++++ mocks/v1/products/export.yaml | 11 ++++ mocks/v1/system/health.yaml | 16 +++++ mocks/v1/user/search.yaml | 11 ++++ src/config.rs | 74 ++++++++++++++++++++++ src/lib.rs | 3 + src/loader.rs | 58 +++++++++++++++++ src/main.rs | 12 +++- tests/integration_test.rs | 74 ++++++++++++++++++++++ 11 files changed, 395 insertions(+), 7 deletions(-) create mode 100644 mocks/v1/auth/login.yaml create mode 100644 mocks/v1/products/export.yaml create mode 100644 mocks/v1/system/health.yaml create mode 100644 mocks/v1/user/search.yaml create mode 100644 src/config.rs create mode 100644 src/lib.rs create mode 100644 src/loader.rs create mode 100644 tests/integration_test.rs diff --git a/Cargo.lock b/Cargo.lock index a14c98b..55a19bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -145,6 +161,18 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -255,6 +283,12 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.14" @@ -300,7 +334,7 @@ dependencies = [ ] [[package]] -name = "mock-server" +name = "mock_server" version = "0.1.0" dependencies = [ "axum", @@ -308,8 +342,10 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "tokio", "tokio-util", + "walkdir", ] [[package]] @@ -377,6 +413,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -386,12 +428,34 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ryu" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -525,6 +589,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tokio" version = "1.48.0" @@ -626,12 +703,40 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -721,6 +826,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "zmij" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index a4f4f08..d7559ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "mock-server" +name = "mock_server" version = "0.1.0" edition = "2024" @@ -18,11 +18,14 @@ serde_yaml = "0.9.34+deprecated" serde_json = "1.0.147" # 物理目录递归扫描工具 -#walkdir = "2.5.0" +walkdir = "2.5.0" # 性能优化:快速哈希(可选,用于路由匹配) #dashmap = "7.0.0-rc2" # 热加载支持(扩展功能) #notify = "8.2.0" # 路径处理 -#pathdiff = "0.2.3" \ No newline at end of file +#pathdiff = "0.2.3" + +[dev-dependencies] +tempfile = "3.24.0" \ No newline at end of file diff --git a/mocks/v1/auth/login.yaml b/mocks/v1/auth/login.yaml new file mode 100644 index 0000000..7625220 --- /dev/null +++ b/mocks/v1/auth/login.yaml @@ -0,0 +1,21 @@ +id: "auth_login_001" +request: + method: "POST" + path: "/api/v1/auth/login" + # 必须包含此 Header 才会匹配 + headers: + Content-Type: "application/json" +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: 200 # 模拟真实网络延迟 \ No newline at end of file diff --git a/mocks/v1/products/export.yaml b/mocks/v1/products/export.yaml new file mode 100644 index 0000000..6c57b51 --- /dev/null +++ b/mocks/v1/products/export.yaml @@ -0,0 +1,11 @@ +id: "prod_export_pdf" +request: + method: "GET" + path: "/api/v1/products/report" +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 diff --git a/mocks/v1/system/health.yaml b/mocks/v1/system/health.yaml new file mode 100644 index 0000000..a1a2d4b --- /dev/null +++ b/mocks/v1/system/health.yaml @@ -0,0 +1,16 @@ +# 使用 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/user/search.yaml b/mocks/v1/user/search.yaml new file mode 100644 index 0000000..d4848fb --- /dev/null +++ b/mocks/v1/user/search.yaml @@ -0,0 +1,11 @@ +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/config.rs b/src/config.rs new file mode 100644 index 0000000..63aad78 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// 顶层包装:支持单对象或数组,自动打平 +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MockSource { + /// 对应“一接口一文件”模式 + Single(MockRule), + /// 对应“一文件多接口”模式 + Multiple(Vec), +} + +impl MockSource { + /// 将不同的解析模式统一转化为列表,供 Loader 构建索引 + pub fn flatten(self) -> Vec { + match self { + Self::Single(rule) => vec![rule], + Self::Multiple(rules) => rules, + } + } +} + +/// 核心 Mock 规则定义 +#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +pub struct MockRule { + pub id: String, + pub request: RequestMatcher, + pub response: MockResponse, + pub settings: Option, +} + +/// 请求匹配条件 +#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +pub struct RequestMatcher { + pub method: String, + pub path: String, + /// 选填:只有请求包含这些参数时才匹配 + pub query_params: Option>, + /// 选填:只有请求包含这些 Header 时才匹配 + pub headers: Option>, +} + +/// 响应内容定义 +#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +pub struct MockResponse { + pub status: u16, + pub headers: Option>, + /// 统一字段:支持 Inline 文本或 file:// 前缀的路径 + pub body: String, +} + +/// 模拟器行为设置 +#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] +pub struct MockSettings { + /// 模拟网络延迟(毫秒) + pub delay_ms: Option, +} + +impl MockResponse { + /// 辅助方法:判断是否为文件协议 + pub fn is_file_protocol(&self) -> bool { + self.body.starts_with("file://") + } + + /// 获取去掉协议前缀后的纯物理路径 + pub fn get_file_path(&self) -> Option<&str> { + if self.is_file_protocol() { + Some(&self.body[7..]) // 截取 "file://" 之后的内容 + } else { + None + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8ec8832 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +// 声明模块并设为 pub,这样 tests/ 目录才能看到它们 +pub mod config; +pub mod loader; \ No newline at end of file diff --git a/src/loader.rs b/src/loader.rs new file mode 100644 index 0000000..0a68dea --- /dev/null +++ b/src/loader.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖 + +use crate::config::{MockRule, MockSource}; // 假设 config.rs 中定义了这两个类型 + +pub struct MockLoader; + +impl MockLoader { + /// 递归扫描指定目录并构建索引表 + pub fn load_all_from_dir(dir: &Path) -> HashMap> { + let mut index: HashMap> = HashMap::new(); + + // 1. 使用 walkdir 递归遍历目录,不限层级 + for entry in WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml")) + { + if let Some(rules) = Self::parse_yaml_file(entry.path()) { + for rule in rules { + // 2. 提取路径首段作为索引 Key + let key = Self::extract_first_segment(&rule.request.path); + + // 3. 将规则插入到对应的索引桶中 + index.entry(key).or_insert_with(Vec::new).push(rule); + } + } + } + + println!("Successfully loaded {} segments from {:?}", index.len(), dir); + index + } + + /// 解析单个 YAML 文件,支持单接口和多接口模式 + fn parse_yaml_file(path: &Path) -> Option> { + let content = fs::read_to_string(path).ok()?; + + // 利用 serde_yaml 的反序列化能力处理 MockSource 枚举 + match serde_yaml::from_str::(&content) { + Ok(source) => Some(source.flatten()), // 统一打平为 Vec + Err(e) => { + eprintln!("Failed to parse YAML at {:?}: {}", path, e); + None + } + } + } + + /// 提取路径的第一级作为 Key(例如 "/api/v1/login" -> "api") + fn extract_first_segment(path: &str) -> String { + path.trim_start_matches('/') + .split('/') + .next() + .unwrap_or("root") // 如果是根路径 "/",则归类到 "root" + .to_string() + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 3bb387a..f39d5e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,9 @@ -fn main() { - println!("Hello, mock-server!"); -} +// 使用项目名(下划线形式)引用 lib 中的内容 +use mock_server::loader::MockLoader; + +#[tokio::main] +async fn main() { + // 你的启动逻辑... + let index = MockLoader::load_all_from_dir(std::path::Path::new("./mocks")); + println!("服务启动中..."); +} \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..852486b --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,74 @@ +use std::fs::{self, File}; +use std::io::Write; +use tempfile::tempdir; +// 替换为你的项目实际名称 +use mock_server::config::{MockSource, MockRule}; +use mock_server::loader::MockLoader; + +/// 模块一:验证 Config 反序列化逻辑 +#[test] +fn test_config_parsing_scenarios() { + // 场景 A: 验证单接口配置 (Inline 模式) + let yaml_single = r#" + id: "auth_v1" + request: { method: "POST", path: "/api/v1/login" } + 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"); + + // 场景 B: 验证多接口配置 (Collection 模式) + 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" } + "#; + 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://")); +} + +/// 模块二:验证 Loader 递归扫描与索引构建 +#[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' 键"); + + // 验证扁平化后的总数 + let total: usize = index.values().map(|v| v.len()).sum(); + assert_eq!(total, 2); +} \ No newline at end of file