Author SHA1 Message Date
CNWei 9c1d0e16b4 docs: 更新README并添加文件上传mock配置
- 移除文件上传相关文档(已从代码中移除)
   - 新增未来计划:动态参数、HTTPS、可视化管理界面
   - 添加 upload.yaml mock 配置文件
2026-03-25 17:40:34 +08:00
CNWeiandClaude Opus 4.6 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 <[email protected]>
2026-03-25 17:11:10 +08:00
CNWei e218ab04fe feat: 优化CLAUDE.md
- 优化 优化CLAUDE.md
2026-03-25 17:10:36 +08:00
CNWei 18e0ccad58 merge: 合并0321分支的热重载功能与body字段匹配支持 2026-03-25 16:59:09 +08:00
CNWei 3097a16465 feat: 实现配置热重载功能
- 引入热加载机制:集成 `notify-debouncer-mini` 监听 `./mocks` 目录,实现 YAML 变动自动重载。
- 优化并发控制:将 `MockRouter` 包装在 `RwLock` 中,确保 Web 线程(读)与监控线程(写)的数据安全。
- 增强系统健壮性:增加防抖处理防止频繁 IO 触发重载。
2026-01-04 11:44:18 +08:00
CNWei 5f3269bad5 feat: 增加request对body字段的支持
- 实现body字段对json语法,yaml语法的支持的支持
- 新增测试用例login_out.yaml
2025-12-28 21:54:34 +08:00
CNWei 1775d3659d feat: 实现核心匹配引擎与请求处理器并修复解析逻辑
- 新增 router 模块:实现基于路径首段索引子集匹配。
- 新增 handler 模块:集成 Axum 处理器,支持 Smart Body 协议与延迟模拟。
- 修复解析与匹配故障:修正 YAML 字段类型解析错误。
2025-12-26 15:21:03 +08:00
CNWei 748cfa8e7f feat: 实现配置加载系统与多级目录递归扫描
- 定义支持“一接口一文件”与“一文件多接口”的数据模型
- 实现基于路径首段的 HashMap 索引构建逻辑
- 新增集成测试 tests/integration_test.rs,验证 YAML 解析与目录递归加载
- 优化 Cargo.toml 配置,解决连字符项目名引用问题
2025-12-25 18:01:09 +08:00
CNWei 9a48c156c8 init: 初始化mock-server项目
- 搭建Rust项目基础结构(src/mocks目录、核心模块空文件)
- 配置Cargo.toml核心依赖(Axum/Tokio/Serde等)
- 添加.gitignore和README初始文件
- 适配Rust开发环境基础配置

项目说明:
- 名称:mock-server | 描述:Rust/Axum配置驱动Mock服务 | 环境:Rust 1.70+
- 下一步:完善文档、补充核心业务逻辑、配置CI/CD"
2025-12-25 13:56:11 +08:00
22 changed files with 1467 additions and 36 deletions
+76
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)
+89
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): 简化响应处理逻辑
将文件响应和内联响应的处理逻辑分离到独立函数中。
```
+89
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`
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(cargo search:*)"
]
}
}
Generated
+286 -2
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"
@@ -84,6 +90,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -93,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"
@@ -145,6 +176,18 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -243,18 +286,70 @@ 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]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.178" version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -295,21 +390,73 @@ 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",
] ]
[[package]] [[package]]
name = "mock-server" name = "mock_server"
version = "0.1.0" 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",
"tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tracing",
"tracing-subscriber",
"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]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -377,13 +524,32 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" 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]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -392,6 +558,15 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -477,6 +652,15 @@ dependencies = [
"unsafe-libyaml", "unsafe-libyaml",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.7" version = "1.4.7"
@@ -525,6 +709,28 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tempfile"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.48.0" version = "1.48.0"
@@ -602,9 +808,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log", "log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -612,6 +830,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -626,12 +870,46 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -721,6 +999,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "0.1.8" version = "0.1.8"
+10 -3
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "mock-server" name = "mock_server"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
@@ -18,11 +18,18 @@ serde_yaml = "0.9.34+deprecated"
serde_json = "1.0.147" serde_json = "1.0.147"
# 物理目录递归扫描工具 # 物理目录递归扫描工具
#walkdir = "2.5.0" walkdir = "2.5.0"
tracing="0.1.44"
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"
[dev-dependencies]
tempfile = "3.24.0"
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 CNWei
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70 -5
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
+28
View File
@@ -0,0 +1,28 @@
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: 2000 # 模拟真实网络延迟
+50
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 # 模拟真实网络延迟
+12
View File
@@ -0,0 +1,12 @@
id: "prod_export_pdf"
request:
method: "GET"
path: "/api/v1/products/report"
body: '{"username":"user","password":"123"}'
response:
status: 200
headers:
Content-Type: "application/pdf"
Content-Disposition: "attachment; filename=report.pdf"
# 智能协议:引擎会自动识别前缀并异步读取磁盘文件
body: "file://./storage/reports/annual_2024.pdf"
+16
View File
@@ -0,0 +1,16 @@
# 使用 YAML 数组语法定义多个规则
- id: "sys_ping"
request:
method: "GET"
path: "/api/v1/ping"
response:
status: 200
body: "pong"
- id: "sys_version"
request:
method: "GET"
path: "/api/v1/version"
response:
status: 200
body: '{"version": "1.2.0-smart"}'
+23
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
+11
View File
@@ -0,0 +1,11 @@
id: "user_search_admin"
request:
method: "GET"
path: "/api/v1/users"
# 请求中必须包含 role=admin 且 status=active
query_params:
role: "admin"
status: "active"
response:
status: 200
body: '{"users": [{"id": 1, "name": "SuperAdmin"}]}'
+76
View File
@@ -0,0 +1,76 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// 顶层包装:支持单对象或数组,自动打平
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum MockSource {
/// 对应“一个接口一个文件”模式
Single(MockRule),
/// 对应“一个文件多个接口”模式
Multiple(Vec<MockRule>),
}
impl MockSource {
/// 将不同的解析模式统一转化为列表,供 Loader 构建索引
pub fn flatten(self) -> Vec<MockRule> {
match self {
Self::Single(rule) => vec![rule],
Self::Multiple(rules) => rules,
}
}
}
/// 核心 Mock 规则定义
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
pub struct MockRule {
pub id: String,
pub request: RequestMatcher,
pub response: MockResponse,
pub settings: Option<MockSettings>,
}
/// 请求匹配条件
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
pub struct RequestMatcher {
pub method: String,
pub path: String,
/// 选填:只有请求包含这些参数时才匹配
pub query_params: Option<HashMap<String, String>>,
/// 选填:只有请求包含这些 Header 时才匹配
pub headers: Option<HashMap<String, String>>,
// 修改点:从 String 改为 Option<serde_json::Value>
pub body: Option<serde_json::Value>,
}
/// 响应内容定义
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
pub struct MockResponse {
pub status: u16,
pub headers: Option<HashMap<String, String>>,
/// 统一字段:支持 Inline 文本或 file:// 前缀的路径
pub body: String,
}
/// 模拟器行为设置
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
pub struct MockSettings {
/// 模拟网络延迟(毫秒)
pub delay_ms: Option<u64>,
}
impl MockResponse {
/// 辅助方法:判断是否为文件协议
pub fn is_file_protocol(&self) -> bool {
self.body.starts_with("file://")
}
/// 获取去掉协议前缀后的纯物理路径
pub fn get_file_path(&self) -> Option<&str> {
if self.is_file_protocol() {
Some(&self.body[7..]) // 截取 "file://" 之后的内容
} else {
None
}
}
}
+104
View File
@@ -0,0 +1,104 @@
use axum::{
body::Body,
extract::{Query, State},
http::{HeaderMap, Method, Request, StatusCode},
response::{IntoResponse, Response},
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock}; // 必须引入 RwLock
use tokio_util::io::ReaderStream;
use crate::router::MockRouter;
/// 共享的应用状态,router 现在由 RwLock 保护以支持热重载
pub struct AppState {
pub router: RwLock<MockRouter>,
}
/// 全局统一请求处理函数
pub async fn mock_handler(
State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数
method: Method,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
req: Request<Body>, // Request<Body> 必须是最后一个参数
) -> impl IntoResponse {
// 1. 提取 path 和 method
let path = req.uri().path().to_string();
let method_str = method.as_str().to_string();
// 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();
for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() {
req_headers.insert(name.as_str().to_string(), v.to_string());
}
}
// 4. 执行匹配逻辑:先获取读锁 (Read Lock)
let maybe_rule = {
let router = state.router.read().expect("Failed to acquire read lock");
router.match_rule(&method_str, &path, &params, &req_headers, &incoming_json).cloned()
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
};
if let Some(rule) = maybe_rule {
// 5. 处理模拟延迟
if let Some(ref settings) = rule.settings {
if let Some(delay) = settings.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
// 6. 构建响应
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
let mut response_builder = Response::builder().status(status);
if let Some(ref h) = rule.response.headers {
for (k, v) in h {
response_builder = response_builder.header(k, v);
}
}
// 7. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() {
match tokio::fs::File::open(file_path).await {
Ok(file) => {
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
response_builder.body(body).unwrap()
}
Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(format!(
"Mock Error: File not found at {}",
file_path
)))
.unwrap(),
}
} else {
// 内联模式:直接返回字符串内容
response_builder
.body(Body::from(rule.response.body.clone()))
.unwrap()
}
} else {
// 匹配失败返回 404
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("No mock rule matched this request"))
.unwrap()
}
}
+5
View File
@@ -0,0 +1,5 @@
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
pub mod config;
pub mod loader;
pub mod router;
pub mod handler;
+58
View File
@@ -0,0 +1,58 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖
use crate::config::{MockRule, MockSource}; // 假设 config.rs 中定义了这两个类型
pub struct MockLoader;
impl MockLoader {
/// 递归扫描指定目录并构建索引表
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
// 1. 使用 walkdir 递归遍历目录,不限层级
for entry in WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml"))
{
if let Some(rules) = Self::parse_yaml_file(entry.path()) {
for rule in rules {
// 2. 提取路径首段作为索引 Key
let key = Self::extract_first_segment(&rule.request.path);
// 3. 将规则插入到对应的索引桶中
index.entry(key).or_insert_with(Vec::new).push(rule);
}
}
}
println!("Successfully loaded {} segments from {:?}", index.len(), dir);
index
}
/// 解析单个 YAML 文件,支持单接口和多接口模式
fn parse_yaml_file(path: &Path) -> Option<Vec<MockRule>> {
let content = fs::read_to_string(path).ok()?;
// 利用 serde_yaml 的反序列化能力处理 MockSource 枚举
match serde_yaml::from_str::<MockSource>(&content) {
Ok(source) => Some(source.flatten()), // 统一打平为 Vec<MockRule>
Err(e) => {
eprintln!("Failed to parse YAML at {:?}: {}", path, e);
None
}
}
}
/// 提取路径的第一级作为 Key(例如 "/api/v1/login" -> "api")
fn extract_first_segment(path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root") // 如果是根路径 "/",则归类到 "root"
.to_string()
}
}
+63 -2
View File
@@ -1,3 +1,64 @@
fn main() { use std::net::SocketAddr;
println!("Hello, mock-server!"); use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use axum::{routing::any, Router};
use notify_debouncer_mini::{new_debouncer, notify::*};
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use mock_server::handler::{mock_handler, AppState};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let mocks_dir = Path::new("./mocks");
if !mocks_dir.exists() {
std::fs::create_dir_all(mocks_dir).unwrap();
}
// 1. 初始加载
println!("Scanning mocks directory...");
let index = MockLoader::load_all_from_dir(mocks_dir);
let shared_state = Arc::new(AppState {
router: RwLock::new(MockRouter::new(index)),
});
// 2. 设置热加载监听器
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()
.fallback(any(mock_handler))
.with_state(shared_state);
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
println!("🚀 Server running at http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
} }
+116
View File
@@ -0,0 +1,116 @@
use std::collections::HashMap;
use crate::config::MockRule;
pub struct MockRouter {
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
pub index: HashMap<String, Vec<MockRule>>,
}
impl MockRouter {
pub fn new(index: HashMap<String, Vec<MockRule>>) -> Self {
Self { index }
}
/// 核心匹配函数:根据请求信息寻找匹配的 Mock 规则
pub fn match_rule(
&self,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 1: 增加参数
) -> Option<&MockRule> {
// 1. 提取请求路径的首段作为索引 Key
let key = self.extract_first_segment(path);
println!("DEBUG: Request Key: '{}', Available Keys: {:?}", key, self.index.keys());
// 2. 从 HashMap 中快速定位候选规则列表 (O(k) 复杂度)
if let Some(rules) = self.index.get(&key) {
// 3. 在候选集中进行线性深度匹配
for rule in rules {
if self.is_match(rule, method, path, queries, headers,incoming_body) {
return Some(rule);
}
}
}
None
}
/// 细粒度匹配逻辑
fn is_match(
&self,
rule: &MockRule,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 3: 增加参数
) -> bool {
// A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠)
if rule.request.method.to_uppercase() != method.to_uppercase() {
println!("DEBUG: [ID:{}] Method Mismatch: YAML={}, Req={}", rule.id, rule.request.method, method);
return false;
}
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
println!("DEBUG: [ID:{}] Path Mismatch: YAML='{}', Req='{}'", rule.id, rule.request.path, path);
return false;
}
println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id);
// B. Query 参数校验 (子集匹配原则)
if let Some(ref required_queries) = rule.request.query_params {
for (key, val) in required_queries {
if queries.get(key) != Some(val) {
return false;
}
}
}
// C. Header 校验 (优化版)
if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers {
println!("{}:{}",key.clone(), val.clone());
// 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值
let matched = headers.iter().any(|(k, v)| {
// k.to_lowercase() == key.to_lowercase() && v.contains(val)
k.to_lowercase() == key.to_lowercase() && v==val
});
if !matched {
return false;
}
}
}
// 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
}
/// 与 Loader 保持一致的 Key 提取算法
fn extract_first_segment(&self, path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string()
}
}
+160
View File
@@ -0,0 +1,160 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
// 确保 Cargo.toml 中有 serde_json
use serde_json::json;
use mock_server::config::{MockRule, MockSource};
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑
#[test]
fn test_config_parsing_scenarios() {
// 场景 A: 验证单接口配置 (增加 body 结构化校验)
let yaml_single = r#"
id: "auth_v1"
request:
method: "POST"
path: "/api/v1/login"
body: { "user": "admin" }
response: { status: 200, body: "welcome" }
"#;
let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
let rules = source_s.flatten();
assert_eq!(rules.len(), 1);
// 验证 body 是否被成功解析为 Value::Object
assert!(rules[0].request.body.is_some());
assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin");
// 场景 B: 验证多接口配置 (原有逻辑不变)
let yaml_multi = r#"
- id: "api_1"
request: { method: "GET", path: "/health" }
response: { status: 200, body: "ok" }
- id: "api_2"
request: { method: "GET", path: "/version" }
response: { status: 200, body: "1.0" }
"#;
let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败");
assert_eq!(source_m.flatten().len(), 2);
// 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 (原有逻辑不变)
let yaml_file = r#"
id: "export_api"
request: { method: "GET", path: "/download" }
response: { status: 200, body: "file://./storage/data.zip" }
"#;
let source_f: MockSource = serde_yaml::from_str(yaml_file).unwrap();
let rule = &source_f.flatten()[0];
assert!(rule.response.body.starts_with("file://"));
}
/// 模块二:验证 Loader 递归扫描与索引构建 (不涉及 Matcher 逻辑,基本保持不变)
#[test]
fn test_loader_recursive_indexing() {
let temp_root = tempdir().expect("无法创建临时目录");
let root_path = temp_root.path();
let auth_path = root_path.join("v1/auth");
fs::create_dir_all(&auth_path).unwrap();
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 f2 = File::create(root_path.join("sys.yaml")).unwrap();
writeln!(f2, "- id: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap();
let index = MockLoader::load_all_from_dir(root_path);
assert!(index.contains_key("api"));
assert!(index.contains_key("health"));
let total: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total, 2);
}
#[test]
fn test_router_matching_logic() {
let mut index = HashMap::new();
// 1. 准备带有 Body 的规则
let rule_auth = serde_yaml::from_str::<MockSource>(
r#"
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. 测试场景 B:Body 不匹配
let wrong_body = Some(json!({ "code": 456 }));
let matched_fail = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&wrong_body
);
assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
// 4. 测试场景 C:智能字符串转换验证 (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());
}
+114
View File
@@ -0,0 +1,114 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
// 假设你的项目名在 Cargo.toml 中叫 mock_server
use mock_server::config::MockSource;
use mock_server::loader::MockLoader;
#[test]
fn test_config_deserialization() {
// 测试 1:验证单接口 YAML 解析
let single_yaml = r#"
id: "auth_v1"
request:
method: "POST"
path: "/api/v1/login"
response:
status: 200
body: "inline_content"
"#;
let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口");
assert_eq!(res.flatten().len(), 1);
// assert_eq!(res.flatten()[0].id, "auth_v1");
// 测试 2:验证多接口 YAML 数组解析
let multi_yaml = r#"
- id: "api_1"
request: { method: "GET", path: "/1" }
response: { status: 200, body: "b1" }
- id: "api_2"
request: { method: "GET", path: "/2" }
response: { status: 200, body: "b2" }
"#;
let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
assert_eq!(res_multi.flatten().len(), 2);
}
#[test]
fn test_recursive_loading_logic() {
// 创建临时 Mock 目录结构
let root_dir = tempdir().expect("创建临时目录失败");
let root_path = root_dir.path();
// 构造物理层级:mocks/v1/user/
let user_dir = root_path.join("v1/user");
fs::create_dir_all(&user_dir).unwrap();
// 在深层目录创建单接口文件
let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap();
writeln!(
file1,
r#"
id: "user_profile"
request:
method: "GET"
path: "/api/v1/user/profile"
response:
status: 200
body: "profile_data"
"#
)
.unwrap();
// 在根目录创建多接口文件
let mut file2 = File::create(root_path.join("system.yaml")).unwrap();
writeln!(
file2,
r#"
- id: "sys_health"
request: {{ method: "GET", path: "/health" }}
response: {{ status: 200, body: "ok" }}
- id: "sys_version"
request: {{ method: "GET", path: "/version" }}
response: {{ status: 200, body: "1.0.0" }}
"#
)
.unwrap();
// writeln!(
// file2,
// r#"
// - id: "sys_health"
// request:
// method: "GET"
// path: "/health"
// response:
// status: 200
// body: "ok"
// - id: "sys_version"
// request:
// method: "GET"
// path: "/version"
// response:
// status: 200
// body: "1.0.0"
// "#
// )
// .unwrap();
// 执行加载
let index = MockLoader::load_all_from_dir(root_path);
// 断言结果:
// 1. 检查 Key 是否根据路径首段正确提取(/api/v1/... -> api, /health -> health)
assert!(index.contains_key("api"), "索引应包含 'api' 键");
assert!(index.contains_key("health"), "索引应包含 'health' 键");
assert!(index.contains_key("version"), "索引应包含 'version' 键");
// 2. 检查规则总数
let total_rules: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total_rules, 3, "总规则数应为 3");
// 3. 检查深层文件是否被正确读取
let api_rules = &index["api"];
assert_eq!(api_rules[0].id, "user_profile");
}