Compare commits
8 Commits
develop
...
feature-ms
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c1d0e16b4 | |||
| b579a835de | |||
| e218ab04fe | |||
| 18e0ccad58 | |||
| 3097a16465 | |||
| 5f3269bad5 | |||
| 1775d3659d | |||
| 748cfa8e7f |
76
.claude/CLAUDE.md
Normal file
76
.claude/CLAUDE.md
Normal 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
.claude/rules/commit-spec.md
Normal file
89
.claude/rules/commit-spec.md
Normal 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
.claude/rules/mock-spec.md
Normal file
89
.claude/rules/mock-spec.md
Normal 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
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(cargo search:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
288
Cargo.lock
generated
288
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -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"
|
||||||
75
README.md
75
README.md
@@ -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
mocks/v1/auth/login.yaml
Normal file
28
mocks/v1/auth/login.yaml
Normal 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
mocks/v1/auth/login_out.yaml
Normal file
50
mocks/v1/auth/login_out.yaml
Normal 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
mocks/v1/products/export.yaml
Normal file
12
mocks/v1/products/export.yaml
Normal 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
mocks/v1/system/health.yaml
Normal file
16
mocks/v1/system/health.yaml
Normal 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
mocks/v1/upload.yaml
Normal file
23
mocks/v1/upload.yaml
Normal 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
mocks/v1/user/search.yaml
Normal file
11
mocks/v1/user/search.yaml
Normal 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
src/config.rs
Normal file
76
src/config.rs
Normal 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
src/handler.rs
Normal file
104
src/handler.rs
Normal 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, ¶ms, &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
src/lib.rs
Normal file
5
src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
|
||||||
|
pub mod config;
|
||||||
|
pub mod loader;
|
||||||
|
pub mod router;
|
||||||
|
pub mod handler;
|
||||||
58
src/loader.rs
Normal file
58
src/loader.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main.rs
65
src/main.rs
@@ -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
src/router.rs
Normal file
116
src/router.rs
Normal 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
tests/integration_test.rs
Normal file
160
tests/integration_test.rs
Normal 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
tests/loader_test.rs
Normal file
114
tests/loader_test.rs
Normal 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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user