5 Commits

Author SHA1 Message Date
9c1d0e16b4 docs: 更新README并添加文件上传mock配置
- 移除文件上传相关文档(已从代码中移除)
   - 新增未来计划:动态参数、HTTPS、可视化管理界面
   - 添加 upload.yaml mock 配置文件
2026-03-25 17:40:34 +08:00
b579a835de docs: 重构项目文档结构
- 将 CLAUDE.md 移至 .claude/ 目录,精简为 AI 开发指南
- 扩充 README.md 为完整的用户文档
- 新增 rules/mock-spec.md: YAML 配置生成规范
- 新增 rules/commit-spec.md: Git 提交消息格式规范
- 从 .gitignore 移除 .claude/ 目录以便跟踪

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 17:11:10 +08:00
e218ab04fe feat: 优化CLAUDE.md
- 优化 优化CLAUDE.md
2026-03-25 17:10:36 +08:00
18e0ccad58 merge: 合并0321分支的热重载功能与body字段匹配支持 2026-03-25 16:59:09 +08:00
5f3269bad5 feat: 增加request对body字段的支持
- 实现body字段对json语法,yaml语法的支持的支持
- 新增测试用例login_out.yaml
2025-12-28 21:54:34 +08:00
13 changed files with 546 additions and 83 deletions

76
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# Mock Server 开发指南
## 构建与测试命令
```bash
cargo build # 构建项目
cargo run # 启动服务 http://127.0.0.1:8080
cargo test # 运行所有测试
cargo test <pattern> # 运行匹配的测试(如 `cargo test router`
cargo clippy # 代码检查
cargo fmt # 格式化代码
```
## 架构
```
┌─────────────┐ ┌──────────────┐ ┌────────────────┐
│ loader.rs │───▶│ router.rs │───▶│ handler.rs │
│ YAML → 索引 │ │ HashMap 索引 │ │ 请求/响应处理 │
└─────────────┘ └──────────────┘ └────────────────┘
▲ │
│ ┌──────────────────┐ │
└─────────│ main.rs │◀─────────┘
热重载 │ AppState(RwLock)│
└──────────────────┘
```
**数据流:**
1. `MockLoader` 扫描 `mocks/` 目录,解析 YAML 为 `MockRule` 结构体
2. 规则按路径首段建立索引(如 `/api/users` → key: `api`)存储于 `MockRouter`
3. 请求匹配顺序method → path → query_params → headers → body
4. `AppState` 使用 `RwLock` 包装 router支持线程安全的热重载
## 核心类型 (config.rs)
- `MockRule`: 完整规则id, request, response, settings
- `RequestMatcher`: 请求匹配条件method, path, query_params, headers, body
- `MockResponse`: 响应配置status, headers, body
- `MockSource`: 枚举类型,支持单接口/多接口 YAML 格式
## 请求匹配 (router.rs)
- 路径首段 HashMap 查找O(1) 获取候选规则
- 候选集内线性扫描进行精确匹配
- Body 匹配:支持 JSON 对象和字符串两种比较方式
## 响应处理 (handler.rs)
- 内联 body直接返回
- `file://` 前缀:从磁盘流式读取文件(低内存占用)
- `settings.delay_ms`:模拟网络延迟
## 文件结构
```
src/
├── main.rs # 入口热重载监听Axum 路由配置
├── config.rs # 数据结构定义MockRule, RequestMatcher 等)
├── loader.rs # YAML 解析,目录扫描
├── router.rs # 路径首段索引,匹配逻辑
├── handler.rs # 统一请求处理器,文件流式响应
└── upload.rs # Multipart 文件上传处理
tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件
storage/ # 上传文件存储(按 YYYY-MM-DD 分目录)
```
## 开发注意事项
- **Rust Edition 2024**Axum 0.8.8
- 测试使用 `tempfile` crate 处理临时文件
- 请求体大小限制10MB (handler.rs:30)
- 热重载防抖时间200ms (main.rs:46)
- Header 匹配大小写不敏感 (router.rs:75)

View File

@@ -0,0 +1,89 @@
---
paths:
- "**/*"
---
## Git 提交消息规范
AI 在生成 commit 消息时必须遵循以下格式。
### 格式
```
<类型>(<范围>): <描述>
[可选的详细说明]
[可选的脚注]
```
### 类型Type
| 类型 | 说明 |
|------|------|
| feat | 新功能feature |
| fix | 修复 bug |
| docs | 文档更新 |
| style | 代码格式(不影响功能的变动) |
| refactor | 代码重构(既非新功能也非 bug 修复) |
| test | 添加或修改测试 |
| chore | 构建系统或辅助工具的变动 |
| perf | 性能优化 |
### 范围Scope
描述提交影响的功能模块或组件(可选)。
### 描述Description
- 简明扼要描述变动内容
- 首字母小写
- 不超过 100 个字符
- 使用祈使句(如 "add" 而非 "added"
### 详细说明Body
- 提供更多上下文和详细说明(可选)
- 每行不超过 72 个字符
- 说明"做了什么"和"为什么"
### 脚注Footer
- 破坏性变动说明(如 `BREAKING CHANGE: ...`
- 关闭的 Issue`Closes #123`
### 示例
**新功能:**
```
feat(api): 添加用户登录端点
- 实现 JWT 认证逻辑
- 添加登录表单前端组件
- 更新用户服务处理登录请求
Closes #45
```
**Bug 修复:**
```
fix(router): 修复路径匹配大小写问题
路径匹配在某些情况下区分大小写导致无法正确路由到处理器。
```
**文档更新:**
```
docs: 更新 README 中的安装说明
```
**重构:**
```
refactor(handler): 简化响应处理逻辑
将文件响应和内联响应的处理逻辑分离到独立函数中。
```

View File

@@ -0,0 +1,89 @@
---
paths:
- "mocks/**/*.{yml,yaml}"
---
## YAML 配置规范
AI 在生成 Mock 规则时必须遵循以下格式。
### 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| id | string | 是 | 规则唯一标识 |
| request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) |
| request.path | string | 是 | 请求路径,精确匹配 |
| request.query_params | object | 否 | 查询参数匹配 |
| request.headers | object | 否 | 请求头匹配(大小写不敏感) |
| request.body | any | 否 | 请求体匹配,支持 JSON 对象或字符串 |
| response.status | number | 是 | HTTP 状态码 |
| response.headers | object | 否 | 响应头 |
| response.body | string | 是 | 响应体,支持 file:// 前缀 |
| settings.delay_ms | number | 否 | 延迟响应(毫秒) |
### 匹配规则
1. **路径精确匹配**(非前缀匹配)
2. **请求头匹配大小写不敏感**
3. **Body 匹配**支持 JSON 对象或字符串比较
### 配置示例
**单接口模式:**
```yaml
id: "login"
request:
method: "POST"
path: "/api/login"
body: { "username": "test" }
response:
status: 200
body: '{"token": "xxx"}'
settings:
delay_ms: 100
```
**多接口模式(数组):**
```yaml
- id: "get-users"
request: { method: "GET", path: "/api/users" }
response: { status: 200, body: '{"users": []}' }
- id: "create-user"
request: { method: "POST", path: "/api/users" }
response: { status: 201, body: '{"id": 1}' }
```
**带查询参数和请求头:**
```yaml
id: "search-users"
request:
method: "GET"
path: "/api/users"
query_params: { "role": "admin" }
headers: { "Authorization": "Bearer token" }
response:
status: 200
headers: { "X-Total-Count": "100" }
body: '{"users": []}'
```
**文件响应:**
```yaml
id: "download-pdf"
request:
method: "GET"
path: "/api/download"
response:
status: 200
body: "file://./data/large-file.pdf"
```
> `file://` 支持两种路径:
> - 相对路径:`file://./data/file.pdf`(相对于项目根目录)
> - 绝对路径:`file:///C:/path/to/file.pdf`

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(cargo search:*)"
]
}
}

View File

@@ -1,11 +1,76 @@
# mock-server # mock-server
基于Rust/Axum的配置驱动型Mock服务支持YAML配置、请求匹配、延迟响应、大文件流式返回等特性。
基于 Rust/Axum 的配置驱动型 Mock 服务,支持 YAML 配置、请求匹配、热重载、延迟响应、大文件流式返回等特性。
## 特性 ## 特性
- 配置驱动YAML定义API行为无需修改代码
- 高性能基于Rust异步运行时哈希索引匹配请求 - **配置驱动**YAML 定义 API 行为,无需修改代码
- 低内存:大响应体支持磁盘文件流式读取,不占用常驻内存 - **热重载**`mocks/*.yaml` 变更自动生效,无需重启服务
- 易扩展:模块化设计,支持动态占位符、热加载(规划中) - **高性能**:基于 Rust 异步运行时,路径首段哈希索引 O(1) 匹配
- **低内存**:大响应体支持 `file://` 协议从磁盘流式读取
- **请求匹配**:支持 method、path、headers、query_params、body 多维度匹配
## 快速开始 ## 快速开始
### 1. 安装依赖 ### 1. 安装依赖
确保已安装 Rust 工具链:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### 2. 构建与运行
```bash
cargo build # 构建项目
cargo run # 启动服务 http://127.0.0.1:8080
cargo test # 运行所有测试
cargo test <pattern> # 运行匹配的测试(如 cargo test router
cargo clippy # 代码检查
cargo fmt # 格式化代码
```
## 文件结构
```
src/
├── main.rs # 入口热重载监听Axum 路由配置
├── config.rs # 数据结构定义MockRule, RequestMatcher 等)
├── loader.rs # YAML 解析,目录扫描
├── router.rs # 路径首段索引,匹配逻辑
└── handler.rs # 统一请求处理器,文件流式响应
tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件
```
## Mock 配置
详细配置规范请参考 [.claude/rules/mock-spec.md](.claude/rules/mock-spec.md)
## 未来计划
### 1. 动态参数与模板响应
支持路径参数捕获(如 `/api/users/{id}`)和响应模板,可在响应中引用请求参数:
```yaml
# 请求: GET /api/users/123
# 响应: { "id": "123", "name": "User 123" }
```
### 2. HTTPS 支持
内置 TLS/SSL 支持,提供安全的 HTTPS 服务。
### 3. 可视化管理界面
- **TUI终端界面**:基于 Ratatui 的交互式终端管理
- **GUI图形界面**Web Dashboard 或桌面应用,可视化管理 Mock 规则
> 此功能需要深入讨论,欢迎提出建议和需求
## 许可证
MIT

View File

@@ -8,7 +8,10 @@ request:
Authorization: "111" Authorization: "111"
host: "127.0.0.1:8080" host: "127.0.0.1:8080"
body: > body: >
{} {
"username":"user",
"password":"123"
}
response: response:
status: 200 status: 200
headers: headers:

View File

@@ -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 # 模拟真实网络延迟

View File

@@ -2,6 +2,7 @@ id: "prod_export_pdf"
request: request:
method: "GET" method: "GET"
path: "/api/v1/products/report" path: "/api/v1/products/report"
body: '{"username":"user","password":"123"}'
response: response:
status: 200 status: 200
headers: headers:

23
mocks/v1/upload.yaml Normal file
View File

@@ -0,0 +1,23 @@
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

View File

@@ -5,9 +5,9 @@ use std::collections::HashMap;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum MockSource { pub enum MockSource {
/// 对应“一接口一文件”模式 /// 对应“一接口一文件”模式
Single(MockRule), Single(MockRule),
/// 对应“一文件多接口”模式 /// 对应“一文件多接口”模式
Multiple(Vec<MockRule>), Multiple(Vec<MockRule>),
} }
@@ -39,6 +39,8 @@ pub struct RequestMatcher {
pub query_params: Option<HashMap<String, String>>, pub query_params: Option<HashMap<String, String>>,
/// 选填:只有请求包含这些 Header 时才匹配 /// 选填:只有请求包含这些 Header 时才匹配
pub headers: Option<HashMap<String, String>>, pub headers: Option<HashMap<String, String>>,
// 修改点:从 String 改为 Option<serde_json::Value>
pub body: Option<serde_json::Value>,
} }
/// 响应内容定义 /// 响应内容定义

View File

@@ -23,9 +23,23 @@ pub async fn mock_handler(
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
req: Request<Body>, // Request<Body> 必须是最后一个参数 req: Request<Body>, // Request<Body> 必须是最后一个参数
) -> impl IntoResponse { ) -> impl IntoResponse {
let path = req.uri().path().to_string(); // 提取 path // 1. 提取 path 和 method
let path = req.uri().path().to_string();
let method_str = method.as_str().to_string();
// 1. 将 Axum HeaderMap 转换为简单的 HashMap // 2. 读取请求 body用于 body 字段匹配)
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::Value> = serde_json::from_slice(&body_bytes).ok();
// 3. 将 Axum HeaderMap 转换为简单的 HashMap
let mut req_headers = HashMap::new(); let mut req_headers = HashMap::new();
for (name, value) in headers.iter() { for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() { if let Ok(v) = value.to_str() {
@@ -33,22 +47,22 @@ pub async fn mock_handler(
} }
} }
// 2. 执行匹配逻辑:先获取读锁 (Read Lock) // 4. 执行匹配逻辑:先获取读锁 (Read Lock)
let maybe_rule = { let maybe_rule = {
let router = state.router.read().expect("Failed to acquire read lock"); let router = state.router.read().expect("Failed to acquire read lock");
router.match_rule(method.as_str(), &path, &params, &req_headers).cloned() router.match_rule(&method_str, &path, &params, &req_headers, &incoming_json).cloned()
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁 // 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
}; };
if let Some(rule) = maybe_rule { if let Some(rule) = maybe_rule {
// 3. 处理模拟延迟 // 5. 处理模拟延迟
if let Some(ref settings) = rule.settings { if let Some(ref settings) = rule.settings {
if let Some(delay) = settings.delay_ms { if let Some(delay) = settings.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await; tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
} }
} }
// 4. 构建响应 // 6. 构建响应
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK); let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
let mut response_builder = Response::builder().status(status); let mut response_builder = Response::builder().status(status);
@@ -58,23 +72,30 @@ pub async fn mock_handler(
} }
} }
// 5. Smart Body 逻辑 // 7. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() { if let Some(file_path) = rule.response.get_file_path() {
match tokio::fs::File::open(file_path).await { match tokio::fs::File::open(file_path).await {
Ok(file) => { Ok(file) => {
let stream = ReaderStream::new(file); let stream = ReaderStream::new(file);
response_builder.body(Body::from_stream(stream)).unwrap() let body = Body::from_stream(stream);
response_builder.body(body).unwrap()
} }
Err(_) => Response::builder() Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR) .status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(format!("File not found: {}", file_path))) .body(Body::from(format!(
"Mock Error: File not found at {}",
file_path
)))
.unwrap(), .unwrap(),
} }
} else { } else {
response_builder.body(Body::from(rule.response.body.clone())).unwrap() // 内联模式:直接返回字符串内容
response_builder
.body(Body::from(rule.response.body.clone()))
.unwrap()
} }
} else { } else {
// 匹配失败 // 匹配失败返回 404
Response::builder() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)
.body(Body::from("No mock rule matched this request")) .body(Body::from("No mock rule matched this request"))

View File

@@ -18,6 +18,7 @@ impl MockRouter {
path: &str, path: &str,
queries: &HashMap<String, String>, queries: &HashMap<String, String>,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 1: 增加参数
) -> Option<&MockRule> { ) -> Option<&MockRule> {
// 1. 提取请求路径的首段作为索引 Key // 1. 提取请求路径的首段作为索引 Key
let key = self.extract_first_segment(path); let key = self.extract_first_segment(path);
@@ -26,7 +27,7 @@ impl MockRouter {
if let Some(rules) = self.index.get(&key) { if let Some(rules) = self.index.get(&key) {
// 3. 在候选集中进行线性深度匹配 // 3. 在候选集中进行线性深度匹配
for rule in rules { 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); return Some(rule);
} }
} }
@@ -43,6 +44,7 @@ impl MockRouter {
path: &str, path: &str,
queries: &HashMap<String, String>, queries: &HashMap<String, String>,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 3: 增加参数
) -> bool { ) -> bool {
// A. 基础校验Method 和 Path 必须完全一致 (忽略末尾斜杠) // A. 基础校验Method 和 Path 必须完全一致 (忽略末尾斜杠)
if rule.request.method.to_uppercase() != method.to_uppercase() { 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::<serde_json::Value>(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 true
} }

View File

@@ -1,7 +1,8 @@
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::Write; use std::io::Write;
use tempfile::tempdir; use tempfile::tempdir;
// 替换为你的项目实际名称 // 确保 Cargo.toml 中有 serde_json
use serde_json::json;
use mock_server::config::{MockRule, MockSource}; use mock_server::config::{MockRule, MockSource};
use mock_server::loader::MockLoader; use mock_server::loader::MockLoader;
use mock_server::router::MockRouter; use mock_server::router::MockRouter;
@@ -10,18 +11,23 @@ use std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑 /// 模块一:验证 Config 反序列化逻辑
#[test] #[test]
fn test_config_parsing_scenarios() { fn test_config_parsing_scenarios() {
// 场景 A: 验证单接口配置 (Inline 模式) // 场景 A: 验证单接口配置 (增加 body 结构化校验)
let yaml_single = r#" let yaml_single = r#"
id: "auth_v1" 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" } response: { status: 200, body: "welcome" }
"#; "#;
let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败"); let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
let rules = source_s.flatten(); let rules = source_s.flatten();
assert_eq!(rules.len(), 1); 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#" let yaml_multi = r#"
- id: "api_1" - id: "api_1"
request: { method: "GET", path: "/health" } 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("解析多接口失败"); let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败");
assert_eq!(source_m.flatten().len(), 2); assert_eq!(source_m.flatten().len(), 2);
// 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 // 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 (原有逻辑不变)
let yaml_file = r#" let yaml_file = r#"
id: "export_api" id: "export_api"
request: { method: "GET", path: "/download" } request: { method: "GET", path: "/download" }
@@ -44,48 +50,34 @@ fn test_config_parsing_scenarios() {
assert!(rule.response.body.starts_with("file://")); assert!(rule.response.body.starts_with("file://"));
} }
/// 模块二:验证 Loader 递归扫描与索引构建 /// 模块二:验证 Loader 递归扫描与索引构建 (不涉及 Matcher 逻辑,基本保持不变)
#[test] #[test]
fn test_loader_recursive_indexing() { fn test_loader_recursive_indexing() {
let temp_root = tempdir().expect("无法创建临时目录"); let temp_root = tempdir().expect("无法创建临时目录");
let root_path = temp_root.path(); let root_path = temp_root.path();
// 创建多级目录结构
let auth_path = root_path.join("v1/auth"); let auth_path = root_path.join("v1/auth");
fs::create_dir_all(&auth_path).unwrap(); fs::create_dir_all(&auth_path).unwrap();
// 1. 在深层目录写入一个单接口文件
let mut f1 = File::create(auth_path.join("login.yaml")).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(); 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(); 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(); 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); let index = MockLoader::load_all_from_dir(root_path);
// 验证逻辑: assert!(index.contains_key("api"));
// 即使物理路径很深,索引 Key 必须是逻辑路径的首段 assert!(index.contains_key("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(); let total: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total, 2); assert_eq!(total, 2);
} }
#[test] #[test]
fn test_router_matching_logic() { fn test_router_matching_logic() {
// 1. 准备模拟数据(模拟 Loader 的输出)
let mut index = HashMap::new(); let mut index = HashMap::new();
// 1. 准备带有 Body 的规则
let rule_auth = serde_yaml::from_str::<MockSource>( let rule_auth = serde_yaml::from_str::<MockSource>(
r#" r#"
id: "auth_v1" id: "auth_v1"
@@ -93,55 +85,76 @@ fn test_router_matching_logic() {
method: "POST" method: "POST"
path: "/api/v1/login" path: "/api/v1/login"
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" }
body: { "code": 123 }
response: { status: 200, body: "token_123" } response: { status: 200, body: "token_123" }
"#, "#,
) )
.unwrap() .unwrap()
.flatten(); .flatten();
let rule_user = serde_yaml::from_str::<MockSource>( index.insert("api".to_string(), vec![rule_auth[0].clone()]);
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); let router = MockRouter::new(index);
// 2. 测试场景 A完全匹配 // 2. 测试场景 A完全匹配 (包括 Body)
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string()); 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!(matched.is_some());
assert_eq!(matched.unwrap().id, "auth_v1"); assert_eq!(matched.unwrap().id, "auth_v1");
// 3. 测试场景 B路径末尾斜杠归一化 (Trailing Slash) // 3. 测试场景 BBody 不匹配
let matched_slash = router.match_rule("POST", "/api/v1/login/", &HashMap::new(), &headers); let wrong_body = Some(json!({ "code": 456 }));
assert!(matched_slash.is_some(), "应该忽略路径末尾的斜杠"); let matched_fail = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&wrong_body
);
assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
// 4. 测试场景 CQuery 参数子集匹配 // 4. 测试场景 C智能字符串转换验证 (YAML 是字符串,请求是对象)
let mut queries = HashMap::new(); let rule_str_body = serde_yaml::from_str::<MockSource>(
queries.insert("id".to_string(), "100".to_string()); r#"
queries.insert("extra".to_string(), "unused".to_string()); // 额外的参数不应影响匹配 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()); let mut index2 = HashMap::new();
assert!(matched_query.is_some()); index2.insert("api".to_string(), vec![rule_str_body[0].clone()]);
assert_eq!(matched_query.unwrap().id, "get_user"); let router2 = MockRouter::new(index2);
// 5. 测试场景 D匹配失败Method 错误) let incoming_obj = Some(json!({ "type": "json_in_string" })); // 请求是 JSON 对象
let fail_method = router.match_rule("GET", "/api/v1/login", &HashMap::new(), &headers); let matched_str = router2.match_rule(
assert!(fail_method.is_none()); "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());
} }