6 Commits

Author SHA1 Message Date
061ceff4b8 fix: 修复 YAML 块语法 body 匹配失败问题
- normalize_yaml_body 函数在解析 JSON 前添加 trim() 处理,解决 YAML `|` 和 `>` 语法产生的前导空格问题
- 修复 multiple_login.yaml 中 response body 格式错误(YAML 对象改为 JSON 字符串)
2026-03-27 17:33:21 +08:00
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
3097a16465 feat: 实现配置热重载功能
- 引入热加载机制:集成 `notify-debouncer-mini` 监听 `./mocks` 目录,实现 YAML 变动自动重载。
- 优化并发控制:将 `MockRouter` 包装在 `RwLock` 中,确保 Web 线程(读)与监控线程(写)的数据安全。
- 增强系统健壮性:增加防抖处理防止频繁 IO 触发重载。
2026-01-04 11:44:18 +08:00
33 changed files with 1917 additions and 431 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,9 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(cargo search:*)",
"Bash(cd:*)"
]
}
}

98
Cargo.lock generated
View File

@@ -60,6 +60,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.10.0"
@@ -109,6 +115,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -271,12 +286,52 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.10.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.16" version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -335,6 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -345,6 +401,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
"notify",
"notify-debouncer-mini",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -356,6 +414,42 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.10.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-mini"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a689eb4262184d9a1727f9087cd03883ea716682ab03ed24efec57d7716dccb8"
dependencies = [
"log",
"notify",
"notify-types",
"tempfile",
]
[[package]]
name = "notify-types"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -442,7 +536,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.10.0",
] ]
[[package]] [[package]]
@@ -451,7 +545,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.10.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",

View File

@@ -26,7 +26,8 @@ tracing-subscriber = "0.3.22"
# 性能优化:快速哈希(可选,用于路由匹配) # 性能优化:快速哈希(可选,用于路由匹配)
#dashmap = "7.0.0-rc2" #dashmap = "7.0.0-rc2"
# 热加载支持(扩展功能) # 热加载支持(扩展功能)
#notify = "8.2.0" notify = "8.2.0"
notify-debouncer-mini = "0.6.0"
# 路径处理 # 路径处理
#pathdiff = "0.2.3" #pathdiff = "0.2.3"

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

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

View File

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

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

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

30
mocks/v1/data/export.yaml Normal file
View File

@@ -0,0 +1,30 @@
# XML 数据导出 - XML 格式
name: "data_export"
request:
method: "POST"
path: "/v1/data/export"
headers:
Content-Type: "application/xml"
body: |
<?xml version="1.0" encoding="UTF-8"?>
<request>
<userId>10001</userId>
<format>xml</format>
</request>
response:
status: 200
headers:
Content-Type: "application/xml"
body: |
<?xml version="1.0" encoding="UTF-8"?>
<response>
<code>0</code>
<message>导出成功</message>
<data>
<user>
<id>10001</id>
<name>管理员</name>
<email>admin@example.com</email>
</user>
</data>
</response>

15
mocks/v1/health.yaml Normal file
View File

@@ -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"
}

View File

@@ -1,12 +1,12 @@
id: "prod_export_pdf" name: "prod_export_pdf"
request: request:
method: "GET" method: "GET"
path: "/api/v1/products/report" path: "/v1/products/report"
body: '{"username":"user","password":"123"}' body: '{"username":"user001","password":"password123"}'
response: response:
status: 200 status: 200
headers: headers:
Content-Type: "application/pdf" Content-Type: "application/pdf"
Content-Disposition: "attachment; filename=report.pdf" Content-Disposition: "attachment; filename=report.pdf"
# 智能协议:引擎会自动识别前缀并异步读取磁盘文件 # 智能协议:引擎会自动识别前缀并异步读取磁盘文件
body: "file://./storage/reports/annual_2024.pdf" body: "file://./storage/v1/hello.pdf"

View File

@@ -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"}'

73
mocks/v1/user/avatar.yaml Normal file
View File

@@ -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"
}
}

View File

@@ -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"

13
mocks/v1/user/echo.yaml Normal file
View File

@@ -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"

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"}]}'

View File

@@ -5,31 +5,158 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::{Arc, RwLock}; // 必须引入 RwLock
use tokio_util::io::ReaderStream; // 需在 Cargo.toml 确认有 tokio-util use tokio_util::io::ReaderStream;
use crate::models::Payload;
use crate::router::MockRouter; use crate::router::MockRouter;
/// 共享的应用状态 /// 共享的应用状态router 现在由 RwLock 保护以支持热重载
pub struct AppState { pub struct AppState {
pub router: MockRouter, pub router: RwLock<MockRouter>,
}
/// 提取请求的 Content-Type去掉参数部分如 boundary
fn extract_content_type(headers: &HeaderMap) -> Option<String> {
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<String, String> {
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<String, String> {
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<String> = 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( pub async fn mock_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数
method: Method, method: Method,
headers: HeaderMap, headers: HeaderMap,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
req: Request<Body>, req: Request<Body>, // Request<Body> 必须是最后一个参数
) -> impl IntoResponse { ) -> impl IntoResponse {
// let path = req.uri().path(); // 1. 提取 path 和 method
// 1. 【关键】将需要的数据克隆出来,断开与 req 的借用关系
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let method_str = method.as_str().to_string(); let method_str = method.as_str().to_string();
// 1. 将 Axum HeaderMap 转换为简单的 HashMap 供 Router 使用
// 2. 现在可以安全地消耗 req 了,因为上面没有指向 req 内部的引用了 // 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 { let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(_) => { Err(_) => {
@@ -40,42 +167,44 @@ pub async fn mock_handler(
} }
}; };
let incoming_json: Option<serde_json::Value> = serde_json::from_slice(&body_bytes).ok(); // 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(); 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() {
req_headers.insert(name.as_str().to_string(), v.to_string()); req_headers.insert(name.as_str().to_string(), v.to_string());
} }
} }
// 2. 执行匹配逻辑
if let Some(rule) = // 6. 执行匹配逻辑:先获取读锁 (Read Lock)
state let maybe_rule = {
.router let router = state.router.read().expect("Failed to acquire read lock");
.match_rule(&method_str, &path, &params, &req_headers, &incoming_json) router.match_rule(&method_str, &path, &params, &req_headers, &parsed_body).cloned()
{ // 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
// 3. 处理模拟延迟 };
if let Some(rule) = maybe_rule {
// 7. 处理模拟延迟
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. 构建响应基础信息 // 8. 构建响应
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);
// 注入 YAML 定义的 Header
if let Some(ref h) = rule.response.headers { if let Some(ref h) = rule.response.headers {
for (k, v) in h { for (k, v) in h {
// println!("{}:{}",k.clone(), v.clone());
response_builder = response_builder.header(k, v); response_builder = response_builder.header(k, v);
} }
} }
// 5. 执行 Smart Body 协议逻辑 // 9. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() { if let Some(file_path) = rule.response.get_file_path() {
// A. 文件模式:异步打开文件并转换为流,实现低内存占用
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);
@@ -91,13 +220,12 @@ pub async fn mock_handler(
.unwrap(), .unwrap(),
} }
} else { } else {
// B. 内联模式:直接返回字符串内容 // 内联模式:直接返回字符串内容
response_builder response_builder
.body(Body::from(rule.response.body.clone())) .body(Body::from(rule.response.body.clone()))
.unwrap() .unwrap()
} }
} else { } else {
println!("请求头{:?}", req_headers.clone());
// 匹配失败返回 404 // 匹配失败返回 404
Response::builder() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)

View File

@@ -1,5 +1,5 @@
// 声明模块并设为 pub这样 tests/ 目录才能看到它们 // 声明模块并设为 pub这样 tests/ 目录才能看到它们
pub mod config; pub mod models;
pub mod loader; pub mod loader;
pub mod router; pub mod router;
pub mod handler; pub mod handler;

View File

@@ -3,7 +3,7 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖 use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖
use crate::config::{MockRule, MockSource}; // 假设 config.rs 中定义了这两个类型 use crate::models::{MockRule, MockSource};
pub struct MockLoader; pub struct MockLoader;

View File

@@ -1,43 +1,63 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::{Arc, RwLock};
use std::time::Duration;
use axum::{routing::any, Router}; use axum::{routing::any, Router};
use notify_debouncer_mini::{new_debouncer, notify::*};
use mock_server::loader::MockLoader; use mock_server::loader::MockLoader;
use mock_server::router::MockRouter; use mock_server::router::MockRouter;
use mock_server::handler::{mock_handler, AppState}; use mock_server::handler::{mock_handler, AppState};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// 1. 初始化日志(建议添加,方便观察加载情况)
// 需要在 Cargo.toml 添加 tracing-subscriber = "0.3"
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// 2. 递归加载所有的 YAML 配置文件
let mocks_dir = Path::new("./mocks"); let mocks_dir = Path::new("./mocks");
if !mocks_dir.exists() { if !mocks_dir.exists() {
println!("Warning: 'mocks/' directory not found. Creating it...");
std::fs::create_dir_all(mocks_dir).unwrap(); std::fs::create_dir_all(mocks_dir).unwrap();
} }
// 1. 初始加载
println!("Scanning mocks directory..."); println!("Scanning mocks directory...");
let index = MockLoader::load_all_from_dir(mocks_dir); let index = MockLoader::load_all_from_dir(mocks_dir);
// 3. 构建路由引擎并包装为共享状态
let router_engine = MockRouter::new(index);
let shared_state = Arc::new(AppState { let shared_state = Arc::new(AppState {
router: router_engine, router: RwLock::new(MockRouter::new(index)),
}); });
// 4. 配置 Axum 路由 // 2. 设置热加载监听器
// 使用 any(mock_handler) 意味着它会接管所有 HTTP 方法和所有路径的请求 let state_for_watcher = shared_state.clone();
let watch_path = mocks_dir.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
// 200ms 防抖,防止编辑器保存文件时产生多次干扰
let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap();
debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap();
// 启动异步任务监听文件变动
tokio::spawn(async move {
while let Ok(res) = rx.recv() {
match res {
Ok(_) => {
println!("🔄 Detecting changes in mocks/, reloading...");
let new_index = MockLoader::load_all_from_dir(&watch_path);
// 获取写锁 (Write Lock) 更新索引
let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock");
*writer = MockRouter::new(new_index);
println!("✅ Mocks reloaded successfully.");
}
Err(e) => eprintln!("Watcher error: {:?}", e),
}
}
});
// 3. 配置 Axum 路由
let app = Router::new() let app = Router::new()
.fallback(any(mock_handler)) .fallback(any(mock_handler))
.with_state(shared_state); .with_state(shared_state);
// 5. 启动服务
let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
println!("🚀 Rust Mock Server is running on http://{}", addr); println!("🚀 Server running at http://{}", addr);
println!("Ready to handle requests based on your YAML definitions.");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();

View File

@@ -1,13 +1,58 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
/// 解析后的请求 Body
#[derive(Debug, Clone)]
pub enum Payload {
/// JSON 格式
Json(serde_json::Value),
/// XML 格式
Xml(String),
/// URL 编码表单
Form(HashMap<String, String>),
/// Multipart 表单(键值对)
Multipart(HashMap<String, String>),
/// 纯文本
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::<Vec<_>>().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)] #[derive(Debug, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum MockSource { pub enum MockSource {
/// 对应一个接口一个文件模式 /// 对应"一个接口一个文件"模式
Single(MockRule), Single(MockRule),
/// 对应一个文件多个接口模式 /// 对应"一个文件多个接口"模式
Multiple(Vec<MockRule>), Multiple(Vec<MockRule>),
} }
@@ -22,16 +67,16 @@ impl MockSource {
} }
/// 核心 Mock 规则定义 /// 核心 Mock 规则定义
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule { pub struct MockRule {
pub id: String, pub name: String,
pub request: RequestMatcher, pub request: RequestMatcher,
pub response: MockResponse, pub response: MockResponse,
pub settings: Option<MockSettings>, pub settings: Option<MockSettings>,
} }
/// 请求匹配条件 /// 请求匹配条件
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct RequestMatcher { pub struct RequestMatcher {
pub method: String, pub method: String,
pub path: String, pub path: String,
@@ -39,12 +84,12 @@ 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>, pub body: Option<serde_json::Value>,
} }
/// 响应内容定义 /// 响应内容定义
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockResponse { pub struct MockResponse {
pub status: u16, pub status: u16,
pub headers: Option<HashMap<String, String>>, pub headers: Option<HashMap<String, String>>,
@@ -53,7 +98,7 @@ pub struct MockResponse {
} }
/// 模拟器行为设置 /// 模拟器行为设置
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockSettings { pub struct MockSettings {
/// 模拟网络延迟(毫秒) /// 模拟网络延迟(毫秒)
pub delay_ms: Option<u64>, pub delay_ms: Option<u64>,

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::config::MockRule; use crate::models::{MockRule, Payload};
pub struct MockRouter { pub struct MockRouter {
// 索引表Key 是路径首段(如 "api"Value 是该段下的所有 Mock 规则 // 索引表Key 是路径首段(如 "api"Value 是该段下的所有 Mock 规则
@@ -18,7 +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: 增加参数 payload: &Payload,
) -> Option<&MockRule> { ) -> Option<&MockRule> {
// 1. 提取请求路径的首段作为索引 Key // 1. 提取请求路径的首段作为索引 Key
let key = self.extract_first_segment(path); let key = self.extract_first_segment(path);
@@ -27,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,incoming_body) { if self.is_match(rule, method, path, queries, headers, payload) {
return Some(rule); return Some(rule);
} }
} }
@@ -44,18 +44,19 @@ 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: 增加参数 payload: &Payload,
) -> 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() {
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; return false;
} }
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') { 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; 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 参数校验 (子集匹配原则) // B. Query 参数校验 (子集匹配原则)
if let Some(ref required_queries) = rule.request.query_params { if let Some(ref required_queries) = rule.request.query_params {
for (key, val) in required_queries { for (key, val) in required_queries {
@@ -65,46 +66,71 @@ impl MockRouter {
} }
} }
// C. Header 校验 (优化版) // C. Header 校验 (大小写不敏感Content-Type 使用前缀匹配)
if let Some(ref required_headers) = rule.request.headers { if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers { for (key, val) in required_headers {
println!("{}:{}",key.clone(), val.clone()); let key_lower = key.to_lowercase();
// 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值
let matched = headers.iter().any(|(k, v)| { let matched = headers.iter().any(|(k, v)| {
// k.to_lowercase() == key.to_lowercase() && v.contains(val) if k.to_lowercase() != key_lower {
k.to_lowercase() == key.to_lowercase() && v==val return false;
}
// Content-Type 使用前缀匹配(因为可能包含 boundary 等参数)
if key_lower == "content-type" {
v.to_lowercase().starts_with(&val.to_lowercase())
} else {
v == val
}
}); });
if !matched { if !matched {
return false; 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::<serde_json::Value>(s).unwrap_or_else(|_| required_val.clone())
} else {
required_val.clone()
};
// 执行全量相等比对 // D. Body 匹配(根据 Payload 类型智能比较)
if final_required != *actual_val { if let Some(ref yaml_body) = rule.request.body {
println!("DEBUG: [ID:{}] Body Mismatch", rule.id); return self.match_body(yaml_body, payload);
return false;
}
}
None => return false, // YAML 要求有 Body 但请求为空
}
} }
true 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 提取算法 /// 与 Loader 保持一致的 Key 提取算法
fn extract_first_segment(&self, path: &str) -> String { fn extract_first_segment(&self, path: &str) -> String {
path.trim_start_matches('/') path.trim_start_matches('/')
@@ -114,3 +140,86 @@ impl MockRouter {
.to_string() .to_string()
} }
} }
/// 规范化 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::<serde_json::Value>(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("<?xml") {
result = result[pos + 2..].to_string();
}
}
// 去掉多余空白字符
result = result
.chars()
.collect::<Vec<_>>()
.chunks(1)
.map(|c| c[0])
.collect::<String>();
// 分割成行,去掉每行首尾空白,过滤空行
result = result
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<String>();
result
}
/// Form 比较YAML 中的键值对必须是请求的子集
fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String, String>) -> 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<String, String>) -> 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
}

BIN
storage/v1/hello.pdf Normal file

Binary file not shown.

20
storage/v1/user_data.json Normal file
View File

@@ -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
}
}

View File

@@ -1,160 +1,92 @@
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 mock_server::models::MockSource;
use serde_json::json;
use mock_server::config::{MockRule, MockSource};
use mock_server::loader::MockLoader; use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑
#[test] #[test]
fn test_config_parsing_scenarios() { fn test_config_deserialization() {
// 场景 A: 验证单接口配置 (增加 body 结构化校验) // 测试 1验证单接口 YAML 解析
let yaml_single = r#" let single_yaml = r#"
id: "auth_v1" name: "auth_v1"
request: request:
method: "POST" method: "POST"
path: "/api/v1/login" path: "/api/v1/login"
body: { "user": "admin" } response:
response: { status: 200, body: "welcome" } status: 200
body: "inline_content"
"#; "#;
let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败"); let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口");
let rules = source_s.flatten(); assert_eq!(res.flatten().len(), 1);
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");
// 场景 B: 验证多接口配置 (原有逻辑不变) // 测试 2验证多接口 YAML 数组解析
let yaml_multi = r#" let multi_yaml = r#"
- id: "api_1" - name: "api_1"
request: { method: "GET", path: "/health" } request: { method: "GET", path: "/1" }
response: { status: 200, body: "ok" } response: { status: 200, body: "b1" }
- id: "api_2" - name: "api_2"
request: { method: "GET", path: "/version" } request: { method: "GET", path: "/2" }
response: { status: 200, body: "1.0" } response: { status: 200, body: "b2" }
"#; "#;
let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析接口失败"); let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
assert_eq!(source_m.flatten().len(), 2); assert_eq!(res_multi.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 递归扫描与索引构建 (不涉及 Matcher 逻辑,基本保持不变)
#[test] #[test]
fn test_loader_recursive_indexing() { fn test_recursive_loading_logic() {
let temp_root = tempdir().expect("无法创建临时目录"); // 创建临时 Mock 目录结构
let root_path = temp_root.path(); let root_dir = tempdir().expect("创建临时目录失败");
let root_path = root_dir.path();
let auth_path = root_path.join("v1/auth"); // 构造物理层级mocks/v1/user/
fs::create_dir_all(&auth_path).unwrap(); 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); let index = MockLoader::load_all_from_dir(root_path);
assert!(index.contains_key("api")); // 断言结果:
assert!(index.contains_key("health")); // 1. 检查 Key 是否根据路径首段正确提取
let total: usize = index.values().map(|v| v.len()).sum(); assert!(index.contains_key("api"), "索引应包含 'api' 键");
assert_eq!(total, 2); assert!(index.contains_key("health"), "索引应包含 'health' 键");
} assert!(index.contains_key("version"), "索引应包含 'version' 键");
#[test] // 2. 检查规则总数
fn test_router_matching_logic() { let total_rules: usize = index.values().map(|v| v.len()).sum();
let mut index = HashMap::new(); assert_eq!(total_rules, 3, "总规则数应为 3");
// 1. 准备带有 Body 的规则 // 3. 检查深层文件是否被正确读取
let rule_auth = serde_yaml::from_str::<MockSource>( let api_rules = &index["api"];
r#" assert_eq!(api_rules[0].name, "user_profile");
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. 测试场景 BBody 不匹配
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::<MockSource>(
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());
} }

View File

@@ -1,114 +1,425 @@
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 中叫 mock_server use serde_json::json;
use mock_server::config::MockSource; use mock_server::models::{MockRule, MockSource, Payload};
use mock_server::loader::MockLoader; use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑
#[test] #[test]
fn test_config_deserialization() { fn test_config_parsing_scenarios() {
// 测试 1验证单接口 YAML 解析 // 场景 A: 验证单接口配置 (增加 body 结构化校验)
let single_yaml = r#" let yaml_single = r#"
id: "auth_v1" name: "auth_v1"
request: request:
method: "POST" method: "POST"
path: "/api/v1/login" path: "/api/v1/login"
response: body: { "user": "admin" }
status: 200 response: { status: 200, body: "welcome" }
body: "inline_content"
"#; "#;
let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口"); let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
assert_eq!(res.flatten().len(), 1); let rules = source_s.flatten();
// assert_eq!(res.flatten()[0].id, "auth_v1"); 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 数组解析 // 场景 B: 验证多接口配置
let multi_yaml = r#" let yaml_multi = r#"
- id: "api_1" - name: "api_1"
request: { method: "GET", path: "/1" } request: { method: "GET", path: "/health" }
response: { status: 200, body: "b1" } response: { status: 200, body: "ok" }
- id: "api_2" - name: "api_2"
request: { method: "GET", path: "/2" } request: { method: "GET", path: "/version" }
response: { status: 200, body: "b2" } response: { status: 200, body: "1.0" }
"#; "#;
let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组"); let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析接口失败");
assert_eq!(res_multi.flatten().len(), 2); 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] #[test]
fn test_recursive_loading_logic() { fn test_loader_recursive_indexing() {
// 创建临时 Mock 目录结构 let temp_root = tempdir().expect("无法创建临时目录");
let root_dir = tempdir().expect("创建临时目录失败"); let root_path = temp_root.path();
let root_path = root_dir.path();
// 构造物理层级mocks/v1/user/ let auth_path = root_path.join("v1/auth");
let user_dir = root_path.join("v1/user"); fs::create_dir_all(&auth_path).unwrap();
fs::create_dir_all(&user_dir).unwrap();
// 在深层目录创建单接口文件 let mut f1 = File::create(auth_path.join("single_login.yaml")).unwrap();
let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap(); writeln!(f1, "name: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap();
writeln!(
file1,
r#"
id: "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();
let mut file2 = File::create(root_path.join("system.yaml")).unwrap(); writeln!(f2, "- name: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").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); let index = MockLoader::load_all_from_dir(root_path);
// 断言结果: assert!(index.contains_key("api"));
// 1. 检查 Key 是否根据路径首段正确提取(/api/v1/... -> api, /health -> health assert!(index.contains_key("health"));
assert!(index.contains_key("api"), "索引应包含 'api' 键"); let total: usize = index.values().map(|v| v.len()).sum();
assert!(index.contains_key("health"), "索引应包含 'health' 键"); assert_eq!(total, 2);
assert!(index.contains_key("version"), "索引应包含 'version' 键"); }
// 2. 检查规则总数 /// 模块三:验证 Router 匹配逻辑(使用新的 Payload 类型)
let total_rules: usize = index.values().map(|v| v.len()).sum(); #[test]
assert_eq!(total_rules, 3, "总规则数应为 3"); fn test_router_matching_logic() {
let mut index = HashMap::new();
// 3. 检查深层文件是否被正确读取
let api_rules = &index["api"]; // 1. 准备带有 Body 的规则
assert_eq!(api_rules[0].id, "user_profile"); let rule_auth = serde_yaml::from_str::<MockSource>(
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. 测试场景 AJSON 完全匹配
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. 测试场景 BBody 不匹配
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::<MockSource>(
r#"
name: "xml_api"
request:
method: "POST"
path: "/api/xml"
body: "<root><name>test</name></root>"
response: { status: 200, body: "ok" }
"#,
)
.unwrap()
.flatten();
// Form 规则
let form_rule = serde_yaml::from_str::<MockSource>(
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::<MockSource>(
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("<root><name>test</name></root>".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::<MockSource>(
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::<MockSource>(
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::<MockSource>(
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::<MockSource>(
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::<MockSource>(
r#"
name: "xml_api"
request:
method: "POST"
path: "/api/xml"
body: |
<user>
<id>1001</id>
<name>张三</name>
</user>
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("<user>\n <id>1001</id>\n <name>张三</name>\n</user>".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("<user><id>1001</id><name>张三</name></user>".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("<?xml version=\"1.0\" encoding=\"UTF-8\"?><user><id>1001</id><name>张三</name></user>".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("<user><id>1002</id><name>李四</name></user>".to_string());
let matched4 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml);
assert!(matched4.is_none(), "内容不同的 XML 不应该匹配");
} }

View File

@@ -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<String, Vec<mock_server::models::MockRule>> {
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#"<request><userId>10001</userId><format>xml</format></request>"#;
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_' 开头");
}
}
}