Author SHA1 Message Date
CNWeiandClaude Opus 4.6 ddfa4f3b5a 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-24 18:44:06 +08:00
CNWei 76ca868038 feat: 优化CLAUDE.md
- 优化 优化CLAUDE.md
2026-03-22 21:46:22 +08:00
CNWeiandClaude Opus 4.6 24c4672022 fix: 修复合并后的测试兼容性
- 更新 handler_test.rs 使用 RwLock<MockRouter>
- 确保所有 43 个测试通过

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-19 22:21:33 +08:00
CNWeiandClaude Opus 4.6 7d517a89c9 merge: 合并热重载功能分支
合并 feature-ms-v0.0.1-SNAPSHOOT-20260319 的热重载实现:
- 使用 notify-debouncer-mini 监听 mocks 目录变化
- AppState 使用 RwLock<MockRouter> 支持并发读写
- 200ms 防抖避免编辑器保存时的多次触发

保留当前分支的功能:
- 文件上传功能 (upload.rs)
- 请求体 body 匹配支持
- 完整的测试覆盖 (43个测试)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-19 22:20:20 +08:00
CNWeiandClaude Opus 4.6 ab368ead1b feat: 实现文件上传功能并完善测试覆盖
- 新增 upload.rs 模块,支持 multipart/form-data 文件上传
- 文件按日期存储在 storage/YYYY-MM-DD/ 目录下
- 使用 UUID 生成唯一文件名,保留原始扩展名
- 添加 axum-extra, uuid, chrono 依赖

新增测试用例:
- config_test.rs: 6 个测试 (配置结构验证)
- router_test.rs: 11 个测试 (路由匹配逻辑)
- handler_test.rs: 8 个测试 (请求处理)
- upload_test.rs: 13 个测试 (文件上传功能)

其他改进:
- 优化 handler.rs 代码注释
- 更新 .gitignore 忽略 storage/ 和 .claude/
- 添加 CLAUDE.md 项目指南文档

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-19 22:11:42 +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
28 changed files with 3324 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:*)"
]
}
}
+3
View File
@@ -12,3 +12,6 @@
# 日志文件
logs/
*.log
# 上传文件存储目录
storage/
Generated
+786 -3
View File
@@ -2,12 +2,33 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.8"
@@ -60,30 +81,132 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [
"axum",
"axum-core",
"bytes",
"fastrand",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"multer",
"pin-project-lite",
"rustversion",
"serde_core",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytes"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -93,6 +216,15 @@ dependencies = [
"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]]
name = "futures-channel"
version = "0.3.31"
@@ -145,12 +277,52 @@ dependencies = [
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.0"
@@ -233,6 +405,36 @@ dependencies = [
"tower-service",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.12.1"
@@ -240,7 +442,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[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]]
@@ -249,12 +473,60 @@ version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
[[package]]
name = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[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]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -295,21 +567,102 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "mock-server"
name = "mock_server"
version = "0.1.0"
dependencies = [
"axum",
"axum-extra",
"chrono",
"futures-util",
"notify",
"notify-debouncer-mini",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"uuid",
"walkdir",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[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]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
@@ -359,6 +712,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
@@ -377,27 +740,73 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
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]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -477,6 +886,21 @@ dependencies = [
"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]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.7"
@@ -508,6 +932,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "syn"
version = "2.0.111"
@@ -525,6 +955,28 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tempfile"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"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]]
name = "tokio"
version = "1.48.0"
@@ -602,9 +1054,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"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]]
name = "tracing-core"
version = "0.1.36"
@@ -612,6 +1076,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"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]]
@@ -620,24 +1110,223 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "uuid"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen 0.46.0",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.10.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -721,6 +1410,100 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.10.0",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zmij"
version = "0.1.8"
+18 -4
View File
@@ -1,11 +1,12 @@
[package]
name = "mock-server"
name = "mock_server"
version = "0.1.0"
edition = "2024"
[dependencies]
# 核心 Web 框架
axum = "0.8.8"
axum-extra = { version = "0.10", features = ["multipart"] }
# 异步运行时
tokio={version = "1.48.0",features = ["full"]}
# 异步文件操作与流处理工具
@@ -18,11 +19,24 @@ serde_yaml = "0.9.34+deprecated"
serde_json = "1.0.147"
# 物理目录递归扫描工具
#walkdir = "2.5.0"
walkdir = "2.5.0"
# UUID 生成(用于唯一文件名)
uuid = { version = "1.0", features = ["v4", "serde"] }
# 日期时间处理
chrono = "0.4"
tracing="0.1.44"
tracing-subscriber = "0.3.22"
# 性能优化:快速哈希(可选,用于路由匹配)
#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.
+72 -6
View File
@@ -1,11 +1,77 @@
# mock-server
基于Rust/Axum的配置驱动型Mock服务,支持YAML配置、请求匹配、延迟响应、大文件流式返回等特性。
基于 Rust/Axum 的配置驱动型 Mock 服务,支持 YAML 配置、请求匹配、热重载、延迟响应、大文件流式返回等特性。
## 特性
- 配置驱动:YAML定义API行为,无需修改代码
- 高性能:基于Rust异步运行时,哈希索引匹配请求
- 低内存:大响应体支持磁盘文件流式读取,不占用常驻内存
- 易扩展:模块化设计,支持动态占位符、热加载(规划中)
- **配置驱动**:YAML 定义 API 行为,无需修改代码
- **热重载**:`mocks/*.yaml` 变更自动生效,无需重启服务
- **高性能**:基于 Rust 异步运行时,路径首段哈希索引 O(1) 匹配
- **低内存**:大响应体支持 `file://` 协议从磁盘流式读取
- **文件上传**:内置 `/api/upload` 端点,按日期分目录存储
## 快速开始
### 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 # 统一请求处理器,文件流式响应
└── upload.rs # Multipart 文件上传处理
tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件
storage/ # 上传文件存储(按 YYYY-MM-DD 分目录)
```
## Mock 配置
详细配置规范请参考 [.claude/rules/mock-spec.md](.claude/rules/mock-spec.md)
## API
### 文件上传
```http
POST /api/upload
Content-Type: multipart/form-data
file: <binary>
```
**响应示例:**
```json
{
"filename": "example.txt",
"path": "storage/2024-01-15/example.txt",
"size": 1024
}
```
## 许可证
MIT
+18
View File
@@ -0,0 +1,18 @@
id: "user-login"
request:
method: "POST"
path: "/api/v1/login"
query_params:
redirect: "/dashboard"
headers:
Content-Type: "application/json"
body:
username: "test"
password: "123456"
response:
status: 200
headers:
Content-Type: "application/json"
body: '{"code": 0, "message": "success", "data": {"token": "mock_token_12345"}}'
settings:
delay_ms: 100
+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"}'
+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
}
}
}
+105
View File
@@ -0,0 +1,105 @@
use axum::{
body::Body,
extract::{Query, State},
http::{HeaderMap, Method, Request, StatusCode},
response::{IntoResponse, Response},
};
use std::collections::HashMap;
use std::sync::{Arc, 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>>,
method: Method,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
req: Request<Body>,
) -> impl IntoResponse {
let path = req.uri().path().to_string();
let method_str = method.as_str().to_string();
// 1. 将需要的数据克隆出来,断开与 req 的借用关系
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();
// 2. 将 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());
}
}
// 3. 执行匹配逻辑:先获取读锁 (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 {
// 4. 处理模拟延迟
if let Some(ref settings) = rule.settings {
if let Some(delay) = settings.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
// 5. 构建响应
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);
}
}
// 6. 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 {
println!("请求头{:?}", req_headers);
// 匹配失败返回 404
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("No mock rule matched this request"))
.unwrap()
}
}
+6
View File
@@ -0,0 +1,6 @@
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
pub mod config;
pub mod loader;
pub mod router;
pub mod handler;
pub mod upload;
+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()
}
}
+82 -2
View File
@@ -1,3 +1,83 @@
fn main() {
println!("Hello, mock-server!");
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use axum::{routing::{any, post}, 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};
use mock_server::upload::upload_handler;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
// 1. 确保 mocks 目录存在
let mocks_dir = Path::new("./mocks");
if !mocks_dir.exists() {
println!("Warning: 'mocks/' directory not found. Creating it...");
std::fs::create_dir_all(mocks_dir).unwrap();
}
// 2. 确保 storage 目录存在
let storage_dir = Path::new("./storage");
if !storage_dir.exists() {
println!("Creating storage directory...");
std::fs::create_dir_all(storage_dir).unwrap();
}
// 3. 初始加载 Mock 配置
println!("Scanning mocks directory...");
let index = MockLoader::load_all_from_dir(mocks_dir);
// 4. 构建共享状态(使用 RwLock 支持热重载)
let shared_state = Arc::new(AppState {
router: RwLock::new(MockRouter::new(index)),
});
// 5. 设置热加载监听器
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),
}
}
});
// 6. 配置 Axum 路由
// 文件上传路由:POST /api/upload
// 其他所有请求由 mock_handler 处理
let app = Router::new()
.route("/api/upload", post(upload_handler))
.fallback(any(mock_handler))
.with_state(shared_state);
// 7. 启动服务
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
println!("🚀 Rust Mock Server is running on http://{}", addr);
println!("📁 File upload endpoint: POST http://{}/api/upload", addr);
println!("🔄 Hot reload enabled for mocks/ directory");
println!("Ready to handle requests based on your YAML definitions.");
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()
}
}
+150
View File
@@ -0,0 +1,150 @@
use axum::{
body::Body,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use axum_extra::extract::Multipart;
use chrono::Local;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use uuid::Uuid;
use crate::handler::AppState;
/// 文件上传结果
#[derive(Debug, serde::Serialize)]
pub struct UploadResult {
pub success: bool,
pub files: Vec<FileInfo>,
pub message: String,
}
/// 单个文件信息
#[derive(Debug, serde::Serialize)]
pub struct FileInfo {
pub original_name: String,
pub stored_name: String,
pub path: String,
pub size: usize,
pub content_type: Option<String>,
}
impl UploadResult {
pub fn success(files: Vec<FileInfo>) -> Self {
UploadResult {
success: true,
files,
message: "Files uploaded successfully".to_string(),
}
}
pub fn error(message: String) -> Self {
UploadResult {
success: false,
files: vec![],
message,
}
}
}
impl IntoResponse for UploadResult {
fn into_response(self) -> Response {
let status = if self.success {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
let body = serde_json::to_string(&self).unwrap_or_else(|_| {
json!({
"success": false,
"files": [],
"message": "Failed to serialize response"
})
.to_string()
});
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap()
}
}
/// 处理文件上传
pub async fn upload_handler(
State(_state): State<Arc<AppState>>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut uploaded_files = Vec::new();
// 确保 storage 目录存在
let storage_root = PathBuf::from("storage");
if let Err(e) = fs::create_dir_all(&storage_root).await {
return UploadResult::error(format!("Failed to create storage directory: {}", e));
}
// 创建按日期分类的子目录
let date_dir = Local::now().format("%Y-%m-%d").to_string();
let upload_dir = storage_root.join(&date_dir);
if let Err(e) = fs::create_dir_all(&upload_dir).await {
return UploadResult::error(format!("Failed to create upload directory: {}", e));
}
// 处理每个上传的字段
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
// 获取文件名
let original_name = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
// 获取内容类型
let content_type = field.content_type().map(|s| s.to_string());
// 读取文件数据
let data = match field.bytes().await {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to read file data: {}", e);
continue;
}
};
// 生成唯一文件名,保留原始扩展名
let extension = std::path::Path::new(&original_name)
.extension()
.and_then(|s| s.to_str())
.map(|s| format!(".{}", s))
.unwrap_or_default();
let stored_name = format!("{}{}", Uuid::new_v4(), extension);
let file_path = upload_dir.join(&stored_name);
// 保存文件
match fs::write(&file_path, &data).await {
Ok(_) => {
uploaded_files.push(FileInfo {
original_name: original_name.clone(),
stored_name: stored_name.clone(),
path: format!("storage/{}/{}", date_dir, stored_name),
size: data.len(),
content_type,
});
}
Err(e) => {
eprintln!("Failed to save file {}: {}", original_name, e);
}
}
}
if uploaded_files.is_empty() {
UploadResult::error("No files were uploaded".to_string())
} else {
UploadResult::success(uploaded_files)
}
}
+140
View File
@@ -0,0 +1,140 @@
use mock_server::config::{MockResponse, MockSettings};
#[test]
fn test_mock_response_file_protocol() {
// 测试 1: 文件协议识别
let response = MockResponse {
status: 200,
headers: None,
body: "file://./data/file.txt".to_string(),
};
assert!(response.is_file_protocol());
// 测试 2: 非文件协议识别
let inline_response = MockResponse {
status: 200,
headers: None,
body: "{\"key\": \"value\"}".to_string(),
};
assert!(!inline_response.is_file_protocol());
}
#[test]
fn test_mock_response_file_path_extraction() {
// 测试 1: 提取文件路径
let response = MockResponse {
status: 200,
headers: None,
body: "file://./data/file.txt".to_string(),
};
assert_eq!(response.get_file_path(), Some("./data/file.txt"));
// 测试 2: 提取相对路径
let response2 = MockResponse {
status: 200,
headers: None,
body: "file:///absolute/path/file.pdf".to_string(),
};
assert_eq!(response2.get_file_path(), Some("/absolute/path/file.pdf"));
// 测试 3: 非文件协议返回 None
let inline_response = MockResponse {
status: 200,
headers: None,
body: "inline content".to_string(),
};
assert_eq!(inline_response.get_file_path(), None);
}
#[test]
fn test_mock_settings_delay() {
// 测试 1: 有延迟设置
let settings = MockSettings {
delay_ms: Some(1000),
};
assert_eq!(settings.delay_ms, Some(1000));
// 测试 2: 无延迟设置
let settings = MockSettings { delay_ms: None };
assert_eq!(settings.delay_ms, None);
}
#[test]
fn test_mock_response_various_body_formats() {
// 测试 1: JSON 格式
let json_response = MockResponse {
status: 200,
headers: None,
body: r#"{"code": 0, "message": "success"}"#.to_string(),
};
assert_eq!(json_response.status, 200);
assert!(!json_response.is_file_protocol());
// 测试 2: HTML 格式
let html_response = MockResponse {
status: 200,
headers: Some(vec![("Content-Type".to_string(), "text/html".to_string())]
.into_iter()
.collect()),
body: "<html><body>Hello</body></html>".to_string(),
};
assert_eq!(html_response.status, 200);
// 测试 3: 纯文本
let text_response = MockResponse {
status: 200,
headers: None,
body: "Plain text response".to_string(),
};
assert_eq!(text_response.body, "Plain text response");
}
#[test]
fn test_mock_response_with_headers() {
let mut headers = std::collections::HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
headers.insert("Authorization".to_string(), "Bearer token".to_string());
let response = MockResponse {
status: 200,
headers: Some(headers),
body: "response body".to_string(),
};
assert_eq!(response.status, 200);
assert!(response.headers.is_some());
let response_headers = response.headers.unwrap();
assert_eq!(response_headers.get("Content-Type"), Some(&"application/json".to_string()));
assert_eq!(response_headers.get("Authorization"), Some(&"Bearer token".to_string()));
}
#[test]
fn test_mock_response_edge_cases() {
// 测试 1: 空文件路径
let empty_file_response = MockResponse {
status: 200,
headers: None,
body: "file://".to_string(),
};
assert!(empty_file_response.is_file_protocol());
assert_eq!(empty_file_response.get_file_path(), Some(""));
// 测试 2: 空响应体
let empty_response = MockResponse {
status: 200,
headers: None,
body: "".to_string(),
};
assert_eq!(empty_response.body, "");
// 测试 3: 包含特殊字符的文件路径
let special_path_response = MockResponse {
status: 200,
headers: None,
body: "file://./data/file with spaces.txt".to_string(),
};
assert_eq!(
special_path_response.get_file_path(),
Some("./data/file with spaces.txt")
);
}
+441
View File
@@ -0,0 +1,441 @@
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use axum::{
body::Body,
extract::Request,
http::{HeaderMap, HeaderValue, Method, StatusCode},
response::IntoResponse,
};
use mock_server::config::MockSource;
use mock_server::handler::{AppState, mock_handler};
use mock_server::router::MockRouter;
use tempfile::tempdir;
use std::fs::{self, File};
use std::io::Write;
use tokio::runtime::Runtime;
/// 创建带有 Mock 规则的 AppState
fn create_app_state_with_rules(rules: Vec<&str>) -> Arc<AppState> {
let temp_dir = tempdir().expect("无法创建临时目录");
let root_path = temp_dir.path();
let mut all_rules = Vec::new();
for (i, yaml_content) in rules.iter().enumerate() {
let file_path = root_path.join(format!("rule_{}.yaml", i));
let mut file = File::create(&file_path).unwrap();
writeln!(file, "{}", yaml_content).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
if let Ok(source) = serde_yaml::from_str::<MockSource>(&content) {
all_rules.extend(source.flatten());
}
}
let mut index = HashMap::new();
for rule in all_rules {
let key = rule.request.path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string();
index.entry(key).or_insert_with(Vec::new).push(rule);
}
let router = MockRouter::new(index);
Arc::new(AppState { router: RwLock::new(router) })
}
#[test]
fn test_handler_basic_request() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "basic_test"
request:
method: "GET"
path: "/api/test"
response:
status: 200
body: "test response"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/test")
.body(Body::empty())
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}
#[test]
fn test_handler_with_query_params() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "query_test"
request:
method: "GET"
path: "/api/search"
query_params:
q: "rust"
response:
status: 200
body: "search results"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/search?q=rust")
.body(Body::empty())
.unwrap();
let mut query_params = HashMap::new();
query_params.insert("q".to_string(), "rust".to_string());
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(query_params),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}
#[test]
fn test_handler_with_headers() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "header_test"
request:
method: "POST"
path: "/api/data"
headers:
Authorization: "Bearer token123"
Content-Type: "application/json"
response:
status: 200
body: "data response"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::POST)
.uri("/api/data")
.body(Body::empty())
.unwrap();
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("Bearer token123"));
headers.insert("content-type", HeaderValue::from_static("application/json"));
let response = mock_handler(
axum::extract::State(state),
Method::POST,
headers,
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}
#[test]
fn test_handler_with_body() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "body_test"
request:
method: "POST"
path: "/api/users"
body:
name: "John"
age: 30
response:
status: 201
body: "user created"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let body_json = serde_json::json!({ "name": "John", "age": 30 });
let body_bytes = serde_json::to_vec(&body_json).unwrap();
let request = Request::builder()
.method(Method::POST)
.uri("/api/users")
.header("content-type", "application/json")
.body(Body::from(body_bytes))
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::POST,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::CREATED);
});
}
#[test]
fn test_handler_no_match() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "existing_rule"
request:
method: "GET"
path: "/api/existing"
response:
status: 200
body: "exists"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/nonexistent")
.body(Body::empty())
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
});
}
#[test]
fn test_handler_response_headers() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "headers_test"
request:
method: "GET"
path: "/api/with-headers"
response:
status: 200
headers:
Content-Type: "application/json"
Cache-Control: "no-cache"
body: '{"key": "value"}'
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/with-headers")
.body(Body::empty())
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/json"
);
assert_eq!(
response.headers().get("cache-control").unwrap(),
"no-cache"
);
});
}
#[test]
fn test_handler_different_status_codes() {
let rt = Runtime::new().unwrap();
let yaml1 = r#"
id: "success"
request:
method: "GET"
path: "/api/success"
response:
status: 200
body: "ok"
"#;
let yaml2 = r#"
id: "created"
request:
method: "POST"
path: "/api/created"
response:
status: 201
body: "created"
"#;
let yaml3 = r#"
id: "error"
request:
method: "GET"
path: "/api/error"
response:
status: 500
body: "server error"
"#;
let state = create_app_state_with_rules(vec![yaml1, yaml2, yaml3]);
rt.block_on(async {
// 测试 200
let request1 = Request::builder()
.method(Method::GET)
.uri("/api/success")
.body(Body::empty())
.unwrap();
let response1 = mock_handler(
axum::extract::State(state.clone()),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request1,
)
.await
.into_response();
assert_eq!(response1.status(), StatusCode::OK);
// 测试 201
let request2 = Request::builder()
.method(Method::POST)
.uri("/api/created")
.body(Body::empty())
.unwrap();
let response2 = mock_handler(
axum::extract::State(state.clone()),
Method::POST,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request2,
)
.await
.into_response();
assert_eq!(response2.status(), StatusCode::CREATED);
// 测试 500
let request3 = Request::builder()
.method(Method::GET)
.uri("/api/error")
.body(Body::empty())
.unwrap();
let response3 = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request3,
)
.await
.into_response();
assert_eq!(response3.status(), StatusCode::INTERNAL_SERVER_ERROR);
});
}
#[test]
fn test_handler_complex_matching() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "complex"
request:
method: "PUT"
path: "/api/users/123"
query_params:
update: "true"
headers:
Authorization: "Bearer secret"
body:
name: "Updated Name"
response:
status: 200
body: "updated"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let body_json = serde_json::json!({ "name": "Updated Name" });
let body_bytes = serde_json::to_vec(&body_json).unwrap();
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
let request = Request::builder()
.method(Method::PUT)
.uri("/api/users/123")
.header("content-type", "application/json")
.header("authorization", "Bearer secret")
.body(Body::from(body_bytes))
.unwrap();
let mut query_params = HashMap::new();
query_params.insert("update".to_string(), "true".to_string());
let response = mock_handler(
axum::extract::State(state),
Method::PUT,
headers,
axum::extract::Query(query_params),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}
+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::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");
}
+354
View File
@@ -0,0 +1,354 @@
use mock_server::config::MockSource;
use mock_server::router::MockRouter;
use std::collections::HashMap;
use serde_json::json;
#[test]
fn test_router_basic_method_path_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "basic_test"
request:
method: "GET"
path: "/api/test"
response: { status: 200, body: "ok" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试精确匹配
let matched = router.match_rule("GET", "/api/test", &HashMap::new(), &HashMap::new(), &None);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id, "basic_test");
// 测试方法不匹配
let not_matched = router.match_rule("POST", "/api/test", &HashMap::new(), &HashMap::new(), &None);
assert!(not_matched.is_none());
// 测试路径不匹配
let not_matched = router.match_rule("GET", "/api/other", &HashMap::new(), &HashMap::new(), &None);
assert!(not_matched.is_none());
}
#[test]
fn test_router_case_insensitive_method() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "case_test"
request:
method: "GET"
path: "/api/data"
response: { status: 200, body: "data" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试不同大小写的方法匹配
assert!(router.match_rule("get", "/api/data", &HashMap::new(), &HashMap::new(), &None).is_some());
assert!(router.match_rule("GET", "/api/data", &HashMap::new(), &HashMap::new(), &None).is_some());
assert!(router.match_rule("Get", "/api/data", &HashMap::new(), &HashMap::new(), &None).is_some());
}
#[test]
fn test_router_path_trailing_slash() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "slash_test"
request:
method: "POST"
path: "/api/users"
response: { status: 200, body: "created" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试带和不带斜杠的路径匹配
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &None).is_some());
assert!(router.match_rule("POST", "/api/users/", &HashMap::new(), &HashMap::new(), &None).is_some());
}
#[test]
fn test_router_query_params_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "query_test"
request:
method: "GET"
path: "/api/search"
query_params:
q: "rust"
limit: "10"
response: { status: 200, body: "results" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试查询参数完全匹配
let mut queries = HashMap::new();
queries.insert("q".to_string(), "rust".to_string());
queries.insert("limit".to_string(), "10".to_string());
queries.insert("extra".to_string(), "value".to_string()); // 额外参数应该忽略
assert!(router.match_rule("GET", "/api/search", &queries, &HashMap::new(), &None).is_some());
// 测试查询参数不匹配
let mut wrong_queries = HashMap::new();
wrong_queries.insert("q".to_string(), "python".to_string());
assert!(router.match_rule("GET", "/api/search", &wrong_queries, &HashMap::new(), &None).is_none());
}
#[test]
fn test_router_headers_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "header_test"
request:
method: "GET"
path: "/api/data"
headers:
Authorization: "Bearer token123"
Content-Type: "application/json"
response: { status: 200, body: "data" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试头部完全匹配(大小写不敏感)
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer token123".to_string());
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("extra-header".to_string(), "value".to_string());
assert!(router.match_rule("GET", "/api/data", &HashMap::new(), &headers, &None).is_some());
// 测试头部不匹配
let mut wrong_headers = HashMap::new();
wrong_headers.insert("authorization".to_string(), "Bearer wrong".to_string());
assert!(router.match_rule("GET", "/api/data", &HashMap::new(), &wrong_headers, &None).is_none());
}
#[test]
fn test_router_body_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "body_test"
request:
method: "POST"
path: "/api/users"
body:
name: "John"
age: 30
response: { status: 200, body: "created" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试 Body 完全匹配
let body = Some(json!({ "name": "John", "age": 30 }));
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &body).is_some());
// 测试 Body 不匹配
let wrong_body = Some(json!({ "name": "Jane", "age": 25 }));
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &wrong_body).is_none());
// 测试请求无 Body
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &None).is_none());
}
#[test]
fn test_router_body_string_to_json() {
let mut index = HashMap::new();
// YAML 中 body 是字符串,但内容是 JSON
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "str_body_test"
request:
method: "POST"
path: "/api/login"
body: '{"username": "admin", "password": "secret"}'
response: { status: 200, body: "token" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 请求中 body 是 JSON 对象,应该匹配
let body = Some(json!({ "username": "admin", "password": "secret" }));
assert!(router.match_rule("POST", "/api/login", &HashMap::new(), &HashMap::new(), &body).is_some());
}
#[test]
fn test_router_multiple_rules_same_segment() {
let mut index = HashMap::new();
let rule1 = serde_yaml::from_str::<MockSource>(
r#"
id: "rule1"
request:
method: "GET"
path: "/api/users"
response: { status: 200, body: "users" }
"#,
)
.unwrap()
.flatten();
let rule2 = serde_yaml::from_str::<MockSource>(
r#"
id: "rule2"
request:
method: "POST"
path: "/api/users"
response: { status: 201, body: "created" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule1[0].clone(), rule2[0].clone()]);
let router = MockRouter::new(index);
// 测试不同方法匹配不同规则
let get_matched = router.match_rule("GET", "/api/users", &HashMap::new(), &HashMap::new(), &None);
assert!(get_matched.is_some());
assert_eq!(get_matched.unwrap().id, "rule1");
let post_matched = router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &None);
assert!(post_matched.is_some());
assert_eq!(post_matched.unwrap().id, "rule2");
}
#[test]
fn test_router_complex_matching_scenario() {
let mut index = HashMap::new();
let complex_rule = serde_yaml::from_str::<MockSource>(
r#"
id: "complex_rule"
request:
method: "PUT"
path: "/api/users/123"
query_params:
update: "true"
headers:
Authorization: "Bearer token456"
Content-Type: "application/json"
body:
name: "Updated"
response: { status: 200, body: "updated" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![complex_rule[0].clone()]);
let router = MockRouter::new(index);
// 测试完全匹配所有条件
let mut queries = HashMap::new();
queries.insert("update".to_string(), "true".to_string());
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer token456".to_string());
headers.insert("content-type".to_string(), "application/json".to_string());
let body = Some(json!({ "name": "Updated" }));
let matched = router.match_rule("PUT", "/api/users/123", &queries, &headers, &body);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id, "complex_rule");
// 测试缺少任何条件都不匹配
let no_body_matched = router.match_rule("PUT", "/api/users/123", &queries, &headers, &None);
assert!(no_body_matched.is_none());
}
#[test]
fn test_router_root_path_handling() {
let mut index = HashMap::new();
let root_rule = serde_yaml::from_str::<MockSource>(
r#"
id: "root_rule"
request:
method: "GET"
path: "/"
response: { status: 200, body: "root" }
"#,
)
.unwrap()
.flatten();
// 根路径会被提取为空字符串 key
index.insert("".to_string(), vec![root_rule[0].clone()]);
let router = MockRouter::new(index);
// 测试根路径匹配
assert!(router.match_rule("GET", "/", &HashMap::new(), &HashMap::new(), &None).is_some());
}
#[test]
fn test_router_nonexistent_key() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "test_rule"
request:
method: "GET"
path: "/api/data"
response: { status: 200, body: "data" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试请求不同段路径,应该不匹配
assert!(router.match_rule("GET", "/health", &HashMap::new(), &HashMap::new(), &None).is_none());
assert!(router.match_rule("GET", "/v2/data", &HashMap::new(), &HashMap::new(), &None).is_none());
}
+246
View File
@@ -0,0 +1,246 @@
use axum::{
http::StatusCode,
response::IntoResponse,
};
use mock_server::upload::{UploadResult, FileInfo};
use tempfile::TempDir;
use tokio::runtime::Runtime;
/// 创建模拟的 multipart 数据
fn create_multipart_body(boundary: &str, files: Vec<(&str, &str, &[u8])>) -> Vec<u8> {
let mut body = Vec::new();
for (field_name, filename, content) in files {
body.extend_from_slice(
format!(
"--{}\r\nContent-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n\r\n",
boundary, field_name, filename
)
.as_bytes(),
);
body.extend_from_slice(content);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
body
}
#[test]
fn test_upload_result_success() {
let file_info = FileInfo {
original_name: "test.txt".to_string(),
stored_name: "uuid-test.txt".to_string(),
path: "storage/2026-03-19/uuid-test.txt".to_string(),
size: 1024,
content_type: Some("text/plain".to_string()),
};
let result = UploadResult::success(vec![file_info]);
// 验证序列化
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("test.txt"));
assert!(json.contains("success"));
assert!(json.contains("1024"));
}
#[test]
fn test_upload_result_error() {
let result = UploadResult::error("Test error message".to_string());
assert!(!result.success);
assert_eq!(result.message, "Test error message");
assert!(result.files.is_empty());
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("Test error message"));
}
#[test]
fn test_file_info_serialization() {
let file_info = FileInfo {
original_name: "document.pdf".to_string(),
stored_name: "550e8400-e29b-41d4-a716-446655440000.pdf".to_string(),
path: "storage/2026-03-19/550e8400-e29b-41d4-a716-446655440000.pdf".to_string(),
size: 2048,
content_type: Some("application/pdf".to_string()),
};
let json = serde_json::to_string(&file_info).unwrap();
assert!(json.contains("document.pdf"));
assert!(json.contains("2048"));
assert!(json.contains("application/pdf"));
}
#[test]
fn test_upload_result_into_response() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let result = UploadResult::success(vec![]);
let response = result.into_response();
assert_eq!(response.status(), StatusCode::OK);
let result_error = UploadResult::error("Error".to_string());
let response_error = result_error.into_response();
assert_eq!(response_error.status(), StatusCode::BAD_REQUEST);
});
}
#[test]
fn test_storage_directory_creation() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let storage_path = temp_dir.path().join("storage");
// 测试目录创建
tokio::fs::create_dir_all(&storage_path).await.unwrap();
assert!(storage_path.exists());
// 测试日期目录创建
let date_dir_name = chrono::Local::now().format("%Y-%m-%d").to_string();
let date_dir = storage_path.join(&date_dir_name);
tokio::fs::create_dir_all(&date_dir).await.unwrap();
assert!(date_dir.exists());
});
}
#[test]
fn test_file_save_and_read() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file.txt");
let content = b"Test file content";
// 写入文件
tokio::fs::write(&file_path, content).await.unwrap();
assert!(file_path.exists());
// 读取文件
let read_content = tokio::fs::read(&file_path).await.unwrap();
assert_eq!(read_content, content);
});
}
#[test]
fn test_multipart_body_creation() {
let boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW";
let content = b"Hello, World!";
let body = create_multipart_body(boundary, vec![("file", "test.txt", content)]);
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains(boundary));
assert!(body_str.contains("test.txt"));
assert!(body_str.contains("Hello, World!"));
}
#[test]
fn test_multiple_files_multipart() {
let boundary = "----WebKitFormBoundary";
let files = vec![
("file1", "a.txt", b"content a" as &[u8]),
("file2", "b.txt", b"content b" as &[u8]),
];
let body = create_multipart_body(boundary, files);
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains("a.txt"));
assert!(body_str.contains("b.txt"));
assert!(body_str.contains("content a"));
assert!(body_str.contains("content b"));
}
#[test]
fn test_uuid_generation() {
use uuid::Uuid;
let uuid1 = Uuid::new_v4();
let uuid2 = Uuid::new_v4();
// UUID 应该是唯一的
assert_ne!(uuid1, uuid2);
// UUID 应该是有效的
assert!(!uuid1.is_nil());
assert!(!uuid2.is_nil());
}
#[test]
fn test_date_directory_format() {
let date_dir_name = chrono::Local::now().format("%Y-%m-%d").to_string();
// 验证日期格式
assert!(date_dir_name.contains('-'));
let parts: Vec<&str> = date_dir_name.split('-').collect();
assert_eq!(parts.len(), 3);
}
#[test]
fn test_file_extension_extraction() {
let test_cases = vec![
("test.txt", ".txt"),
("document.pdf", ".pdf"),
("image.png", ".png"),
("archive.tar.gz", ".gz"),
("noextension", ""),
];
for (filename, expected_ext) in test_cases {
let extension = std::path::Path::new(filename)
.extension()
.and_then(|s| s.to_str())
.map(|s| format!(".{}", s))
.unwrap_or_default();
assert_eq!(extension, expected_ext);
}
}
#[test]
fn test_large_file_content() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("large_file.bin");
// 创建 1MB 的数据
let large_content = vec![0u8; 1024 * 1024];
tokio::fs::write(&file_path, &large_content).await.unwrap();
let file_size = tokio::fs::metadata(&file_path).await.unwrap().len();
assert_eq!(file_size, 1024 * 1024);
});
}
#[test]
fn test_binary_file_content() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("binary.bin");
// 创建二进制数据
let binary_data: Vec<u8> = (0..=255).collect();
tokio::fs::write(&file_path, &binary_data).await.unwrap();
let read_data = tokio::fs::read(&file_path).await.unwrap();
assert_eq!(read_data.len(), 256);
assert_eq!(read_data, binary_data);
});
}