5 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
49 changed files with 2071 additions and 2855 deletions

View File

@@ -1,26 +1,26 @@
--- ---
paths: paths:
- "mocks/**/*.json" - "mocks/**/*.{yml,yaml}"
--- ---
## JSON 配置规范 ## YAML 配置规范
AI 在生成 Mock 规则时必须遵循以下格式。 AI 在生成 Mock 规则时必须遵循以下格式。
### 字段说明 ### 字段说明
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|----------------------|--------|:--:|----------------------------------| |------|------|:----:|------|
| name | string | 是 | 规则唯一标识 | | id | string | 是 | 规则唯一标识 |
| request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) | | request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) |
| request.path | string | 是 | 请求路径,精确匹配 | | request.path | string | 是 | 请求路径,精确匹配 |
| request.query_params | object | 否 | 查询参数匹配 | | request.query_params | object | 否 | 查询参数匹配 |
| request.headers | object | 否 | 请求头匹配(大小写不敏感) | | request.headers | object | 否 | 请求头匹配(大小写不敏感) |
| request.body | any | 否 | 请求体匹配,支持 JSON 对象或字符串 | | request.body | any | 否 | 请求体匹配,支持 JSON 对象或字符串 |
| response.status | number | 是 | HTTP 状态码 | | response.status | number | 是 | HTTP 状态码 |
| response.headers | object | 否 | 响应头 | | response.headers | object | 否 | 响应头 |
| response.body | string | 是 | 响应体,支持 file:// 前缀 | | response.body | string | 是 | 响应体,支持 file:// 前缀 |
| settings.delay_ms | number | 否 | 延迟响应(毫秒) | | settings.delay_ms | number | 否 | 延迟响应(毫秒) |
### 匹配规则 ### 匹配规则
@@ -30,91 +30,58 @@ AI 在生成 Mock 规则时必须遵循以下格式。
### 配置示例 ### 配置示例
**基础示例** **单接口模式**
```json ```yaml
{ id: "login"
"name": "login", request:
"request": { method: "POST"
"method": "POST", path: "/api/login"
"path": "/v1/auth/login", body: { "username": "test" }
"body": { response:
"username": "user001", status: 200
"password": "password123" body: '{"token": "xxx"}'
} settings:
}, delay_ms: 100
"response": { ```
"status": 200,
"headers": { **多接口模式(数组):**
"Content-Type": "application/json"
}, ```yaml
"body": "{\"token\": \"xxx\"}" - id: "get-users"
}, request: { method: "GET", path: "/api/users" }
"settings": { response: { status: 200, body: '{"users": []}' }
"delay_ms": 100
} - id: "create-user"
} request: { method: "POST", path: "/api/users" }
response: { status: 201, body: '{"id": 1}' }
``` ```
**带查询参数和请求头:** **带查询参数和请求头:**
```json ```yaml
{ id: "search-users"
"name": "search_users", request:
"request": { method: "GET"
"method": "GET", path: "/api/users"
"path": "/v1/users", query_params: { "role": "admin" }
"query_params": { headers: { "Authorization": "Bearer token" }
"role": "admin" response:
}, status: 200
"headers": { headers: { "X-Total-Count": "100" }
"Authorization": "Bearer token" body: '{"users": []}'
}
},
"response": {
"status": 200,
"headers": {
"X-Total-Count": "100"
},
"body": "{\"users\": []}"
}
}
``` ```
**文件响应:** **文件响应:**
```json ```yaml
{ id: "download-pdf"
"name": "download_pdf", request:
"request": { method: "GET"
"method": "GET", path: "/api/download"
"path": "/v1/download" response:
}, status: 200
"response": { body: "file://./data/large-file.pdf"
"status": 200,
"headers": {
"Content-Type": "application/pdf"
},
"body": "file://./storage/data/report.pdf"
}
}
```
**字符串格式 Body兼容格式**
```json
{
"name": "string_body_example",
"request": {
"method": "POST",
"path": "/v1/api/test",
"body": "{\"username\":\"user001\",\"password\":\"password123\"}"
},
"response": {
"status": 200,
"body": "{\"success\": true}"
}
}
``` ```
> `file://` 支持两种路径: > `file://` 支持两种路径:

View File

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

3
.gitignore vendored
View File

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

522
Cargo.lock generated
View File

@@ -2,15 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -26,17 +17,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -102,10 +82,28 @@ dependencies = [
] ]
[[package]] [[package]]
name = "base64" name = "axum-extra"
version = "0.22.1" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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"
@@ -133,9 +131,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.58" version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -156,7 +154,6 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link",
] ]
@@ -168,70 +165,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "crossbeam-channel" name = "encoding_rs"
version = "0.5.15" version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [ dependencies = [
"crossbeam-utils", "cfg-if",
] ]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -260,12 +201,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -290,21 +225,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -312,7 +232,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -321,23 +240,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.31" version = "0.3.31"
@@ -367,13 +269,9 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro", "futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab", "slab",
@@ -537,17 +435,11 @@ 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 = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.1", "hashbrown 0.16.1",
@@ -650,15 +542,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -694,22 +577,40 @@ 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",
"rmcp",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tracing", "tracing",
"tracing-appender",
"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"
@@ -755,12 +656,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-conv"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -799,12 +694,6 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "pastey"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -823,21 +712,6 @@ 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 = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -878,35 +752,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -916,87 +761,6 @@ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
] ]
[[package]]
name = "ref-cast"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rmcp"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5df440eaa43f8573491ed4a5899719b6d29099500774abba12214a095a4083ed"
dependencies = [
"async-trait",
"base64",
"bytes",
"chrono",
"futures",
"http",
"http-body",
"http-body-util",
"pastey",
"pin-project-lite",
"rand",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
"sse-stream",
"thiserror",
"tokio",
"tokio-stream",
"tokio-util",
"tower-service",
"tracing",
"uuid",
]
[[package]]
name = "rmcp-macros"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef03779cccab8337dd8617c53fce5c98ec21794febc397531555472ca28f8c3"
dependencies = [
"darling",
"proc-macro2",
"quote",
"serde_json",
"syn",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.3" version = "1.1.3"
@@ -1031,32 +795,6 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schemars"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -1099,17 +837,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.147" version = "1.0.147"
@@ -1146,6 +873,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -1193,23 +933,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "sse-stream" name = "spin"
version = "0.2.1" version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"bytes",
"futures-util",
"http-body",
"http-body-util",
"pin-project-lite",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
@@ -1241,26 +968,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
@@ -1270,37 +977,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.48.0" version = "1.48.0"
@@ -1329,17 +1005,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.17" version = "0.7.17"
@@ -1393,18 +1058,6 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-appender"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [
"crossbeam-channel",
"thiserror",
"time",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
@@ -1443,14 +1096,10 @@ version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [ dependencies = [
"matchers",
"nu-ansi-term", "nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
] ]
@@ -1468,13 +1117,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "uuid" name = "unsafe-libyaml"
version = "1.23.0" 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 = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "uuid"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
"serde_core",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -1484,6 +1140,12 @@ 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"
@@ -1842,26 +1504,6 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "0.1.8" version = "0.1.8"

View File

@@ -1,36 +1,42 @@
[package] [package]
name = "mock_server" name = "mock_server"
version = "0.1.0" version = "0.1.0"
edition = "2021" 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"]}
# 异步文件操作与流处理工具 # 异步文件操作与流处理工具
tokio-util = {version = "0.7.17",features = ["io"]} tokio-util = {version = "0.7.17",features = ["io"]}
futures-util = "0.3.31" futures-util = "0.3.31"
# 序列化 # 序列化与 YAML 解析
serde = {version = "1.0.228",features = ["derive"]} serde = {version = "1.0.228",features = ["derive"]}
serde_yaml = "0.9.34+deprecated"
serde_json = "1.0.147" serde_json = "1.0.147"
# 物理目录递归扫描工具 # 物理目录递归扫描工具
walkdir = "2.5.0" walkdir = "2.5.0"
# 日志系统 # UUID 生成(用于唯一文件名)
tracing = "0.1.44" uuid = { version = "1.0", features = ["v4", "serde"] }
tracing-subscriber = { version = "0.3.22", features = ["fmt", "env-filter"] }
tracing-appender = "0.2"
# 日期时间处理
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" notify-debouncer-mini = "0.6.0"
# 路径处理
# MCP Server 支持 #pathdiff = "0.2.3"
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
schemars = "1.0"
[dev-dependencies] [dev-dependencies]
tempfile = "3.24.0" tempfile = "3.24.0"

View File

@@ -8,7 +8,7 @@
- **热重载**`mocks/*.yaml` 变更自动生效,无需重启服务 - **热重载**`mocks/*.yaml` 变更自动生效,无需重启服务
- **高性能**:基于 Rust 异步运行时,路径首段哈希索引 O(1) 匹配 - **高性能**:基于 Rust 异步运行时,路径首段哈希索引 O(1) 匹配
- **低内存**:大响应体支持 `file://` 协议从磁盘流式读取 - **低内存**:大响应体支持 `file://` 协议从磁盘流式读取
- **请求匹配**:支持 method、path、headers、query_params、body 多维度匹配 - **文件上传**:内置 `/api/upload` 端点,按日期分目录存储
## 快速开始 ## 快速开始
@@ -39,37 +39,38 @@ src/
├── config.rs # 数据结构定义MockRule, RequestMatcher 等) ├── config.rs # 数据结构定义MockRule, RequestMatcher 等)
├── loader.rs # YAML 解析,目录扫描 ├── loader.rs # YAML 解析,目录扫描
├── router.rs # 路径首段索引,匹配逻辑 ├── router.rs # 路径首段索引,匹配逻辑
── handler.rs # 统一请求处理器,文件流式响应 ── handler.rs # 统一请求处理器,文件流式响应
└── upload.rs # Multipart 文件上传处理
tests/ # 集成测试(每个模块一个测试文件) tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件 mocks/ # YAML Mock 配置文件
storage/ # 上传文件存储(按 YYYY-MM-DD 分目录)
``` ```
## Mock 配置 ## Mock 配置
详细配置规范请参考 [.claude/rules/mock-spec.md](.claude/rules/mock-spec.md) 详细配置规范请参考 [.claude/rules/mock-spec.md](.claude/rules/mock-spec.md)
## 未来计划 ## API
### 1. 动态参数与模板响应 ### 文件上传
支持路径参数捕获(如 `/api/users/{id}`)和响应模板,可在响应中引用请求参数: ```http
POST /api/upload
Content-Type: multipart/form-data
```yaml file: <binary>
# 请求: GET /api/users/123
# 响应: { "id": "123", "name": "User 123" }
``` ```
### 2. HTTPS 支持 **响应示例:**
内置 TLS/SSL 支持,提供安全的 HTTPS 服务。 ```json
{
### 3. 可视化管理界面 "filename": "example.txt",
"path": "storage/2024-01-15/example.txt",
- **TUI终端界面**:基于 Ratatui 的交互式终端管理 "size": 1024
- **GUI图形界面**Web Dashboard 或桌面应用,可视化管理 Mock 规则 }
```
> 此功能需要深入讨论,欢迎提出建议和需求
## 许可证 ## 许可证

View File

@@ -1,427 +0,0 @@
# rmcp 0.11 MCP Server 实现指南
本文档详细介绍了如何使用 rmcp 0.11 实现 MCP Server包括核心概念、关键代码模式和常见陷阱。
## 目录
- [核心概念](#核心概念)
- [Tool 注册三要素](#tool-注册三要素)
- [完整代码示例](#完整代码示例)
- [常见错误和解决方案](#常见错误和解决方案)
- [HTTP 传输配置](#http-传输配置)
- [客户端配置](#客户端配置)
---
## 核心概念
### rmcp 简介
rmcp 是 Rust 官方的 MCP SDK提供了实现 MCP (Model Context Protocol) 服务器和客户端的完整工具链。
### MCP 协议
MCP (Model Context Protocol) 是一个开放协议,用于连接 AI 助手与外部系统。它定义了一套标准化的接口,允许 AI 模型:
- **Tools**: 调用外部工具/函数
- **Resources**: 访问外部资源
- **Prompts**: 使用预定义的提示模板
### 依赖配置
```toml
# Cargo.toml
[dependencies]
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
schemars = "1.0"
tokio-util = { version = "0.7", features = ["io"] }
```
---
## Tool 注册三要素
要使 MCP tools 在 rmcp 0.11 中正常工作,**必须**同时具备以下三个要素:
### 1. `#[tool_router]` 宏
放在包含 tool 方法的 impl 块上:
```rust
#[tool_router]
impl MockMcpServer {
// tool 方法...
}
```
### 2. `tool_router: ToolRouter<Self>` 字段
在结构体中必须包含此字段:
```rust
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>, // 必需字段
}
```
### 3. `#[tool_handler]` 宏
放在 `ServerHandler` trait 实现块上:
```rust
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
// ...
}
}
```
> **重要**: 缺少 `#[tool_handler]` 会导致 `tools/list` 返回空数组 `{"tools": []}`
---
## 完整代码示例
### 结构体定义
```rust
use std::sync::Arc;
use rmcp::{
handler::server::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::*,
tool, tool_handler, tool_router,
ErrorData as McpError, ServerHandler,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// MCP Server for Mock Server management
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>, // 必需字段
}
```
### 请求参数结构体
```rust
/// 使用 schemars 和 serde 自动生成 JSON Schema
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListRulesRequest {
#[schemars(description = "Optional group name to filter by")]
pub group: Option<String>,
}
```
### Tool 方法实现
```rust
#[tool_router]
impl MockMcpServer {
pub fn new(manager: Arc<MockManager>) -> Self {
Self {
manager,
tool_router: Self::tool_router(), // 初始化 tool_router
}
}
/// 无参数 tool
#[tool(description = "List all groups (directories)")]
async fn list_groups(&self) -> Result<CallToolResult, McpError> {
let groups = self.manager.list_groups();
let result = serde_json::to_string_pretty(&groups)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
/// 带参数 tool - 使用 Parameters<T> 包装器
#[tool(description = "Get a specific mock rule by group and name")]
async fn get_mock_rule(
&self,
params: Parameters<GetRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.get(&params.0.group, &params.0.name) {
Some(rule) => {
let result = serde_json::to_string_pretty(&rule)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
None => Ok(CallToolResult::error(vec![Content::text(
format!("Rule not found: {}/{}", params.0.group, params.0.name),
)])),
}
}
/// 可选参数
#[tool(description = "List all mock rules, optionally filtered by group")]
async fn list_mock_rules(
&self,
params: Parameters<ListRulesRequest>,
) -> Result<CallToolResult, McpError> {
let rules = self.manager.list(params.0.group.as_deref());
let result = serde_json::to_string_pretty(&rules)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
}
```
### ServerHandler 实现
```rust
#[tool_handler] // 关键宏!
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools() // 必须启用 tools 能力
.build(),
instructions: Some("Mock Server MCP - Manage mock API rules.".to_string()),
..Default::default()
}
}
}
```
---
## 常见错误和解决方案
### 1. Missing `#[tool_handler]`
**症状**: `tools/list` 返回空数组 `{"tools": []}`
**原因**: 没有在 `ServerHandler` impl 块上添加 `#[tool_handler]`
**解决方案**:
```rust
// 错误 - 缺少宏
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo { ... }
}
// 正确
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo { ... }
}
```
### 2. Missing `enable_tools()`
**症状**: Tools 不被广播,客户端无法发现 tools
**原因**: `ServerCapabilities` 没有启用 tools
**解决方案**:
```rust
// 错误
ServerInfo {
capabilities: ServerCapabilities::default (),
...
}
// 正确
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
...
}
```
### 3. 使用 `#[tool(aggr)]`
**症状**: 编译错误
**原因**: rmcp 0.11 不支持 `aggr` 参数
**解决方案**:
```rust
// 错误 - rmcp 0.11 不支持
#[tool(aggr)]
async fn my_tool(&self, params: MyRequest) -> Result<...>
// 正确 - 使用 Parameters<T> 包装器
#[tool(description = "...")]
async fn my_tool(&self, params: Parameters<MyRequest>) -> Result<...>
```
### 4. 使用 `#[tool(tool_box)]`
**症状**: 编译错误
**原因**: rmcp 0.11 使用不同的宏名称
**解决方案**:
```rust
// 错误
#[tool(tool_box)]
impl MockMcpServer { ... }
// 正确
#[tool_router]
impl MockMcpServer { ... }
```
---
## HTTP 传输配置
### StreamableHttpService 无状态模式
适用于简单的 HTTP 集成,无需维护会话状态:
```rust
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::never::NeverSessionManager,
};
use tokio_util::sync::CancellationToken;
/// 创建无状态 MCP HTTP 服务
pub fn create_mcp_http_service(
manager: Arc<MockManager>,
) -> StreamableHttpService<MockMcpServer, NeverSessionManager> {
StreamableHttpService::new(
// 每次请求创建新的 server 实例
move || Ok(MockMcpServer::new(manager.clone())),
// 无状态会话管理器
Arc::new(NeverSessionManager::default()),
StreamableHttpServerConfig {
sse_keep_alive: None, // SSE 保活配置(可选)
stateful_mode: false, // 无状态模式
cancellation_token: CancellationToken::new(),
},
)
}
```
### 与 Axum 集成
```rust
use axum::{
routing::post,
Router,
body::Body,
http::Request,
};
let mcp_service = create_mcp_http_service(manager);
let app = Router::new()
.route("/mcp", post({
let service = mcp_service.clone();
move | req: Request < Body > | {
let service = service.clone();
async move { service.handle(req).await }
}
}));
```
### 配置选项说明
| 选项 | 类型 | 说明 |
|----------------------|---------------------|------------|
| `sse_keep_alive` | `Option<Duration>` | SSE 连接保活间隔 |
| `stateful_mode` | `bool` | 是否维护会话状态 |
| `cancellation_token` | `CancellationToken` | 用于优雅关闭 |
---
## 客户端配置
### Claude Code 配置
在 Claude Code 的 `settings.json` 中添加:
```json
{
"mcpServers": {
"mock-server": {
"type": "http",
"url": "http://127.0.0.1:8080/mcp"
}
}
}
```
### Claude Desktop 配置
在 Claude Desktop 的配置文件中添加:
```json
{
"mcpServers": {
"mock-server": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}
```
---
## 架构说明
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP Server (Axum) │
│ Port 8080 │
├─────────────────────────────────────────────────────────────┤
│ │
│ POST /mcp │ /* (fallback) │
│ ├─ tools/list │ Mock API endpoints │
│ ├─ tools/call │ │
│ └─ other MCP methods │ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ StreamableHttpService<MockMcpServer> │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ MockMcpServer │ │ NeverSessionManager │ │
│ │ │ │ (无状态) │ │
│ │ - tool_router │ │ │ │
│ │ - manager │ │ │ │
│ └─────────────────┘ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ MockManager (Arc) │
│ │
│ 共享于 HTTP MCP 端点和 Mock API handler │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 参考资源
- [rmcp GitHub Repository](https://github.com/anthropics/rmcp)
- [MCP Specification](https://spec.modelcontextprotocol.io/)
- [schemars Documentation](https://docs.rs/schemars/)

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

28
mocks/v1/auth/login.yaml Normal file
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 # 模拟真实网络延迟

View File

@@ -1,26 +0,0 @@
{
"name": "user_login_001",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user001",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10001,\"username\":\"user001\",\"role\":\"administrator\"}}"
},
"settings": {
"delay_ms": 2000
}
}

View File

@@ -1,26 +0,0 @@
{
"name": "user_login_002",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user002",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10002,\"username\":\"user002\",\"role\":\"administrator\"}}"
},
"settings": {
"delay_ms": 2000
}
}

View File

@@ -1,26 +0,0 @@
{
"name": "user_login_003",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user003",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10003,\"username\":\"user003\",\"role\":\"administrator\"}}"
},
"settings": {
"delay_ms": 2000
}
}

View File

@@ -1,23 +0,0 @@
{
"name": "user_login_004",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user004",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10004,\"username\":\"user004\",\"role\":\"administrator\"}}"
}
}

View File

@@ -1,23 +0,0 @@
{
"name": "user_login_005",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user005",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10005,\"username\":\"user005\",\"role\":\"administrator\"}}"
}
}

View File

@@ -0,0 +1,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

@@ -1,22 +0,0 @@
{
"name": "user_register",
"request": {
"method": "POST",
"path": "/v1/auth/register",
"headers": {
"Content-Type": "application/json"
},
"body": {
"username": "newuser",
"password": "newpass123",
"email": "newuser@example.com"
}
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"注册成功\",\"data\":{\"userId\":10002,\"username\":\"newuser\",\"email\":\"newuser@example.com\",\"createdAt\":\"2026-03-27T10:00:00Z\"}}"
}
}

View File

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

View File

@@ -1,14 +0,0 @@
{
"name": "health_check",
"request": {
"method": "GET",
"path": "/v1/health"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"status\":\"healthy\",\"version\":\"2.0.0\",\"timestamp\":\"2026-03-27T10:00:00Z\"}"
}
}

View File

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

View File

@@ -1,16 +0,0 @@
{
"name": "prod_export_pdf",
"request": {
"method": "GET",
"path": "/v1/products/report",
"body": "{\"username\":\"user001\",\"password\":\"password123\"}"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename=report.pdf"
},
"body": "file://./storage/v1/hello.pdf"
}
}

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

View File

@@ -1,18 +0,0 @@
{
"name": "user_upload_avatar_001",
"request": {
"method": "POST",
"path": "/v1/user/avatar",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": ["avatar1", "description1"]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10001.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
}
}

View File

@@ -1,21 +0,0 @@
{
"name": "user_upload_avatar_002",
"request": {
"method": "POST",
"path": "/v1/user/avatar",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": {
"avatar2": "avatar",
"description2": "description"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10002.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
}
}

View File

@@ -1,21 +0,0 @@
{
"name": "user_upload_avatar_003",
"request": {
"method": "POST",
"path": "/v1/user/avatar",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": {
"avatar3": "avatar",
"description3": "description"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10003.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
}
}

View File

@@ -1,18 +0,0 @@
{
"name": "user_download",
"request": {
"method": "GET",
"path": "/v1/user/download",
"query_params": {
"format": "json"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/octet-stream",
"Content-Disposition": "attachment; filename=user_data.json"
},
"body": "file://./storage/v1/user_data.json"
}
}

View File

@@ -1,18 +0,0 @@
{
"name": "user_echo",
"request": {
"method": "POST",
"path": "/v1/user/echo",
"headers": {
"Content-Type": "text/plain"
},
"body": "Hello V1 Mock Server"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "text/plain"
},
"body": "Echo from V1: Hello V1 Mock Server"
}
}

View File

@@ -1,21 +0,0 @@
{
"name": "_user_login_form",
"request": {
"method": "POST",
"path": "/v1/user/login/form",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"body": {
"username": "formuser",
"password": "formpass"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"表单登录成功\",\"data\":{\"token\":\"v2_form_token_xyz\",\"userId\":20001,\"username\":\"formuser\"}}"
}
}

View File

@@ -1,17 +0,0 @@
{
"name": "user_profile",
"request": {
"method": "GET",
"path": "/v1/user/profile",
"headers": {
"Authorization": "Bearer v1_test_token"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"获取成功\",\"data\":{\"userId\":10001,\"username\":\"admin\",\"email\":\"admin@example.com\",\"nickname\":\"管理员\",\"avatar\":\"https://example.com/avatars/admin.jpg\",\"createdAt\":\"2025-01-01T00:00:00Z\"}}"
}
}

11
mocks/v1/user/search.yaml Normal file
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
src/config.rs Normal file
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
}
}
}

View File

@@ -8,155 +8,25 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::models::Payload;
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>,
} }
/// 提取请求的 Content-Type去掉参数部分如 boundary
fn extract_content_type(headers: &HeaderMap) -> Option<String> {
headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_lowercase())
}
/// 根据 Content-Type 解析 Body始终以请求的 Content-Type 为准)
fn parse_body(content_type: Option<&str>, bytes: &[u8]) -> Payload {
if bytes.is_empty() {
return Payload::None;
}
match content_type {
Some(ct) if ct.contains("application/json") => {
serde_json::from_slice(bytes)
.map(Payload::Json)
.unwrap_or_else(|_| {
// JSON 解析失败,降级为文本
Payload::Text(String::from_utf8_lossy(bytes).to_string())
})
}
Some(ct) if ct.contains("xml") => {
Payload::Xml(String::from_utf8_lossy(bytes).to_string())
}
Some(ct) if ct.contains("form-urlencoded") => {
Payload::Form(parse_urlencoded(bytes))
}
Some(ct) if ct.contains("multipart/form-data") => {
Payload::Multipart(extract_multipart_data(bytes))
}
_ => {
Payload::Text(String::from_utf8_lossy(bytes).to_string())
}
}
}
/// 解析 urlencoded 格式
fn parse_urlencoded(bytes: &[u8]) -> HashMap<String, String> {
let body = String::from_utf8_lossy(bytes);
let mut map = HashMap::new();
for pair in body.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let decoded_key = urlencoding_decode(key);
let decoded_value = urlencoding_decode(value);
map.insert(decoded_key, decoded_value);
}
}
map
}
/// URL 解码(简单实现)
fn urlencoding_decode(s: &str) -> String {
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '+' {
result.push(' ');
} else if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else {
result.push(c);
}
}
result
}
/// 从 multipart body 中提取键值对
fn extract_multipart_data(bytes: &[u8]) -> HashMap<String, String> {
let body = String::from_utf8_lossy(bytes);
let mut map = HashMap::new();
// 分割 boundary
let lines: Vec<&str> = body.lines().collect();
let mut current_name: Option<String> = None;
let mut current_value = String::new();
let mut in_value = false;
for line in &lines {
// 检测 Content-Disposition 行,提取 name
if line.contains("Content-Disposition") && line.contains("name=") {
// 保存上一个字段的值
if let Some(name) = current_name.take() {
map.insert(name, current_value.trim().to_string());
current_value.clear();
}
// 提取 name 属性
if let Some(start) = line.find("name=\"") {
let start = start + 6;
if let Some(end) = line[start..].find('"') {
current_name = Some(line[start..start + end].to_string());
in_value = false;
}
}
} else if line.starts_with("Content-Type") {
// 跳过 Content-Type 行
continue;
} else if line.is_empty() {
// 空行后面是值
in_value = true;
} else if in_value {
// 收集值内容
if !current_value.is_empty() {
current_value.push('\n');
}
current_value.push_str(line);
}
}
// 保存最后一个字段
if let Some(name) = current_name {
map.insert(name, current_value.trim().to_string());
}
map
}
/// 全局统一请求处理函数 /// 全局统一请求处理函数
pub async fn mock_handler( pub async fn mock_handler(
State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数 State(state): 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 {
// 1. 提取 path 和 method
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let method_str = method.as_str().to_string(); let method_str = method.as_str().to_string();
// 2. 提取请求的 Content-Type // 1. 将需要的数据克隆出来,断开与 req 的借用关系
let req_content_type = extract_content_type(&headers);
// 3. 读取请求 body
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await { let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(_) => { Err(_) => {
@@ -167,10 +37,9 @@ pub async fn mock_handler(
} }
}; };
// 4. 根据【请求的 Content-Type】解析 body let incoming_json: Option<serde_json::Value> = serde_json::from_slice(&body_bytes).ok();
let parsed_body = parse_body(req_content_type.as_deref(), &body_bytes);
// 5. 将 Axum HeaderMap 转换为简单的 HashMap // 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() {
@@ -178,22 +47,22 @@ pub async fn mock_handler(
} }
} }
// 6. 执行匹配逻辑:先获取读锁 (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_str, &path, &params, &req_headers, &parsed_body).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 {
// 7. 处理模拟延迟 // 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;
} }
} }
// 8. 构建响应 // 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);
@@ -203,7 +72,7 @@ pub async fn mock_handler(
} }
} }
// 9. 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) => {
@@ -226,6 +95,7 @@ pub async fn mock_handler(
.unwrap() .unwrap()
} }
} else { } else {
println!("请求头{:?}", req_headers);
// 匹配失败返回 404 // 匹配失败返回 404
Response::builder() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)

View File

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

View File

@@ -1,34 +1,31 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::Path; use std::path::{Path, PathBuf};
use walkdir::WalkDir; use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖
use crate::models::MockRule; use crate::config::{MockRule, MockSource}; // 假设 config.rs 中定义了这两个类型
pub struct MockLoader; pub struct MockLoader;
impl MockLoader { impl MockLoader {
/// 递归扫描指定目录并构建索引表 /// 递归扫描指定目录并构建索引表
/// 目录结构mocks/{group}/{rule}.json
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> { pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new(); let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
if !dir.exists() { // 1. 使用 walkdir 递归遍历目录,不限层级
return index;
}
// 1. 使用 walkdir 递归遍历目录,寻找 JSON 文件
for entry in WalkDir::new(dir) for entry in WalkDir::new(dir)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "json")) .filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml"))
{ {
if let Some(rule) = Self::parse_json_file(entry.path()) { if let Some(rules) = Self::parse_yaml_file(entry.path()) {
// 2. 提取路径首段作为索引 Key for rule in rules {
let key = Self::extract_first_segment(&rule.request.path); // 2. 提取路径首段作为索引 Key
let key = Self::extract_first_segment(&rule.request.path);
// 3. 将规则插入到对应的索引桶中 // 3. 将规则插入到对应的索引桶中
index.entry(key).or_insert_with(Vec::new).push(rule); index.entry(key).or_insert_with(Vec::new).push(rule);
}
} }
} }
@@ -36,20 +33,15 @@ impl MockLoader {
index index
} }
/// 解析单个 JSON 文件 /// 解析单个 YAML 文件,支持单接口和多接口模式
fn parse_json_file(path: &Path) -> Option<MockRule> { fn parse_yaml_file(path: &Path) -> Option<Vec<MockRule>> {
match fs::read_to_string(path) { let content = fs::read_to_string(path).ok()?;
Ok(content) => {
match serde_json::from_str::<MockRule>(&content) { // 利用 serde_yaml 的反序列化能力处理 MockSource 枚举
Ok(rule) => Some(rule), match serde_yaml::from_str::<MockSource>(&content) {
Err(e) => { Ok(source) => Some(source.flatten()), // 统一打平为 Vec<MockRule>
eprintln!("Failed to parse JSON at {:?}: {}", path, e);
None
}
}
}
Err(e) => { Err(e) => {
eprintln!("Failed to read file {:?}: {}", path, e); eprintln!("Failed to parse YAML at {:?}: {}", path, e);
None None
} }
} }
@@ -63,4 +55,4 @@ impl MockLoader {
.unwrap_or("root") // 如果是根路径 "/",则归类到 "root" .unwrap_or("root") // 如果是根路径 "/",则归类到 "root"
.to_string() .to_string()
} }
} }

View File

@@ -1,144 +1,83 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::Path;
use std::sync::Arc; use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use axum::{routing::{any, post}, Router};
use axum::{routing::post, Router}; use notify_debouncer_mini::{new_debouncer, notify::*};
use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode};
use tracing::{info, error};
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::logging; use mock_server::upload::upload_handler;
fn print_usage() {
println!("Mock Server - A mock API server with hot-reload and MCP support");
println!();
println!("Usage: mock_server [OPTIONS]");
println!();
println!("Options:");
println!(" --mocks <DIR> Mocks directory path (default: ./mocks)");
println!(" --port <PORT> HTTP server port (default: 8080)");
println!(" --help Show this help message");
println!();
println!("MCP Endpoint: POST http://127.0.0.1:<PORT>/mcp");
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Initialize logging system tracing_subscriber::fmt::init();
let log_dir = PathBuf::from("./logs");
logging::init(log_dir);
// Parse command line arguments
let args: Vec<String> = std::env::args().collect();
let mut mocks_dir = PathBuf::from("./mocks");
let mut port: u16 = 8080;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--help" | "-h" => {
print_usage();
return;
}
"--mocks" => {
if i + 1 < args.len() {
mocks_dir = PathBuf::from(&args[i + 1]);
i += 1;
} else {
eprintln!("--mocks requires a directory path");
return;
}
}
"--port" => {
if i + 1 < args.len() {
match args[i + 1].parse::<u16>() {
Ok(p) => port = p,
Err(_) => {
eprintln!("Invalid port number: {}", args[i + 1]);
return;
}
}
i += 1;
} else {
eprintln!("--port requires a port number");
return;
}
}
_ => {
eprintln!("Unknown option: {}", args[i]);
print_usage();
return;
}
}
i += 1;
}
// 1. 确保 mocks 目录存在
let mocks_dir = Path::new("./mocks");
if !mocks_dir.exists() { if !mocks_dir.exists() {
std::fs::create_dir_all(&mocks_dir).unwrap(); println!("Warning: 'mocks/' directory not found. Creating it...");
std::fs::create_dir_all(mocks_dir).unwrap();
} }
// Create shared MockManager for both HTTP and MCP // 2. 确保 storage 目录存在
let manager = Arc::new(mock_server::manager::MockManager::new(mocks_dir.clone())); let storage_dir = Path::new("./storage");
info!("Loaded {} groups", manager.list_groups().len()); if !storage_dir.exists() {
println!("Creating storage directory...");
std::fs::create_dir_all(storage_dir).unwrap();
}
// Run unified HTTP server (includes MCP endpoint) // 3. 初始加载 Mock 配置
run_http_server(mocks_dir, port, manager).await; println!("Scanning mocks directory...");
} let index = MockLoader::load_all_from_dir(mocks_dir);
async fn run_http_server(mocks_dir: PathBuf, port: u16, manager: Arc<mock_server::manager::MockManager>) { // 4. 构建共享状态(使用 RwLock 支持热重载)
info!("Scanning mocks directory...");
let index = MockLoader::load_all_from_dir(&mocks_dir);
let shared_state = Arc::new(AppState { let shared_state = Arc::new(AppState {
router: std::sync::RwLock::new(MockRouter::new(index)), router: RwLock::new(MockRouter::new(index)),
}); });
// Setup hot-reload watcher // 5. 设置热加载监听器
let state_for_watcher = shared_state.clone(); let state_for_watcher = shared_state.clone();
let watch_path = mocks_dir.clone(); let watch_path = mocks_dir.to_path_buf();
let manager_for_watcher = manager.clone();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
// 200ms 防抖,防止编辑器保存文件时产生多次干扰
let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap(); let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap();
debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap(); debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap();
// 启动异步任务监听文件变动
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(res) = rx.recv() { while let Ok(res) = rx.recv() {
match res { match res {
Ok(_) => { Ok(_) => {
info!("Detected changes in mocks/, reloading..."); println!("🔄 Detecting changes in mocks/, reloading...");
let new_index = MockLoader::load_all_from_dir(&watch_path); 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"); let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock");
*writer = MockRouter::new(new_index); *writer = MockRouter::new(new_index);
// Also reload in manager println!("✅ Mocks reloaded successfully.");
manager_for_watcher.reload();
info!("Mocks reloaded successfully.");
} }
Err(e) => error!("Watcher error: {:?}", e), Err(e) => eprintln!("Watcher error: {:?}", e),
} }
} }
}); });
// Create MCP HTTP service (stateless) // 6. 配置 Axum 路由
let mcp_service = mock_server::mcp::create_mcp_http_service(manager.clone()); // 文件上传路由POST /api/upload
// 其他所有请求由 mock_handler 处理
let app = Router::new() let app = Router::new()
// MCP endpoint (stateless HTTP transport) .route("/api/upload", post(upload_handler))
.route("/mcp", post(|req| async move { .fallback(any(mock_handler))
mcp_service.handle(req).await
}))
// Mock API fallback
.fallback(axum::routing::any(mock_handler))
.with_state(shared_state); .with_state(shared_state);
let addr = SocketAddr::from(([127, 0, 0, 1], port)); // 7. 启动服务
info!("HTTP server running at http://{}", addr); let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
info!("MCP endpoint available at http://{}/mcp", 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();
if let Err(e) = axum::serve(listener, app).await { axum::serve(listener, app).await.unwrap();
error!("HTTP server error: {}", e);
}
} }

View File

@@ -1,208 +0,0 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::RwLock;
use crate::models::MockRule;
use crate::loader::MockLoader;
use crate::router::MockRouter;
/// Mock 规则管理器
/// 提供统一的 CRUD 操作,同时管理内存和文件持久化
pub struct MockManager {
/// mocks 目录的基础路径
base_path: PathBuf,
/// 内存中的规则分组group -> Vec<MockRule>
groups: RwLock<HashMap<String, Vec<MockRule>>>,
/// 路由索引
router: RwLock<MockRouter>,
}
impl MockManager {
/// 创建新的 MockManager
pub fn new(base_path: PathBuf) -> Self {
let groups = MockLoader::load_all_from_dir(&base_path);
let index = Self::build_index(&groups);
let router = MockRouter::new(index);
Self {
base_path,
groups: RwLock::new(groups),
router: RwLock::new(router),
}
}
/// 从分组构建路由索引
fn build_index(groups: &HashMap<String, Vec<MockRule>>) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
for rules in groups.values() {
for rule in rules {
let key = Self::extract_first_segment(&rule.request.path);
index.entry(key).or_default().push(rule.clone());
}
}
index
}
/// 提取路径首段
fn extract_first_segment(path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string()
}
/// 列出所有规则(可按分组过滤)
pub fn list(&self, group: Option<&str>) -> Vec<(String, MockRule)> {
let groups = self.groups.read().unwrap();
match group {
Some(g) => groups
.get(g)
.map(|rules| rules.iter().map(|r| (g.to_string(), r.clone())).collect())
.unwrap_or_default(),
None => groups
.iter()
.flat_map(|(g, rules)| rules.iter().map(|r| (g.clone(), r.clone())))
.collect(),
}
}
/// 获取单个规则
pub fn get(&self, group: &str, name: &str) -> Option<MockRule> {
let groups = self.groups.read().unwrap();
groups
.get(group)?
.iter()
.find(|r| r.name == name)
.cloned()
}
/// 创建规则
pub fn create(&self, group: &str, rule: MockRule) -> Result<(), String> {
// 1. 写入文件
let dir = self.base_path.join(group);
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?;
let file_path = dir.join(format!("{}.json", rule.name));
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
groups
.entry(group.to_string())
.or_default()
.push(rule.clone());
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 更新规则
pub fn update(&self, group: &str, name: &str, rule: MockRule) -> Result<(), String> {
// 1. 更新文件
let file_path = self.base_path.join(group).join(format!("{}.json", name));
// 如果 name 变化,需要删除旧文件
if name != rule.name {
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除旧文件失败: {}", e))?;
}
let new_path = self.base_path.join(group).join(format!("{}.json", rule.name));
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&new_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
} else {
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
}
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
if let Some(rules) = groups.get_mut(group) {
if let Some(pos) = rules.iter().position(|r| r.name == name) {
rules[pos] = rule.clone();
}
}
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 删除规则
pub fn delete(&self, group: &str, name: &str) -> Result<(), String> {
// 1. 删除文件
let file_path = self.base_path.join(group).join(format!("{}.json", name));
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
}
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
if let Some(rules) = groups.get_mut(group) {
rules.retain(|r| r.name != name);
if rules.is_empty() {
groups.remove(group);
// 尝试删除空目录
let _ = fs::remove_dir(self.base_path.join(group));
}
}
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 重载所有规则(从磁盘重新加载)
pub fn reload(&self) {
let new_groups = MockLoader::load_all_from_dir(&self.base_path);
let new_index = Self::build_index(&new_groups);
let new_router = MockRouter::new(new_index);
{
let mut groups = self.groups.write().unwrap();
*groups = new_groups;
}
{
let mut router = self.router.write().unwrap();
*router = new_router;
}
}
/// 重建 router 索引
fn rebuild_router(&self) {
let groups = self.groups.read().unwrap();
let index = Self::build_index(&groups);
let new_router = MockRouter::new(index);
let mut router = self.router.write().unwrap();
*router = new_router;
}
/// 获取 router 的读锁引用(用于 handler
pub fn get_router(&self) -> &RwLock<MockRouter> {
&self.router
}
/// 列出所有分组
pub fn list_groups(&self) -> Vec<String> {
let groups = self.groups.read().unwrap();
groups.keys().cloned().collect()
}
}

View File

@@ -1,3 +0,0 @@
mod server;
pub use server::create_mcp_http_service;

View File

@@ -1,199 +0,0 @@
use std::sync::Arc;
use rmcp::{
handler::server::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::*,
tool, tool_handler, tool_router,
ErrorData as McpError, ServerHandler,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::manager::MockManager;
use crate::models::MockRule;
/// MCP Server for Mock Server management
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>,
}
/// Request for getting a specific mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
/// Request for creating a new mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CreateRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Mock rule definition (JSON object)")]
pub rule_json: String,
}
/// Request for updating a mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateRuleRequest {
#[schemars(description = "Group name")]
pub group: String,
#[schemars(description = "Current rule name")]
pub name: String,
#[schemars(description = "Updated rule definition (JSON object)")]
pub rule_json: String,
}
/// Request for deleting a mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteRuleRequest {
#[schemars(description = "Group name")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
/// Request for listing mock rules with optional filter
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListRulesRequest {
#[schemars(description = "Optional group name to filter by")]
pub group: Option<String>,
}
#[tool_router]
impl MockMcpServer {
pub fn new(manager: Arc<MockManager>) -> Self {
Self {
manager,
tool_router: Self::tool_router(),
}
}
#[tool(description = "List all mock rules, optionally filtered by group")]
async fn list_mock_rules(
&self,
params: Parameters<ListRulesRequest>,
) -> Result<CallToolResult, McpError> {
let rules = self.manager.list(params.0.group.as_deref());
let result = serde_json::to_string_pretty(&rules)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
#[tool(description = "Get a specific mock rule by group and name")]
async fn get_mock_rule(
&self,
params: Parameters<GetRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.get(&params.0.group, &params.0.name) {
Some(rule) => {
let result = serde_json::to_string_pretty(&rule)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
None => Ok(CallToolResult::error(vec![Content::text(
format!("Rule not found: {}/{}", params.0.group, params.0.name),
)])),
}
}
#[tool(description = "Create a new mock rule")]
async fn create_mock_rule(
&self,
params: Parameters<CreateRuleRequest>,
) -> Result<CallToolResult, McpError> {
let rule: MockRule = serde_json::from_str(&params.0.rule_json)
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
match self.manager.create(&params.0.group, rule) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Created rule in group '{}'", params.0.group),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Update an existing mock rule")]
async fn update_mock_rule(
&self,
params: Parameters<UpdateRuleRequest>,
) -> Result<CallToolResult, McpError> {
let rule: MockRule = serde_json::from_str(&params.0.rule_json)
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
match self.manager.update(&params.0.group, &params.0.name, rule) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Updated rule {}/{}", params.0.group, params.0.name),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Delete a mock rule")]
async fn delete_mock_rule(
&self,
params: Parameters<DeleteRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.delete(&params.0.group, &params.0.name) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Deleted rule {}/{}", params.0.group, params.0.name),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Reload all mock rules from disk")]
async fn reload_mock_rules(&self) -> Result<CallToolResult, McpError> {
self.manager.reload();
Ok(CallToolResult::success(vec![Content::text(
"Reloaded all rules from disk".to_string(),
)]))
}
#[tool(description = "List all groups (directories)")]
async fn list_groups(&self) -> Result<CallToolResult, McpError> {
let groups = self.manager.list_groups();
let result = serde_json::to_string_pretty(&groups)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
}
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
instructions: Some("Mock Server MCP - Manage mock API rules for development.\n\nUse list_mock_rules to see all rules, create_mock_rule to add new ones, and delete_mock_rule to remove them.".to_string()),
..Default::default()
}
}
}
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::never::NeverSessionManager,
};
use tokio_util::sync::CancellationToken;
/// Create stateless MCP HTTP service for integration with Axum
pub fn create_mcp_http_service(
manager: Arc<MockManager>,
) -> StreamableHttpService<MockMcpServer, NeverSessionManager> {
StreamableHttpService::new(
move || Ok(MockMcpServer::new(manager.clone())),
Arc::new(NeverSessionManager::default()),
StreamableHttpServerConfig {
sse_keep_alive: None,
stateful_mode: false,
cancellation_token: CancellationToken::new(),
},
)
}

View File

@@ -1,101 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// 解析后的请求 Body
#[derive(Debug, Clone)]
pub enum Payload {
/// JSON 格式
Json(serde_json::Value),
/// XML 格式
Xml(String),
/// URL 编码表单
Form(HashMap<String, String>),
/// Multipart 表单(键值对)
Multipart(HashMap<String, String>),
/// 纯文本
Text(String),
/// 无 Body
None,
}
impl Payload {
/// 转换为字符串(用于兜底比较)
pub fn to_compare_string(&self) -> String {
match self {
Payload::Json(v) => v.to_string(),
Payload::Xml(s) | Payload::Text(s) => s.clone(),
Payload::Form(map) | Payload::Multipart(map) => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by_key(|(k, _)| *k);
pairs.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
}
Payload::None => String::new(),
}
}
/// 获取对应的 Content-Type 名称
pub fn content_type_name(&self) -> &'static str {
match self {
Payload::Json(_) => "application/json",
Payload::Xml(_) => "application/xml",
Payload::Form(_) => "application/x-www-form-urlencoded",
Payload::Multipart(_) => "multipart/form-data",
Payload::Text(_) => "text/plain",
Payload::None => "none",
}
}
}
/// 核心 Mock 规则定义
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule {
pub name: 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>>,
/// 选填:请求体匹配条件
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
}
}
}

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::models::{MockRule, Payload}; use crate::config::MockRule;
pub struct MockRouter { pub struct MockRouter {
// 索引表Key 是路径首段(如 "api"Value 是该段下的所有 Mock 规则 // 索引表Key 是路径首段(如 "api"Value 是该段下的所有 Mock 规则
@@ -18,7 +18,7 @@ impl MockRouter {
path: &str, path: &str,
queries: &HashMap<String, String>, queries: &HashMap<String, String>,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
payload: &Payload, 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);
@@ -27,7 +27,7 @@ impl MockRouter {
if let Some(rules) = self.index.get(&key) { if let Some(rules) = self.index.get(&key) {
// 3. 在候选集中进行线性深度匹配 // 3. 在候选集中进行线性深度匹配
for rule in rules { for rule in rules {
if self.is_match(rule, method, path, queries, headers, payload) { if self.is_match(rule, method, path, queries, headers,incoming_body) {
return Some(rule); return Some(rule);
} }
} }
@@ -44,19 +44,18 @@ impl MockRouter {
path: &str, path: &str,
queries: &HashMap<String, String>, queries: &HashMap<String, String>,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
payload: &Payload, 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() {
println!("DEBUG: [NAME:{}] Method Mismatch: YAML={}, Req={}", rule.name, rule.request.method, method); println!("DEBUG: [ID:{}] Method Mismatch: YAML={}, Req={}", rule.id, rule.request.method, method);
return false; return false;
} }
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') { if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
println!("DEBUG: [NAME:{}] Path Mismatch: YAML='{}', Req='{}'", rule.name, rule.request.path, path); println!("DEBUG: [ID:{}] Path Mismatch: YAML='{}', Req='{}'", rule.id, rule.request.path, path);
return false; return false;
} }
println!("DEBUG: [NAME:{}] Method and Path matched! Checking headers...", rule.name); println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id);
// B. Query 参数校验 (子集匹配原则) // B. Query 参数校验 (子集匹配原则)
if let Some(ref required_queries) = rule.request.query_params { if let Some(ref required_queries) = rule.request.query_params {
for (key, val) in required_queries { for (key, val) in required_queries {
@@ -65,76 +64,47 @@ impl MockRouter {
} }
} }
} }
// C. Header 校验 (大小写不敏感Content-Type 使用前缀匹配) // C. Header 校验 (优化版)
if let Some(ref required_headers) = rule.request.headers { if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers { for (key, val) in required_headers {
let key_lower = key.to_lowercase(); println!("{}:{}",key.clone(), val.clone());
// 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值
let matched = headers.iter().any(|(k, v)| { let matched = headers.iter().any(|(k, v)| {
if k.to_lowercase() != key_lower { // k.to_lowercase() == key.to_lowercase() && v.contains(val)
return false; k.to_lowercase() == key.to_lowercase() && v==val
}
// Content-Type 使用前缀匹配(因为可能包含 boundary 等参数)
if key_lower == "content-type" {
v.to_lowercase().starts_with(&val.to_lowercase())
} else {
v == val
}
}); });
if !matched { if !matched {
return false; return false;
} }
} }
} }
// D. 智能 Body 全量比对逻辑
if let Some(ref required_val) = rule.request.body {
match incoming_body {
Some(actual_val) => {
// 实现你的想法:尝试将 YAML 中的 String 转换为 Object 再对比
let final_required = if let Some(s) = required_val.as_str() {
// 如果能解析成 JSON就用解析后的对象否则用原始字符串 Value
serde_json::from_str::<serde_json::Value>(s).unwrap_or_else(|_| required_val.clone())
} else {
required_val.clone()
};
// D. Body 匹配(根据 Payload 类型智能比较) // 执行全量相等比对
if let Some(ref rule_body) = rule.request.body { if final_required != *actual_val {
return self.match_body(rule_body, payload); println!("DEBUG: [ID:{}] Body Mismatch", rule.id);
return false;
}
}
None => return false, // YAML 要求有 Body 但请求为空
}
} }
true true
} }
/// Body 匹配逻辑
fn match_body(&self, rule_body: &serde_json::Value, payload: &Payload) -> bool {
match payload {
Payload::Json(actual) => {
// 如果 rule_body 是字符串,尝试解析为 JSON 后比较
if let Some(rule_str) = rule_body.as_str() {
// 尝试将字符串解析为 JSON
if let Ok(parsed_rule) = serde_json::from_str::<serde_json::Value>(rule_str) {
return &parsed_rule == actual;
}
}
// 直接比较
rule_body == actual
}
Payload::Xml(actual) => {
// XML 字符串比较(规范化后比较)
rule_body.as_str()
.map(|expected| normalize_xml(expected) == normalize_xml(actual))
.unwrap_or(false)
}
Payload::Form(actual) => {
// Form 键值对比较(子集匹配)
compare_form_with_json(rule_body, actual)
}
Payload::Multipart(actual_data) => {
// Multipart 匹配:支持键值对或字段名列表
compare_multipart_with_json(rule_body, actual_data)
}
Payload::Text(actual) => {
// 字符串比较(去掉首尾空白)
rule_body.as_str()
.map(|expected| expected.trim() == actual.trim())
.unwrap_or_else(|| rule_body.to_string().trim() == actual.trim())
}
Payload::None => {
false // 配置了 body但请求没有 body
}
}
}
/// 与 Loader 保持一致的 Key 提取算法 /// 与 Loader 保持一致的 Key 提取算法
fn extract_first_segment(&self, path: &str) -> String { fn extract_first_segment(&self, path: &str) -> String {
path.trim_start_matches('/') path.trim_start_matches('/')
@@ -143,75 +113,4 @@ impl MockRouter {
.unwrap_or("root") .unwrap_or("root")
.to_string() .to_string()
} }
} }
/// 规范化 XML 字符串:去掉声明、多余空白、格式化为紧凑形式
fn normalize_xml(xml: &str) -> String {
let mut result = xml.to_string();
// 去掉 XML 声明
if let Some(pos) = result.find("?>") {
if result[..pos].contains("<?xml") {
result = result[pos + 2..].to_string();
}
}
// 去掉多余空白字符
result = result
.chars()
.collect::<Vec<_>>()
.chunks(1)
.map(|c| c[0])
.collect::<String>();
// 分割成行,去掉每行首尾空白,过滤空行
result = result
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<String>();
result
}
/// Form 比较JSON 中的键值对必须是请求的子集
fn compare_form_with_json(rule_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
let rule_map = match rule_body.as_object() {
Some(obj) => obj,
None => return false,
};
for (key, rule_val) in rule_map {
let expected = rule_val.as_str().map(|s| s.to_string()).unwrap_or_else(|| rule_val.to_string());
if actual.get(key) != Some(&expected) {
return false;
}
}
true
}
/// Multipart 比较:对象和数组形式都只匹配字段名是否存在
fn compare_multipart_with_json(rule_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
// 方式 1对象形式 - 只匹配键名是否存在(忽略值)
if let Some(rule_map) = rule_body.as_object() {
for key in rule_map.keys() {
if !actual.contains_key(key) {
return false;
}
}
return true;
}
// 方式 2数组形式 - 只匹配字段名是否存在
if let Some(rule_array) = rule_body.as_array() {
for rule_field in rule_array {
let field_name = rule_field.as_str().map(|s| s.to_string()).unwrap_or_else(|| rule_field.to_string());
if !actual.contains_key(&field_name) {
return false;
}
}
return true;
}
false
}

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

Binary file not shown.

View File

@@ -1,20 +0,0 @@
{
"exportInfo": {
"exportedAt": "2026-03-27T10:00:00Z",
"version": "2.0.0"
},
"user": {
"id": 10001,
"username": "admin",
"email": "admin@example.com",
"nickname": "管理员",
"role": "administrator",
"createdAt": "2025-01-01T00:00:00Z",
"lastLoginAt": "2026-03-27T09:30:00Z"
},
"preferences": {
"theme": "dark",
"language": "zh-CN",
"notifications": true
}
}

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,86 +1,160 @@
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::Write; use std::io::Write;
use tempfile::tempdir; use tempfile::tempdir;
use mock_server::models::MockRule; // 确保 Cargo.toml 中有 serde_json
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 std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑
#[test] #[test]
fn test_config_deserialization() { fn test_config_parsing_scenarios() {
// 测试 1验证 JSON 解析 // 场景 A: 验证单接口配置 (增加 body 结构化校验)
let single_json = r#"{ let yaml_single = r#"
"name": "auth_v1", id: "auth_v1"
"request": { request:
"method": "POST", method: "POST"
"path": "/api/v1/login" path: "/api/v1/login"
}, body: { "user": "admin" }
"response": { response: { status: 200, body: "welcome" }
"status": 200, "#;
"body": "inline_content" let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
} let rules = source_s.flatten();
}"#; assert_eq!(rules.len(), 1);
let res: MockRule = serde_json::from_str(single_json).expect("应该成功解析 JSON"); // 验证 body 是否被成功解析为 Value::Object
assert_eq!(res.name, "auth_v1"); 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] #[test]
fn test_recursive_loading_logic() { fn test_loader_recursive_indexing() {
// 创建临时 Mock 目录结构 let temp_root = tempdir().expect("无法创建临时目录");
let root_dir = tempdir().expect("创建临时目录失败"); let root_path = temp_root.path();
let root_path = root_dir.path();
// 构造物理层级mocks/v1/user/ let auth_path = root_path.join("v1/auth");
let user_dir = root_path.join("v1/user"); fs::create_dir_all(&auth_path).unwrap();
fs::create_dir_all(&user_dir).unwrap();
// 在深层目录创建单接口 JSON 文件 let mut f1 = File::create(auth_path.join("login.yaml")).unwrap();
let mut file1 = File::create(user_dir.join("get_profile.json")).unwrap(); writeln!(f1, "id: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap();
writeln!(
file1,
r#"{{
"name": "user_profile",
"request": {{
"method": "GET",
"path": "/api/v1/user/profile"
}},
"response": {{
"status": 200,
"body": "profile_data"
}}
}}"#
)
.unwrap();
// 在根目录创建另一个 JSON 文件 let mut f2 = File::create(root_path.join("sys.yaml")).unwrap();
let mut file2 = File::create(root_path.join("health.json")).unwrap(); writeln!(f2, "- id: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap();
writeln!(
file2,
r#"{{
"name": "sys_health",
"request": {{
"method": "GET",
"path": "/health"
}},
"response": {{
"status": 200,
"body": "ok"
}}
}}"#
)
.unwrap();
// 执行加载
let index = MockLoader::load_all_from_dir(root_path); let index = MockLoader::load_all_from_dir(root_path);
// 断言结果: assert!(index.contains_key("api"));
// 1. 检查 Key 是否根据路径首段正确提取 assert!(index.contains_key("health"));
assert!(index.contains_key("api"), "索引应包含 'api' 键"); let total: usize = index.values().map(|v| v.len()).sum();
assert!(index.contains_key("health"), "索引应包含 'health' 键"); assert_eq!(total, 2);
// 2. 检查规则总数
let total_rules: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total_rules, 2, "总规则数应为 2");
// 3. 检查深层文件是否被正确读取
let api_rules = &index["api"];
assert_eq!(api_rules[0].name, "user_profile");
} }
#[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. 测试场景 BBody 不匹配
let wrong_body = Some(json!({ "code": 456 }));
let matched_fail = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&wrong_body
);
assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
// 4. 测试场景 C智能字符串转换验证 (YAML 是字符串,请求是对象)
let rule_str_body = serde_yaml::from_str::<MockSource>(
r#"
id: "str_match"
request:
method: "POST"
path: "/api/str"
body: '{"type": "json_in_string"}' # 这里 YAML 解析为 Value::String
response: { status: 200, body: "ok" }
"#).unwrap().flatten();
let mut index2 = HashMap::new();
index2.insert("api".to_string(), vec![rule_str_body[0].clone()]);
let router2 = MockRouter::new(index2);
let incoming_obj = Some(json!({ "type": "json_in_string" })); // 请求是 JSON 对象
let matched_str = router2.match_rule(
"POST",
"/api/str",
&HashMap::new(),
&HashMap::new(),
&incoming_obj
);
assert!(matched_str.is_some(), "应该支持将 YAML 字符串 body 转换为对象进行匹配");
// 5. 测试场景 D末尾斜杠兼容性测试
let matched_slash = router.match_rule(
"POST",
"/api/v1/login/",
&HashMap::new(),
&headers,
&incoming_body
);
assert!(matched_slash.is_some());
}

View File

@@ -1,318 +1,114 @@
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::Write; use std::io::Write;
use tempfile::tempdir; use tempfile::tempdir;
use serde_json::json; // 假设你的项目名在 Cargo.toml 中叫 mock_server
use mock_server::models::{MockRule, Payload}; use mock_server::config::MockSource;
use mock_server::loader::MockLoader; use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑
#[test] #[test]
fn test_config_parsing_scenarios() { fn test_config_deserialization() {
// 场景 A: 验证单接口配置 (增加 body 结构化校验) // 测试 1验证单接口 YAML 解析
let json_single = r#"{ let single_yaml = r#"
"name": "auth_v1", id: "auth_v1"
"request": { request:
"method": "POST", method: "POST"
"path": "/api/v1/login", path: "/api/v1/login"
"body": { "user": "admin" } response:
}, status: 200
"response": { "status": 200, "body": "welcome" } body: "inline_content"
}"#; "#;
let rule: MockRule = serde_json::from_str(json_single).expect("解析单接口失败"); let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口");
assert!(rule.request.body.is_some()); assert_eq!(res.flatten().len(), 1);
assert_eq!(rule.request.body.as_ref().unwrap()["user"], "admin"); // assert_eq!(res.flatten()[0].id, "auth_v1");
// 场景 B: 验证 Smart Body 的 file:// 协议字符串解析 // 测试 2验证多接口 YAML 数组解析
let json_file = r#"{ let multi_yaml = r#"
"name": "export_api", - id: "api_1"
"request": { "method": "GET", "path": "/download" }, request: { method: "GET", path: "/1" }
"response": { "status": 200, "body": "file://./storage/data.zip" } response: { status: 200, body: "b1" }
}"#; - id: "api_2"
let rule_file: MockRule = serde_json::from_str(json_file).unwrap(); request: { method: "GET", path: "/2" }
assert!(rule_file.response.body.starts_with("file://")); response: { status: 200, body: "b2" }
"#;
let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
assert_eq!(res_multi.flatten().len(), 2);
} }
/// 模块二:验证 Loader 递归扫描与索引构建
#[test] #[test]
fn test_loader_recursive_indexing() { fn test_recursive_loading_logic() {
let temp_root = tempdir().expect("无法创建临时目录"); // 创建临时 Mock 目录结构
let root_path = temp_root.path(); let root_dir = tempdir().expect("创建临时目录失败");
let root_path = root_dir.path();
let auth_path = root_path.join("v1/auth"); // 构造物理层级mocks/v1/user/
fs::create_dir_all(&auth_path).unwrap(); let user_dir = root_path.join("v1/user");
fs::create_dir_all(&user_dir).unwrap();
let mut f1 = File::create(auth_path.join("login.json")).unwrap(); // 在深层目录创建单接口文件
writeln!(f1, r#"{{"name":"l1","request":{{"method":"POST","path":"/api/v1/login"}},"response":{{"status":200,"body":"ok"}}}}"#).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 f2 = File::create(root_path.join("health.json")).unwrap(); // 在根目录创建多接口文件
writeln!(f2, r#"{{"name":"s1","request":{{"method":"GET","path":"/health"}},"response":{{"status":200,"body":"up"}}}}"#).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); let index = MockLoader::load_all_from_dir(root_path);
assert!(index.contains_key("api")); // 断言结果:
assert!(index.contains_key("health")); // 1. 检查 Key 是否根据路径首段正确提取(/api/v1/... -> api, /health -> health
let total: usize = index.values().map(|v| v.len()).sum(); assert!(index.contains_key("api"), "索引应包含 'api' 键");
assert_eq!(total, 2); assert!(index.contains_key("health"), "索引应包含 'health' 键");
} assert!(index.contains_key("version"), "索引应包含 'version' 键");
/// 模块三:验证 Router 匹配逻辑(使用新的 Payload 类型) // 2. 检查规则总数
#[test] let total_rules: usize = index.values().map(|v| v.len()).sum();
fn test_router_matching_logic() { assert_eq!(total_rules, 3, "总规则数应为 3");
let mut index = HashMap::new();
// 3. 检查深层文件是否被正确读取
// 1. 准备带有 Body 的规则 let api_rules = &index["api"];
let rule_json = r#"{ assert_eq!(api_rules[0].id, "user_profile");
"name": "auth_v1",
"request": {
"method": "POST",
"path": "/api/v1/login",
"headers": { "Content-Type": "application/json" },
"body": { "code": 123 }
},
"response": { "status": 200, "body": "token_123" }
}"#;
let rule_auth: MockRule = serde_json::from_str(rule_json).unwrap();
index.insert("api".to_string(), vec![rule_auth.clone()]);
let router = MockRouter::new(index);
// 2. 测试场景 AJSON 完全匹配
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let payload = Payload::Json(json!({ "code": 123 }));
let matched = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&payload,
);
assert!(matched.is_some());
assert_eq!(matched.unwrap().name, "auth_v1");
// 3. 测试场景 BBody 不匹配
let wrong_payload = Payload::Json(json!({ "code": 456 }));
let matched_fail = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&wrong_payload,
);
assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
// 4. 测试场景 C末尾斜杠兼容性测试
let matched_slash = router.match_rule(
"POST",
"/api/v1/login/",
&HashMap::new(),
&headers,
&payload,
);
assert!(matched_slash.is_some());
}
/// 模块四:验证不同 Payload 类型的匹配
#[test]
fn test_payload_type_matching() {
let mut index = HashMap::new();
// XML 规则
let xml_rule: MockRule = serde_json::from_str(r#"{
"name": "xml_api",
"request": {
"method": "POST",
"path": "/api/xml",
"body": "<root><name>test</name></root>"
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
// Form 规则
let form_rule: MockRule = serde_json::from_str(r#"{
"name": "form_api",
"request": {
"method": "POST",
"path": "/api/form",
"body": { "username": "admin", "password": "123456" }
},
"response": { "status": 200, "body": "login_ok" }
}"#).unwrap();
// Text 规则
let text_rule: MockRule = serde_json::from_str(r#"{
"name": "text_api",
"request": {
"method": "POST",
"path": "/api/text",
"body": "plain text content"
},
"response": { "status": 200, "body": "text_ok" }
}"#).unwrap();
index.insert("api".to_string(), vec![xml_rule, form_rule, text_rule]);
let router = MockRouter::new(index);
// 测试 XML 匹配
let xml_payload = Payload::Xml("<root><name>test</name></root>".to_string());
let xml_matched = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_payload);
assert!(xml_matched.is_some(), "XML 应该匹配");
assert_eq!(xml_matched.unwrap().name, "xml_api");
// 测试 Form 匹配
let mut form_data = HashMap::new();
form_data.insert("username".to_string(), "admin".to_string());
form_data.insert("password".to_string(), "123456".to_string());
let form_payload = Payload::Form(form_data);
let form_matched = router.match_rule("POST", "/api/form", &HashMap::new(), &HashMap::new(), &form_payload);
assert!(form_matched.is_some(), "Form 应该匹配");
assert_eq!(form_matched.unwrap().name, "form_api");
// 测试 Text 匹配
let text_payload = Payload::Text("plain text content".to_string());
let text_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &text_payload);
assert!(text_matched.is_some(), "Text 应该匹配");
assert_eq!(text_matched.unwrap().name, "text_api");
// 测试 None 不匹配
let none_payload = Payload::None;
let none_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &none_payload);
assert!(none_matched.is_none(), "None 不应该匹配有 body 的规则");
}
/// 模块五:验证 Payload 方法
#[test]
fn test_payload_methods() {
// 测试 content_type_name
assert_eq!(Payload::Json(json!({})).content_type_name(), "application/json");
assert_eq!(Payload::Xml("".to_string()).content_type_name(), "application/xml");
assert_eq!(Payload::Form(HashMap::new()).content_type_name(), "application/x-www-form-urlencoded");
assert_eq!(Payload::Multipart(HashMap::new()).content_type_name(), "multipart/form-data");
assert_eq!(Payload::Text("".to_string()).content_type_name(), "text/plain");
assert_eq!(Payload::None.content_type_name(), "none");
// 测试 to_compare_string
let json_payload = Payload::Json(json!({"a": 1, "b": 2}));
assert!(json_payload.to_compare_string().contains("\"a\":1"));
let text_payload = Payload::Text("hello world".to_string());
assert_eq!(text_payload.to_compare_string(), "hello world");
let none_payload = Payload::None;
assert_eq!(none_payload.to_compare_string(), "");
// 测试 Form 排序后的字符串
let mut form_map = HashMap::new();
form_map.insert("b".to_string(), "2".to_string());
form_map.insert("a".to_string(), "1".to_string());
let form_payload = Payload::Form(form_map);
assert_eq!(form_payload.to_compare_string(), "a=1&b=2");
// 测试 Multipart 排序后的字符串
let mut multipart_map = HashMap::new();
multipart_map.insert("b".to_string(), "2".to_string());
multipart_map.insert("a".to_string(), "1".to_string());
let multipart_payload = Payload::Multipart(multipart_map);
assert_eq!(multipart_payload.to_compare_string(), "a=1&b=2");
}
/// 模块六:验证 Multipart 匹配
#[test]
fn test_multipart_matching() {
let mut index = HashMap::new();
// 数组形式:只匹配字段名
let array_rule: MockRule = serde_json::from_str(r#"{
"name": "upload_array",
"request": {
"method": "POST",
"path": "/api/upload/array",
"body": ["file", "description"]
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
// 对象形式:匹配键名
let object_rule: MockRule = serde_json::from_str(r#"{
"name": "upload_object",
"request": {
"method": "POST",
"path": "/api/upload/object",
"body": { "username": "admin", "role": "user" }
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
index.insert("api".to_string(), vec![array_rule, object_rule]);
let router = MockRouter::new(index);
// 测试数组形式:匹配字段名
let mut full_data = HashMap::new();
full_data.insert("file".to_string(), "file_content".to_string());
full_data.insert("description".to_string(), "test file".to_string());
let full_payload = Payload::Multipart(full_data);
let matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &full_payload);
assert!(matched.is_some(), "包含所有字段应该匹配");
// 测试数组形式:缺少字段
let mut partial_data = HashMap::new();
partial_data.insert("file".to_string(), "file_content".to_string());
let partial_payload = Payload::Multipart(partial_data);
let not_matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &partial_payload);
assert!(not_matched.is_none(), "缺少字段不应该匹配");
// 测试对象形式:键名匹配(值被忽略)
let mut correct_data = HashMap::new();
correct_data.insert("username".to_string(), "any_value".to_string());
correct_data.insert("role".to_string(), "any_role".to_string());
let correct_payload = Payload::Multipart(correct_data);
let object_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &correct_payload);
assert!(object_matched.is_some(), "键名匹配应该成功");
// 测试对象形式:键名不匹配
let mut wrong_data = HashMap::new();
wrong_data.insert("username".to_string(), "admin".to_string());
wrong_data.insert("other_field".to_string(), "value".to_string());
let wrong_payload = Payload::Multipart(wrong_data);
let wrong_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &wrong_payload);
assert!(wrong_matched.is_none(), "缺少键名不应该成功");
}
/// 模块七:验证 XML 格式化匹配
#[test]
fn test_xml_normalized_matching() {
let mut index = HashMap::new();
// JSON 中的 XML紧凑格式
let xml_rule: MockRule = serde_json::from_str(r#"{
"name": "xml_api",
"request": {
"method": "POST",
"path": "/api/xml",
"body": "<user><id>1001</id><name>张三</name></user>"
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
index.insert("api".to_string(), vec![xml_rule]);
let router = MockRouter::new(index);
// 测试 1紧凑格式的 XML
let compact_xml = Payload::Xml("<user><id>1001</id><name>张三</name></user>".to_string());
let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml);
assert!(matched1.is_some(), "紧凑格式 XML 应该匹配");
// 测试 2带 XML 声明的请求
let xml_with_decl = Payload::Xml("<?xml version=\"1.0\" encoding=\"UTF-8\"?><user><id>1001</id><name>张三</name></user>".to_string());
let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl);
assert!(matched2.is_some(), "带声明的 XML 应该匹配");
// 测试 3内容不同不应该匹配
let wrong_xml = Payload::Xml("<user><id>1002</id><name>李四</name></user>".to_string());
let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml);
assert!(matched3.is_none(), "内容不同的 XML 不应该匹配");
} }

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);
});
}

View File

@@ -1,226 +0,0 @@
use std::path::Path;
use std::collections::HashMap;
use serde_json::json;
use mock_server::models::Payload;
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
/// 加载 v1 目录的 mock 规则
fn load_v1_mocks() -> HashMap<String, Vec<mock_server::models::MockRule>> {
let v1_path = Path::new("./mocks/v1");
MockLoader::load_all_from_dir(v1_path)
}
// ========== 模块一:验证所有 JSON 文件正确加载 ==========
#[test]
fn test_v1_load_all_mocks() {
let index = load_v1_mocks();
// 验证索引键存在
assert!(index.contains_key("v1"), "应包含 'v1' 索引键");
// 验证规则总数
let total: usize = index.values().map(|v| v.len()).sum();
assert!(total >= 10, "v1 目录应有至少 10 个 mock 规则");
}
// ========== 模块二JSON Payload 测试 ==========
#[test]
fn test_v1_json_login() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
headers.insert("Authorization".to_string(), "eyJhbGciOiJIUzI1NiIsInR5cCI6".to_string());
headers.insert("host".to_string(), "127.0.0.1:8080".to_string());
let payload = Payload::Json(json!({
"username": "user001",
"password": "password123"
}));
let matched = router.match_rule("POST", "/v1/auth/login", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "JSON 登录应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_login_001");
assert_eq!(rule.response.status, 200);
}
#[test]
fn test_v1_json_register() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let payload = Payload::Json(json!({
"username": "newuser",
"password": "newpass123",
"email": "newuser@example.com"
}));
let matched = router.match_rule("POST", "/v1/auth/register", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "JSON 注册应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_register");
assert_eq!(rule.response.status, 201);
}
// ========== 模块三Form Payload 测试 ==========
#[test]
fn test_v1_form_login() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/x-www-form-urlencoded".to_string());
let mut form_data = HashMap::new();
form_data.insert("username".to_string(), "formuser".to_string());
form_data.insert("password".to_string(), "formpass".to_string());
let payload = Payload::Form(form_data);
let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Form 登录应匹配成功");
assert_eq!(matched.unwrap().name, "_user_login_form");
}
// ========== 模块四Text Payload 测试 ==========
#[test]
fn test_v1_text_echo() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "text/plain".to_string());
let payload = Payload::Text("Hello V1 Mock Server".to_string());
let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Text 回显应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_echo");
assert!(rule.response.body.contains("Echo from V1"));
}
// ========== 模块五XML Payload 测试 ==========
#[test]
fn test_v1_xml_export() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/xml".to_string());
let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?><request><userId>10001</userId><format>xml</format></request>"#;
let payload = Payload::Xml(xml_body.to_string());
let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "XML 导出应匹配成功");
assert_eq!(matched.unwrap().name, "data_export");
}
// ========== 模块六Multipart Payload 测试 ==========
#[test]
fn test_v1_multipart_upload() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "multipart/form-data".to_string());
let mut multipart_data = HashMap::new();
multipart_data.insert("avatar1".to_string(), "file_content".to_string());
multipart_data.insert("description1".to_string(), "user avatar".to_string());
let payload = Payload::Multipart(multipart_data);
let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Multipart 上传应匹配成功");
assert_eq!(matched.unwrap().name, "user_upload_avatar_001");
}
// ========== 模块七None Payload 测试 (GET 无 Body) ==========
#[test]
fn test_v1_health_check() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/health", &HashMap::new(), &HashMap::new(), &payload);
assert!(matched.is_some(), "健康检查应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "health_check");
assert_eq!(rule.response.status, 200);
}
#[test]
fn test_v1_get_profile() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "获取用户信息应匹配成功");
assert_eq!(matched.unwrap().name, "user_profile");
}
// ========== 模块八Query Params 测试 ==========
#[test]
fn test_v1_query_params() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut query = HashMap::new();
query.insert("format".to_string(), "json".to_string());
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/user/download", &query, &HashMap::new(), &payload);
assert!(matched.is_some(), "带 query params 的下载应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_download");
assert!(rule.response.body.starts_with("file://"));
}
// ========== 模块九Header 匹配测试 ==========
#[test]
fn test_v1_header_required() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
// 无 Authorization header 不应匹配
let payload = Payload::None;
let no_match = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &HashMap::new(), &payload);
assert!(no_match.is_none(), "无 Authorization 不应匹配");
// 有正确的 Authorization header 应匹配
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "有 Authorization 应匹配");
}