6 Commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 22:21:33 +08:00
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 <noreply@anthropic.com>
2026-03-19 22:20:20 +08:00
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 <noreply@anthropic.com>
2026-03-19 22:11:42 +08:00
5f3269bad5 feat: 增加request对body字段的支持
- 实现body字段对json语法,yaml语法的支持的支持
- 新增测试用例login_out.yaml
2025-12-28 21:54:34 +08:00
23 changed files with 2421 additions and 101 deletions

76
.claude/CLAUDE.md Normal file
View File

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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@
# 日志文件 # 日志文件
logs/ logs/
*.log *.log
# 上传文件存储目录
storage/

507
Cargo.lock generated
View File

@@ -2,12 +2,33 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.8" version = "0.8.8"
@@ -60,6 +81,30 @@ dependencies = [
"tracing", "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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -72,18 +117,62 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" 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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -106,6 +195,18 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -184,16 +285,44 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 5.3.0",
"wasip2", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -276,6 +405,36 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.12.1" version = "2.12.1"
@@ -283,7 +442,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.16.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -312,6 +473,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 = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]] [[package]]
name = "kqueue" name = "kqueue"
version = "1.1.1" version = "1.1.1"
@@ -338,6 +509,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.178" version = "0.2.178"
@@ -400,6 +577,8 @@ name = "mock_server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"axum-extra",
"chrono",
"futures-util", "futures-util",
"notify", "notify",
"notify-debouncer-mini", "notify-debouncer-mini",
@@ -411,9 +590,27 @@ dependencies = [
"tokio-util", "tokio-util",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
"walkdir", "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]] [[package]]
name = "notify" name = "notify"
version = "8.2.0" version = "8.2.0"
@@ -459,6 +656,15 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@@ -506,6 +712,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.103" version = "1.0.103"
@@ -530,6 +746,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -552,6 +774,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.21" version = "1.0.21"
@@ -573,6 +801,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -661,6 +895,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.7" version = "1.4.7"
@@ -692,6 +932,12 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "syn" name = "syn"
version = "2.0.111" version = "2.0.111"
@@ -716,7 +962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -864,18 +1110,42 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" 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 = "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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -898,7 +1168,95 @@ version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [ dependencies = [
"wit-bindgen", "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]] [[package]]
@@ -910,12 +1268,65 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"
@@ -1005,6 +1416,94 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 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]] [[package]]
name = "zmij" name = "zmij"
version = "0.1.8" version = "0.1.8"

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
# 核心 Web 框架 # 核心 Web 框架
axum = "0.8.8" axum = "0.8.8"
axum-extra = { version = "0.10", features = ["multipart"] }
# 异步运行时 # 异步运行时
tokio={version = "1.48.0",features = ["full"]} tokio={version = "1.48.0",features = ["full"]}
# 异步文件操作与流处理工具 # 异步文件操作与流处理工具
@@ -20,6 +21,12 @@ 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="0.1.44"
tracing-subscriber = "0.3.22" tracing-subscriber = "0.3.22"

View File

@@ -1,11 +1,77 @@
# mock-server # 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
mocks/example-api.yaml Normal file
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

View File

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

View File

@@ -0,0 +1,50 @@
- id: "auth_login_out_001"
request:
method: "POST"
path: "/api/v1/auth/login_out"
# 必须包含此 Header 才会匹配
headers:
Content-Type: "application/json"
Authorization: "111"
host: "127.0.0.1:8080"
body:
type: true
response:
status: 200
headers:
Content-Type: "application/json"
X-Mock-Engine: "Rust-Gemini-v1.2"
# 直接内联 JSON 字符串
body: >
{
"code": 0,
"data": "退出成功",
"msg": "success"
}
settings:
delay_ms: 200 # 模拟真实网络延迟
- id: "auth_login_out_002"
request:
method: "POST"
path: "/api/v1/auth/login_out"
# 必须包含此 Header 才会匹配
headers:
Content-Type: "application/json"
Authorization: "111"
host: "127.0.0.1:8080"
body:
type: false
response:
status: 200
headers:
Content-Type: "application/json"
X-Mock-Engine: "Rust-Gemini-v1.2"
# 直接内联 JSON 字符串
body: >
{
"code": 1,
"data": "退出失败",
"msg": "success"
}
settings:
delay_ms: 200 # 模拟真实网络延迟

View File

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

View File

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

View File

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

View File

@@ -3,3 +3,4 @@ pub mod config;
pub mod loader; pub mod loader;
pub mod router; pub mod router;
pub mod handler; pub mod handler;
pub mod upload;

View File

@@ -2,30 +2,42 @@ use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use axum::{routing::any, Router}; use axum::{routing::{any, post}, Router};
use notify_debouncer_mini::{new_debouncer, notify::*}; use notify_debouncer_mini::{new_debouncer, notify::*};
use mock_server::loader::MockLoader; use mock_server::loader::MockLoader;
use mock_server::router::MockRouter; use mock_server::router::MockRouter;
use mock_server::handler::{mock_handler, AppState}; use mock_server::handler::{mock_handler, AppState};
use mock_server::upload::upload_handler;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// 1. 确保 mocks 目录存在
let mocks_dir = Path::new("./mocks"); let mocks_dir = Path::new("./mocks");
if !mocks_dir.exists() { if !mocks_dir.exists() {
println!("Warning: 'mocks/' directory not found. Creating it...");
std::fs::create_dir_all(mocks_dir).unwrap(); std::fs::create_dir_all(mocks_dir).unwrap();
} }
// 1. 初始加载 // 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..."); println!("Scanning mocks directory...");
let index = MockLoader::load_all_from_dir(mocks_dir); let index = MockLoader::load_all_from_dir(mocks_dir);
// 4. 构建共享状态(使用 RwLock 支持热重载)
let shared_state = Arc::new(AppState { let shared_state = Arc::new(AppState {
router: RwLock::new(MockRouter::new(index)), router: RwLock::new(MockRouter::new(index)),
}); });
// 2. 设置热加载监听器 // 5. 设置热加载监听器
let state_for_watcher = shared_state.clone(); let state_for_watcher = shared_state.clone();
let watch_path = mocks_dir.to_path_buf(); let watch_path = mocks_dir.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
@@ -51,13 +63,20 @@ async fn main() {
} }
}); });
// 3. 配置 Axum 路由 // 6. 配置 Axum 路由
// 文件上传路由POST /api/upload
// 其他所有请求由 mock_handler 处理
let app = Router::new() let app = Router::new()
.route("/api/upload", post(upload_handler))
.fallback(any(mock_handler)) .fallback(any(mock_handler))
.with_state(shared_state); .with_state(shared_state);
// 7. 启动服务
let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
println!("🚀 Server running at http://{}", addr); 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(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();

View File

@@ -18,6 +18,7 @@ impl MockRouter {
path: &str, path: &str,
queries: &HashMap<String, String>, queries: &HashMap<String, String>,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 1: 增加参数
) -> Option<&MockRule> { ) -> Option<&MockRule> {
// 1. 提取请求路径的首段作为索引 Key // 1. 提取请求路径的首段作为索引 Key
let key = self.extract_first_segment(path); let key = self.extract_first_segment(path);
@@ -26,7 +27,7 @@ impl MockRouter {
if let Some(rules) = self.index.get(&key) { if let Some(rules) = self.index.get(&key) {
// 3. 在候选集中进行线性深度匹配 // 3. 在候选集中进行线性深度匹配
for rule in rules { for rule in rules {
if self.is_match(rule, method, path, queries, headers) { if self.is_match(rule, method, path, queries, headers,incoming_body) {
return Some(rule); return Some(rule);
} }
} }
@@ -43,6 +44,7 @@ impl MockRouter {
path: &str, path: &str,
queries: &HashMap<String, String>, queries: &HashMap<String, String>,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 3: 增加参数
) -> bool { ) -> bool {
// A. 基础校验Method 和 Path 必须完全一致 (忽略末尾斜杠) // A. 基础校验Method 和 Path 必须完全一致 (忽略末尾斜杠)
if rule.request.method.to_uppercase() != method.to_uppercase() { if rule.request.method.to_uppercase() != method.to_uppercase() {
@@ -78,6 +80,27 @@ impl MockRouter {
} }
} }
} }
// D. 智能 Body 全量比对逻辑
if let Some(ref required_val) = rule.request.body {
match incoming_body {
Some(actual_val) => {
// 实现你的想法:尝试将 YAML 中的 String 转换为 Object 再对比
let final_required = if let Some(s) = required_val.as_str() {
// 如果能解析成 JSON就用解析后的对象否则用原始字符串 Value
serde_json::from_str::<serde_json::Value>(s).unwrap_or_else(|_| required_val.clone())
} else {
required_val.clone()
};
// 执行全量相等比对
if final_required != *actual_val {
println!("DEBUG: [ID:{}] Body Mismatch", rule.id);
return false;
}
}
None => return false, // YAML 要求有 Body 但请求为空
}
}
true true
} }

150
src/upload.rs Normal file
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
tests/config_test.rs Normal file
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
tests/handler_test.rs Normal file
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);
});
}

View File

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

354
tests/router_test.rs Normal file
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
tests/upload_test.rs Normal file
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);
});
}