From ab368ead1bb414d1b62d9cd13253a88c02bca831 Mon Sep 17 00:00:00 2001 From: CNWei Date: Thu, 19 Mar 2026 22:11:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .gitignore | 6 + CLAUDE.md | 228 +++++++++++++++++ Cargo.lock | 507 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 7 + mocks/example-api.yaml | 18 ++ src/handler.rs | 5 +- src/lib.rs | 3 +- src/main.rs | 21 +- src/upload.rs | 150 +++++++++++ tests/config_test.rs | 140 +++++++++++ tests/handler_test.rs | 441 +++++++++++++++++++++++++++++++++ tests/integration_test.rs | 2 +- tests/router_test.rs | 354 ++++++++++++++++++++++++++ tests/upload_test.rs | 246 ++++++++++++++++++ 14 files changed, 2113 insertions(+), 15 deletions(-) create mode 100644 CLAUDE.md create mode 100644 mocks/example-api.yaml create mode 100644 src/upload.rs create mode 100644 tests/config_test.rs create mode 100644 tests/handler_test.rs create mode 100644 tests/router_test.rs create mode 100644 tests/upload_test.rs diff --git a/.gitignore b/.gitignore index ac6822b..3d25264 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ # 日志文件 logs/ *.log + +# 上传文件存储目录 +storage/ + +# Claude Code 配置目录 +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e1370d5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,228 @@ +# Mock Server 项目指南 + +## 项目概述 + +基于 Rust + Axum 的配置驱动型 Mock 服务,支持 YAML 配置、请求匹配、延迟响应、大文件流式返回和文件上传等特性。 + +## 技术栈 + +- **语言**: Rust (Edition 2024) +- **Web 框架**: Axum 0.8.8 + axum-extra (multipart 支持) +- **异步运行时**: Tokio +- **序列化**: serde + serde_yaml + serde_json +- **工具库**: walkdir, uuid, chrono, tokio-util + +## 项目结构 + +``` +mock-server/ +├── src/ +│ ├── main.rs # 程序入口,路由配置,服务启动 +│ ├── lib.rs # 库模块导出 +│ ├── config.rs # 配置结构定义 (MockRule, MockResponse, MockSettings) +│ ├── loader.rs # YAML 配置加载器,递归扫描 mocks 目录 +│ ├── router.rs # 路由匹配引擎,基于路径首段索引 +│ ├── handler.rs # 请求处理器,统一处理所有 Mock 请求 +│ └── upload.rs # 文件上传处理模块 +├── tests/ # 集成测试 +│ ├── config_test.rs # 配置模块测试 +│ ├── handler_test.rs # 请求处理测试 +│ ├── integration_test.rs # 集成测试 +│ ├── loader_test.rs # 加载器测试 +│ ├── router_test.rs # 路由匹配测试 +│ └── upload_test.rs # 文件上传测试 +├── mocks/ # YAML Mock 配置文件目录 +├── storage/ # 上传文件存储目录 (按日期分类: YYYY-MM-DD) +└── Cargo.toml +``` + +## 核心模块说明 + +### config.rs - 配置结构 + +定义 Mock 规则的数据结构: +- `MockRule`: 完整的 Mock 规则(id, request, response, settings) +- `MockRequest`: 请求匹配条件(method, path, query_params, headers, body) +- `MockResponse`: 响应配置(status, headers, body) +- `MockSettings`: 额外设置(delay_ms 延迟) +- `MockSource`: 支持单接口和多接口 YAML 格式 + +**文件协议**: body 以 `file://` 开头时,从磁盘流式读取文件 + +### loader.rs - 配置加载器 + +- `MockLoader::load_all_from_dir()`: 递归扫描目录下的 .yaml/.yml 文件 +- 按路径首段建立索引(如 `/api/users` -> key: `api`) +- 支持单接口和多接口两种 YAML 格式 + +### router.rs - 路由匹配引擎 + +- 基于路径首段的 HashMap 快速索引 +- 线性深度匹配:method -> path -> query_params -> headers -> body +- 支持大小写不敏感的方法匹配 +- 支持尾部斜杠忽略 + +### handler.rs - 请求处理器 + +- 统一处理所有 HTTP 方法和路径 +- 支持延迟响应(settings.delay_ms) +- 支持文件流式响应(低内存占用) +- 匹配失败返回 404 + +### upload.rs - 文件上传 + +- 路由: `POST /api/upload` +- 支持 multipart/form-data 格式 +- 文件存储: `storage/YYYY-MM-DD/uuid.extension` +- 返回 JSON 格式的文件信息 + +## 常用命令 + +```bash +# 构建项目 +cargo build + +# 运行项目 +cargo run + +# 运行所有测试 +cargo test + +# 运行特定测试 +cargo test test_name + +# 检查代码 +cargo check + +# 格式化代码 +cargo fmt + +# 代码检查 +cargo clippy +``` + +## YAML 配置示例 + +### 单接口模式 + +```yaml +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": "xxx"}}' +settings: + delay_ms: 100 +``` + +### 多接口模式 + +```yaml +- id: "get-users" + request: + method: "GET" + path: "/api/users" + response: + status: 200 + body: '{"users": []}' + +- id: "create-user" + request: + method: "POST" + path: "/api/users" + response: + status: 201 + body: '{"id": 1}' +``` + +### 文件响应模式 + +```yaml +id: "download-file" +request: + method: "GET" + path: "/api/download" +response: + status: 200 + headers: + Content-Type: "application/pdf" + body: "file://./data/document.pdf" +``` + +## 开发规范 + +### 代码风格 + +- 使用 Rust 标准命名约定:snake_case for functions/variables, PascalCase for types +- 公开函数添加文档注释 `///` +- 错误处理使用 `Result` 和 `Option` +- 异步函数使用 `async/await` + +### 测试规范 + +- 每个模块对应一个测试文件 +- 测试函数命名:`test_<模块>_<场景>` +- 使用 `tempfile` crate 处理临时文件 +- 测试应该独立运行,不依赖执行顺序 + +### Git 提交规范 + +``` +feat: 新功能 +fix: 修复 bug +docs: 文档更新 +test: 测试相关 +refactor: 代码重构 +chore: 构建/工具变更 +``` + +## API 端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/upload` | POST | 文件上传 | +| `/*` | ANY | Mock 请求处理(由 YAML 配置定义) | + +## 文件上传响应格式 + +```json +{ + "success": true, + "files": [ + { + "original_name": "document.pdf", + "stored_name": "550e8400-e29b-41d4-a716-446655440000.pdf", + "path": "storage/2026-03-19/550e8400-e29b-41d4-a716-446655440000.pdf", + "size": 1024, + "content_type": "application/pdf" + } + ], + "message": "Files uploaded successfully" +} +``` + +## 扩展功能规划 + +- [ ] 热加载:配置文件变更自动重载 +- [ ] 动态占位符:支持 `{{timestamp}}`, `{{uuid}}` 等 +- [ ] 管理界面:Web UI 管理 Mock 规则 +- [ ] 请求录制:自动生成 Mock 配置 +- [ ] 条件匹配:基于请求体的复杂匹配规则 + +## 注意事项 + +1. **文件协议**: 使用 `file://` 时确保文件路径正确 +2. **延迟响应**: 仅用于测试,生产环境请移除 +3. **上传目录**: 确保 `storage/` 目录有写入权限 +4. **内存限制**: 请求体限制为 10MB diff --git a/Cargo.lock b/Cargo.lock index 659de77..1539229 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.8" @@ -60,24 +81,92 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "fastrand", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -100,6 +189,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -169,16 +270,44 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -261,6 +390,36 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.12.1" @@ -268,7 +427,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -277,12 +438,28 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.178" @@ -344,6 +521,8 @@ name = "mock_server" version = "0.1.0" dependencies = [ "axum", + "axum-extra", + "chrono", "futures-util", "serde", "serde_json", @@ -353,9 +532,27 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "uuid", "walkdir", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -365,6 +562,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -412,6 +618,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -436,6 +652,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -458,6 +680,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.21" @@ -479,6 +707,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -567,6 +801,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.7" @@ -598,6 +838,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "syn" version = "2.0.111" @@ -622,7 +868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -770,18 +1016,42 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -804,7 +1074,95 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] @@ -816,12 +1174,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -911,6 +1322,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 5b9b191..9fdccff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] # 核心 Web 框架 axum = "0.8.8" +axum-extra = { version = "0.10", features = ["multipart"] } # 异步运行时 tokio={version = "1.48.0",features = ["full"]} # 异步文件操作与流处理工具 @@ -20,6 +21,12 @@ serde_json = "1.0.147" # 物理目录递归扫描工具 walkdir = "2.5.0" +# UUID 生成(用于唯一文件名) +uuid = { version = "1.0", features = ["v4", "serde"] } + +# 日期时间处理 +chrono = "0.4" + tracing="0.1.44" tracing-subscriber = "0.3.22" diff --git a/mocks/example-api.yaml b/mocks/example-api.yaml new file mode 100644 index 0000000..772dcbc --- /dev/null +++ b/mocks/example-api.yaml @@ -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 diff --git a/src/handler.rs b/src/handler.rs index 17029ca..f6e0d4f 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -23,13 +23,10 @@ pub async fn mock_handler( Query(params): Query>, req: Request, ) -> impl IntoResponse { - // let path = req.uri().path(); - // 1. 【关键】将需要的数据克隆出来,断开与 req 的借用关系 let path = req.uri().path().to_string(); let method_str = method.as_str().to_string(); - // 1. 将 Axum HeaderMap 转换为简单的 HashMap 供 Router 使用 - // 2. 现在可以安全地消耗 req 了,因为上面没有指向 req 内部的引用了 + // 将 Axum HeaderMap 转换为简单的 HashMap 供 Router 使用 let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await { Ok(bytes) => bytes, Err(_) => { diff --git a/src/lib.rs b/src/lib.rs index e13f385..2d6045e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,5 @@ pub mod config; pub mod loader; pub mod router; -pub mod handler; \ No newline at end of file +pub mod handler; +pub mod upload; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index ef70ecd..8436f8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; -use axum::{routing::any, Router}; +use axum::{routing::{any, post}, Router}; use mock_server::loader::MockLoader; use mock_server::router::MockRouter; use mock_server::handler::{mock_handler, AppState}; +use mock_server::upload::upload_handler; #[tokio::main] async fn main() { @@ -22,21 +23,31 @@ async fn main() { println!("Scanning mocks directory..."); let index = MockLoader::load_all_from_dir(mocks_dir); - // 3. 构建路由引擎并包装为共享状态 + // 3. 确保 storage 目录存在 + let storage_dir = Path::new("./storage"); + if !storage_dir.exists() { + println!("Creating storage directory..."); + std::fs::create_dir_all(storage_dir).unwrap(); + } + + // 4. 构建路由引擎并包装为共享状态 let router_engine = MockRouter::new(index); let shared_state = Arc::new(AppState { router: router_engine, }); - // 4. 配置 Axum 路由 - // 使用 any(mock_handler) 意味着它会接管所有 HTTP 方法和所有路径的请求 + // 5. 配置 Axum 路由 + // 文件上传路由:POST /api/upload + // 其他所有请求由 mock_handler 处理 let app = Router::new() + .route("/api/upload", post(upload_handler)) .fallback(any(mock_handler)) .with_state(shared_state); - // 5. 启动服务 + // 6. 启动服务 let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); println!("🚀 Rust Mock Server is running on http://{}", addr); + println!("📁 File upload endpoint: POST http://{}/api/upload", addr); println!("Ready to handle requests based on your YAML definitions."); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); diff --git a/src/upload.rs b/src/upload.rs new file mode 100644 index 0000000..caacb61 --- /dev/null +++ b/src/upload.rs @@ -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, + 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, +} + +impl UploadResult { + pub fn success(files: Vec) -> 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>, + 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) + } +} diff --git a/tests/config_test.rs b/tests/config_test.rs new file mode 100644 index 0000000..64a7a41 --- /dev/null +++ b/tests/config_test.rs @@ -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: "Hello".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") + ); +} diff --git a/tests/handler_test.rs b/tests/handler_test.rs new file mode 100644 index 0000000..addf862 --- /dev/null +++ b/tests/handler_test.rs @@ -0,0 +1,441 @@ +use std::collections::HashMap; +use std::sync::Arc; +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 { + 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::(&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 }) +} + +#[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); + }); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 5355fae..379216d 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -3,7 +3,7 @@ use std::io::Write; use tempfile::tempdir; // 确保 Cargo.toml 中有 serde_json use serde_json::json; -use mock_server::config::{MockRule, MockSource}; +use mock_server::config::MockSource; use mock_server::loader::MockLoader; use mock_server::router::MockRouter; use std::collections::HashMap; diff --git a/tests/router_test.rs b/tests/router_test.rs new file mode 100644 index 0000000..bd051d1 --- /dev/null +++ b/tests/router_test.rs @@ -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::( + 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::( + 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::( + 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::( + 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::( + 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::( + 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::( + 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::( + r#" + id: "rule1" + request: + method: "GET" + path: "/api/users" + response: { status: 200, body: "users" } + "#, + ) + .unwrap() + .flatten(); + + let rule2 = serde_yaml::from_str::( + 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::( + 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::( + 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::( + 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()); +} diff --git a/tests/upload_test.rs b/tests/upload_test.rs new file mode 100644 index 0000000..a4ab544 --- /dev/null +++ b/tests/upload_test.rs @@ -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 { + 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 = (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); + }); +}