diff --git a/.claude/rules/mock-spec.md b/.claude/rules/mock-spec.md index 34ad8e6..2ec58b2 100644 --- a/.claude/rules/mock-spec.md +++ b/.claude/rules/mock-spec.md @@ -1,26 +1,26 @@ --- paths: - - "mocks/**/*.{yml,yaml}" + - "mocks/**/*.json" --- -## YAML 配置规范 +## JSON 配置规范 AI 在生成 Mock 规则时必须遵循以下格式。 ### 字段说明 -| 字段 | 类型 | 必填 | 说明 | -|------|------|:----:|------| -| id | string | 是 | 规则唯一标识 | -| request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) | -| request.path | string | 是 | 请求路径,精确匹配 | -| request.query_params | object | 否 | 查询参数匹配 | -| request.headers | object | 否 | 请求头匹配(大小写不敏感) | -| request.body | any | 否 | 请求体匹配,支持 JSON 对象或字符串 | -| response.status | number | 是 | HTTP 状态码 | -| response.headers | object | 否 | 响应头 | -| response.body | string | 是 | 响应体,支持 file:// 前缀 | -| settings.delay_ms | number | 否 | 延迟响应(毫秒) | +| 字段 | 类型 | 必填 | 说明 | +|----------------------|--------|:--:|----------------------------------| +| name | string | 是 | 规则唯一标识 | +| request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) | +| request.path | string | 是 | 请求路径,精确匹配 | +| request.query_params | object | 否 | 查询参数匹配 | +| request.headers | object | 否 | 请求头匹配(大小写不敏感) | +| request.body | any | 否 | 请求体匹配,支持 JSON 对象或字符串 | +| response.status | number | 是 | HTTP 状态码 | +| response.headers | object | 否 | 响应头 | +| response.body | string | 是 | 响应体,支持 file:// 前缀 | +| settings.delay_ms | number | 否 | 延迟响应(毫秒) | ### 匹配规则 @@ -30,58 +30,91 @@ AI 在生成 Mock 规则时必须遵循以下格式。 ### 配置示例 -**单接口模式:** +**基础示例:** -```yaml -id: "login" -request: - method: "POST" - path: "/api/login" - body: { "username": "test" } -response: - status: 200 - body: '{"token": "xxx"}' -settings: - delay_ms: 100 -``` - -**多接口模式(数组):** - -```yaml -- id: "get-users" - request: { method: "GET", path: "/api/users" } - response: { status: 200, body: '{"users": []}' } - -- id: "create-user" - request: { method: "POST", path: "/api/users" } - response: { status: 201, body: '{"id": 1}' } +```json +{ + "name": "login", + "request": { + "method": "POST", + "path": "/v1/auth/login", + "body": { + "username": "user001", + "password": "password123" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"token\": \"xxx\"}" + }, + "settings": { + "delay_ms": 100 + } +} ``` **带查询参数和请求头:** -```yaml -id: "search-users" -request: - method: "GET" - path: "/api/users" - query_params: { "role": "admin" } - headers: { "Authorization": "Bearer token" } -response: - status: 200 - headers: { "X-Total-Count": "100" } - body: '{"users": []}' +```json +{ + "name": "search_users", + "request": { + "method": "GET", + "path": "/v1/users", + "query_params": { + "role": "admin" + }, + "headers": { + "Authorization": "Bearer token" + } + }, + "response": { + "status": 200, + "headers": { + "X-Total-Count": "100" + }, + "body": "{\"users\": []}" + } +} ``` **文件响应:** -```yaml -id: "download-pdf" -request: - method: "GET" - path: "/api/download" -response: - status: 200 - body: "file://./data/large-file.pdf" +```json +{ + "name": "download_pdf", + "request": { + "method": "GET", + "path": "/v1/download" + }, + "response": { + "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://` 支持两种路径: diff --git a/Cargo.lock b/Cargo.lock index 607bd6e..1dd57d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,27 @@ # 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 = "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,6 +75,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -72,12 +93,28 @@ 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.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -85,10 +122,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "equivalent" -version = "1.0.2" +name = "chrono" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "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 = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "errno" @@ -106,6 +163,12 @@ 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 = "form_urlencoded" version = "1.2.2" @@ -124,6 +187,21 @@ dependencies = [ "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]] name = "futures-channel" version = "0.3.31" @@ -131,6 +209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -139,6 +218,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures-macro" version = "0.3.31" @@ -168,9 +264,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -188,12 +288,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "http" version = "1.4.0" @@ -277,13 +371,27 @@ dependencies = [ ] [[package]] -name = "indexmap" -version = "2.12.1" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "equivalent", - "hashbrown", + "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]] @@ -312,6 +420,16 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -403,9 +521,10 @@ dependencies = [ "futures-util", "notify", "notify-debouncer-mini", + "rmcp", + "schemars", "serde", "serde_json", - "serde_yaml", "tempfile", "tokio", "tokio-util", @@ -459,6 +578,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" @@ -488,6 +616,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -539,6 +673,38 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "rmcp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +dependencies = [ + "base64", + "chrono", + "futures", + "paste", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "rustix" version = "1.1.3" @@ -552,6 +718,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" @@ -567,6 +739,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -603,6 +799,17 @@ dependencies = [ "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]] name = "serde_json" version = "1.0.147" @@ -639,19 +846,6 @@ dependencies = [ "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]] name = "sharded-slab" version = "0.1.7" @@ -661,6 +855,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" @@ -722,6 +922,26 @@ dependencies = [ "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]] name = "thread_local" version = "1.1.9" @@ -864,12 +1084,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "valuable" version = "0.1.1" @@ -901,6 +1115,51 @@ dependencies = [ "wit-bindgen", ] +[[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 = "winapi-util" version = "0.1.11" @@ -910,12 +1169,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" diff --git a/Cargo.toml b/Cargo.toml index f831bc6..749b15e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "mock_server" version = "0.1.0" edition = "2024" +[features] +default = [] +mcp = ["rmcp", "schemars"] + [dependencies] # 核心 Web 框架 axum = "0.8.8" @@ -12,9 +16,8 @@ tokio={version = "1.48.0",features = ["full"]} tokio-util = {version = "0.7.17",features = ["io"]} futures-util = "0.3.31" -# 序列化与 YAML 解析 +# 序列化 serde = {version = "1.0.228",features = ["derive"]} -serde_yaml = "0.9.34+deprecated" serde_json = "1.0.147" # 物理目录递归扫描工具 @@ -23,13 +26,13 @@ walkdir = "2.5.0" tracing="0.1.44" tracing-subscriber = "0.3.22" -# 性能优化:快速哈希(可选,用于路由匹配) -#dashmap = "7.0.0-rc2" # 热加载支持(扩展功能) notify = "8.2.0" notify-debouncer-mini = "0.6.0" -# 路径处理 -#pathdiff = "0.2.3" + +# MCP Server 支持(可选) +rmcp = { version = "0.1", features = ["server"], optional = true } +schemars = { version = "0.8", optional = true } [dev-dependencies] tempfile = "3.24.0" \ No newline at end of file diff --git a/mocks/v1/auth/login_001.json b/mocks/v1/auth/login_001.json new file mode 100644 index 0000000..23d70af --- /dev/null +++ b/mocks/v1/auth/login_001.json @@ -0,0 +1,26 @@ +{ + "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 + } +} diff --git a/mocks/v1/auth/login_002.json b/mocks/v1/auth/login_002.json new file mode 100644 index 0000000..8a42929 --- /dev/null +++ b/mocks/v1/auth/login_002.json @@ -0,0 +1,26 @@ +{ + "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 + } +} diff --git a/mocks/v1/auth/login_003.json b/mocks/v1/auth/login_003.json new file mode 100644 index 0000000..1c00438 --- /dev/null +++ b/mocks/v1/auth/login_003.json @@ -0,0 +1,26 @@ +{ + "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 + } +} diff --git a/mocks/v1/auth/login_004.json b/mocks/v1/auth/login_004.json new file mode 100644 index 0000000..e384bc0 --- /dev/null +++ b/mocks/v1/auth/login_004.json @@ -0,0 +1,23 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/auth/login_005.json b/mocks/v1/auth/login_005.json new file mode 100644 index 0000000..ce51d19 --- /dev/null +++ b/mocks/v1/auth/login_005.json @@ -0,0 +1,23 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/auth/multiple_login.yaml b/mocks/v1/auth/multiple_login.yaml deleted file mode 100644 index 00c6ce5..0000000 --- a/mocks/v1/auth/multiple_login.yaml +++ /dev/null @@ -1,118 +0,0 @@ -# 用户登录 - JSON 格式 -- 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 # 模拟真实网络延迟 - -- 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 # 模拟真实网络延迟 - -- 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" - } - } - -- 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" - } - } \ No newline at end of file diff --git a/mocks/v1/auth/register.json b/mocks/v1/auth/register.json new file mode 100644 index 0000000..aed3a98 --- /dev/null +++ b/mocks/v1/auth/register.json @@ -0,0 +1,22 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/auth/register.yaml b/mocks/v1/auth/register.yaml deleted file mode 100644 index af5a64e..0000000 --- a/mocks/v1/auth/register.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# 用户注册 - JSON 格式 -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" - } - } diff --git a/mocks/v1/auth/single_login.yaml b/mocks/v1/auth/single_login.yaml deleted file mode 100644 index 19000e5..0000000 --- a/mocks/v1/auth/single_login.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# 用户登录 - JSON 格式 -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 # 模拟真实网络延迟 diff --git a/mocks/v1/data/export.json b/mocks/v1/data/export.json new file mode 100644 index 0000000..3ba3772 --- /dev/null +++ b/mocks/v1/data/export.json @@ -0,0 +1,18 @@ +{ + "name": "data_export", + "request": { + "method": "POST", + "path": "/v1/data/export", + "headers": { + "Content-Type": "application/xml" + }, + "body": "10001xml" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/xml" + }, + "body": "0导出成功10001管理员admin@example.com" + } +} diff --git a/mocks/v1/data/export.yaml b/mocks/v1/data/export.yaml deleted file mode 100644 index bcdc0bb..0000000 --- a/mocks/v1/data/export.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# XML 数据导出 - XML 格式 -name: "data_export" -request: - method: "POST" - path: "/v1/data/export" - headers: - Content-Type: "application/xml" - body: | - - - 10001 - xml - -response: - status: 200 - headers: - Content-Type: "application/xml" - body: | - - - 0 - 导出成功 - - - 10001 - 管理员 - admin@example.com - - - diff --git a/mocks/v1/health.json b/mocks/v1/health.json new file mode 100644 index 0000000..8bab80f --- /dev/null +++ b/mocks/v1/health.json @@ -0,0 +1,14 @@ +{ + "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\"}" + } +} diff --git a/mocks/v1/health.yaml b/mocks/v1/health.yaml deleted file mode 100644 index 688a534..0000000 --- a/mocks/v1/health.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 健康检查 - GET 无 Body -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" - } diff --git a/mocks/v1/products/export.yaml b/mocks/v1/products/export.yaml deleted file mode 100644 index ea6abca..0000000 --- a/mocks/v1/products/export.yaml +++ /dev/null @@ -1,12 +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" \ No newline at end of file diff --git a/mocks/v1/products/export_pdf.json b/mocks/v1/products/export_pdf.json new file mode 100644 index 0000000..d5eb5e2 --- /dev/null +++ b/mocks/v1/products/export_pdf.json @@ -0,0 +1,16 @@ +{ + "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" + } +} diff --git a/mocks/v1/user/avatar.yaml b/mocks/v1/user/avatar.yaml deleted file mode 100644 index 1337114..0000000 --- a/mocks/v1/user/avatar.yaml +++ /dev/null @@ -1,73 +0,0 @@ -# 上传头像 - Multipart 格式(数组形式,只匹配字段名) -- 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" - } - } - -- 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" - } - } -- 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" - } - } \ No newline at end of file diff --git a/mocks/v1/user/avatar_001.json b/mocks/v1/user/avatar_001.json new file mode 100644 index 0000000..a579097 --- /dev/null +++ b/mocks/v1/user/avatar_001.json @@ -0,0 +1,18 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/user/avatar_002.json b/mocks/v1/user/avatar_002.json new file mode 100644 index 0000000..31ee1c3 --- /dev/null +++ b/mocks/v1/user/avatar_002.json @@ -0,0 +1,21 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/user/avatar_003.json b/mocks/v1/user/avatar_003.json new file mode 100644 index 0000000..09e4e26 --- /dev/null +++ b/mocks/v1/user/avatar_003.json @@ -0,0 +1,21 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/user/download.json b/mocks/v1/user/download.json new file mode 100644 index 0000000..dc02dfe --- /dev/null +++ b/mocks/v1/user/download.json @@ -0,0 +1,18 @@ +{ + "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" + } +} diff --git a/mocks/v1/user/download.yaml b/mocks/v1/user/download.yaml deleted file mode 100644 index ef8a024..0000000 --- a/mocks/v1/user/download.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# 下载用户数据文件 - file:// 协议 -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" diff --git a/mocks/v1/user/echo.json b/mocks/v1/user/echo.json new file mode 100644 index 0000000..1b06a6d --- /dev/null +++ b/mocks/v1/user/echo.json @@ -0,0 +1,18 @@ +{ + "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" + } +} diff --git a/mocks/v1/user/echo.yaml b/mocks/v1/user/echo.yaml deleted file mode 100644 index a83478d..0000000 --- a/mocks/v1/user/echo.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# 文本回显 - Text 格式 -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" diff --git a/mocks/v1/user/login_form.json b/mocks/v1/user/login_form.json new file mode 100644 index 0000000..c19eb86 --- /dev/null +++ b/mocks/v1/user/login_form.json @@ -0,0 +1,21 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/user/login_form.yaml b/mocks/v1/user/login_form.yaml deleted file mode 100644 index ea9a072..0000000 --- a/mocks/v1/user/login_form.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# 表单登录 - Form 格式 -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" - } - } diff --git a/mocks/v1/user/profile.json b/mocks/v1/user/profile.json new file mode 100644 index 0000000..98c4778 --- /dev/null +++ b/mocks/v1/user/profile.json @@ -0,0 +1,17 @@ +{ + "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\"}}" + } +} diff --git a/mocks/v1/user/profile.yaml b/mocks/v1/user/profile.yaml deleted file mode 100644 index 3c8277e..0000000 --- a/mocks/v1/user/profile.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# 获取用户信息 - GET 无 Body,需要 Authorization Header -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" - } - } diff --git a/src/handler.rs b/src/handler.rs index a91ea5e..8354acf 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -5,7 +5,7 @@ use axum::{ response::{IntoResponse, Response}, }; use std::collections::HashMap; -use std::sync::{Arc, RwLock}; // 必须引入 RwLock +use std::sync::{Arc, RwLock}; use tokio_util::io::ReaderStream; use crate::models::Payload; diff --git a/src/lib.rs b/src/lib.rs index 8753663..af0dcdf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,8 @@ pub mod models; pub mod loader; pub mod router; -pub mod handler; \ No newline at end of file +pub mod handler; +pub mod manager; + +#[cfg(feature = "mcp")] +pub mod mcp; \ No newline at end of file diff --git a/src/loader.rs b/src/loader.rs index 6cba720..8758aaa 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -1,31 +1,34 @@ use std::collections::HashMap; use std::fs; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖 +use std::path::Path; +use walkdir::WalkDir; -use crate::models::{MockRule, MockSource}; +use crate::models::MockRule; pub struct MockLoader; impl MockLoader { /// 递归扫描指定目录并构建索引表 + /// 目录结构:mocks/{group}/{rule}.json pub fn load_all_from_dir(dir: &Path) -> HashMap> { let mut index: HashMap> = HashMap::new(); - // 1. 使用 walkdir 递归遍历目录,不限层级 + if !dir.exists() { + return index; + } + + // 1. 使用 walkdir 递归遍历目录,寻找 JSON 文件 for entry in WalkDir::new(dir) .into_iter() .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml")) + .filter(|e| e.path().extension().map_or(false, |ext| ext == "json")) { - if let Some(rules) = Self::parse_yaml_file(entry.path()) { - for rule in rules { - // 2. 提取路径首段作为索引 Key - let key = Self::extract_first_segment(&rule.request.path); + if let Some(rule) = Self::parse_json_file(entry.path()) { + // 2. 提取路径首段作为索引 Key + let key = Self::extract_first_segment(&rule.request.path); - // 3. 将规则插入到对应的索引桶中 - index.entry(key).or_insert_with(Vec::new).push(rule); - } + // 3. 将规则插入到对应的索引桶中 + index.entry(key).or_insert_with(Vec::new).push(rule); } } @@ -33,15 +36,20 @@ impl MockLoader { index } - /// 解析单个 YAML 文件,支持单接口和多接口模式 - fn parse_yaml_file(path: &Path) -> Option> { - let content = fs::read_to_string(path).ok()?; - - // 利用 serde_yaml 的反序列化能力处理 MockSource 枚举 - match serde_yaml::from_str::(&content) { - Ok(source) => Some(source.flatten()), // 统一打平为 Vec + /// 解析单个 JSON 文件 + fn parse_json_file(path: &Path) -> Option { + match fs::read_to_string(path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(rule) => Some(rule), + Err(e) => { + eprintln!("Failed to parse JSON at {:?}: {}", path, e); + None + } + } + } Err(e) => { - eprintln!("Failed to parse YAML at {:?}: {}", path, e); + eprintln!("Failed to read file {:?}: {}", path, e); None } } @@ -55,4 +63,4 @@ impl MockLoader { .unwrap_or("root") // 如果是根路径 "/",则归类到 "root" .to_string() } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 7f8fa92..9444cc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,47 +1,136 @@ use std::net::SocketAddr; -use std::path::Path; -use std::sync::{Arc, RwLock}; +use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; + use axum::{routing::any, Router}; -use notify_debouncer_mini::{new_debouncer, notify::*}; +use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode}; use mock_server::loader::MockLoader; use mock_server::router::MockRouter; use mock_server::handler::{mock_handler, AppState}; +fn print_usage() { + println!("Mock Server - A mock API server with hot-reload support"); + println!(); + println!("Usage: mock_server [OPTIONS]"); + println!(); + println!("Options:"); + println!(" --mcp Run as MCP server (stdio transport)"); + println!(" --mocks Mocks directory path (default: ./mocks)"); + println!(" --port Server port (default: 8080)"); + println!(" --help Show this help message"); +} + #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); - let mocks_dir = Path::new("./mocks"); - if !mocks_dir.exists() { - std::fs::create_dir_all(mocks_dir).unwrap(); + // 解析命令行参数 + let args: Vec = std::env::args().collect(); + let mut mocks_dir = PathBuf::from("./mocks"); + let mut port: u16 = 8080; + + #[cfg(feature = "mcp")] + let mut mcp_mode = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--help" | "-h" => { + print_usage(); + return; + } + "--mcp" => { + #[cfg(feature = "mcp")] + { + mcp_mode = true; + } + #[cfg(not(feature = "mcp"))] + { + eprintln!("MCP feature not enabled. Rebuild with --features mcp"); + std::process::exit(1); + } + } + "--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::() { + 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. 初始加载 + if !mocks_dir.exists() { + std::fs::create_dir_all(&mocks_dir).unwrap(); + } + + #[cfg(feature = "mcp")] + if mcp_mode { + run_mcp_server(mocks_dir).await; + return; + } + + run_http_server(mocks_dir, port).await; +} + +#[cfg(feature = "mcp")] +async fn run_mcp_server(mocks_dir: PathBuf) { + println!("Starting MCP server..."); + let manager = Arc::new(mock_server::manager::MockManager::new(mocks_dir)); + println!("Loaded {} groups", manager.list_groups().len()); + + match mock_server::mcp::run_mcp_server(manager).await { + Ok(_) => println!("MCP server stopped"), + Err(e) => eprintln!("MCP server error: {}", e), + } +} + +async fn run_http_server(mocks_dir: PathBuf, port: u16) { println!("Scanning mocks directory..."); - let index = MockLoader::load_all_from_dir(mocks_dir); + let index = MockLoader::load_all_from_dir(&mocks_dir); let shared_state = Arc::new(AppState { - router: RwLock::new(MockRouter::new(index)), + router: std::sync::RwLock::new(MockRouter::new(index)), }); - // 2. 设置热加载监听器 + // 设置热加载监听器 let state_for_watcher = shared_state.clone(); - let watch_path = mocks_dir.to_path_buf(); + let watch_path = mocks_dir.clone(); let (tx, rx) = std::sync::mpsc::channel(); - // 200ms 防抖,防止编辑器保存文件时产生多次干扰 let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap(); debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap(); - // 启动异步任务监听文件变动 tokio::spawn(async move { while let Ok(res) = rx.recv() { match res { Ok(_) => { println!("🔄 Detecting changes in mocks/, reloading..."); let new_index = MockLoader::load_all_from_dir(&watch_path); - // 获取写锁 (Write Lock) 更新索引 let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock"); *writer = MockRouter::new(new_index); println!("✅ Mocks reloaded successfully."); @@ -51,14 +140,13 @@ async fn main() { } }); - // 3. 配置 Axum 路由 let app = Router::new() .fallback(any(mock_handler)) .with_state(shared_state); - let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); println!("🚀 Server running at http://{}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); -} \ No newline at end of file +} diff --git a/src/manager.rs b/src/manager.rs new file mode 100644 index 0000000..3b11326 --- /dev/null +++ b/src/manager.rs @@ -0,0 +1,208 @@ +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 + groups: RwLock>>, + /// 路由索引 + router: RwLock, +} + +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>) -> HashMap> { + let mut index: HashMap> = 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 { + 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 { + &self.router + } + + /// 列出所有分组 + pub fn list_groups(&self) -> Vec { + let groups = self.groups.read().unwrap(); + groups.keys().cloned().collect() + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..a944085 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,3 @@ +mod server; + +pub use server::run_mcp_server; diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000..f6d4933 --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,159 @@ +use rmcp::{ + handler::server::tool::ToolRouter, + model::*, + tool, tool_handler, tool_router, + ServerHandler, ServiceExt, + transport::stdio, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::manager::MockManager; +use crate::models::MockRule; + +/// MCP Server for Mock Server management +#[derive(Clone)] +pub struct MockMcpServer { + manager: Arc, + tool_router: ToolRouter, +} + +#[tool_router] +impl MockMcpServer { + pub fn new(manager: Arc) -> Self { + Self { + manager, + tool_router: Self::tool_router(), + } + } + + #[tool(description = "List all mock rules, optionally filtered by group")] + async fn list_mock_rules( + &self, + #[schemars(description = "Optional group name to filter by")] + group: Option, + ) -> Result { + let rules = self.manager.list(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, + #[schemars(description = "Group name (directory name)")] + group: String, + #[schemars(description = "Rule name")] + name: String, + ) -> Result { + match self.manager.get(&group, &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: {}/{}", group, name), + )])), + } + } + + #[tool(description = "Create a new mock rule")] + async fn create_mock_rule( + &self, + #[schemars(description = "Group name (directory name)")] + group: String, + #[schemars(description = "Mock rule definition (JSON object)")] + rule_json: String, + ) -> Result { + let rule: MockRule = serde_json::from_str(&rule_json) + .map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?; + + match self.manager.create(&group, rule) { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + format!("Created rule in group '{}'", group), + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])), + } + } + + #[tool(description = "Update an existing mock rule")] + async fn update_mock_rule( + &self, + #[schemars(description = "Group name")] + group: String, + #[schemars(description = "Current rule name")] + name: String, + #[schemars(description = "Updated rule definition (JSON object)")] + rule_json: String, + ) -> Result { + let rule: MockRule = serde_json::from_str(&rule_json) + .map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?; + + match self.manager.update(&group, &name, rule) { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + format!("Updated rule {}/{}", group, name), + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])), + } + } + + #[tool(description = "Delete a mock rule")] + async fn delete_mock_rule( + &self, + #[schemars(description = "Group name")] + group: String, + #[schemars(description = "Rule name")] + name: String, + ) -> Result { + match self.manager.delete(&group, &name) { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + format!("Deleted rule {}/{}", group, 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 { + self.manager.reload(); + Ok(CallToolResult::success(vec![Content::text( + "Reloaded all rules from disk", + )])) + } + + #[tool(description = "List all groups (directories)")] + async fn list_groups(&self) -> Result { + 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 { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder() + .enable_tools() + .build(), + server_info: Implementation { + name: "mock-server".to_string(), + version: "0.1.0".to_string(), + }, + 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()), + } + } +} + +/// Run MCP server with stdio transport +pub async fn run_mcp_server(manager: Arc) -> Result<(), Box> { + let server = MockMcpServer::new(manager); + let service = server.serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/src/models.rs b/src/models.rs index 60e1c2f..8a5ebda 100644 --- a/src/models.rs +++ b/src/models.rs @@ -46,26 +46,6 @@ impl Payload { } } -/// 顶层包装:支持单对象或数组,自动打平 -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum MockSource { - /// 对应"一个接口一个文件"模式 - Single(MockRule), - /// 对应"一个文件多个接口"模式 - Multiple(Vec), -} - -impl MockSource { - /// 将不同的解析模式统一转化为列表,供 Loader 构建索引 - pub fn flatten(self) -> Vec { - match self { - Self::Single(rule) => vec![rule], - Self::Multiple(rules) => rules, - } - } -} - /// 核心 Mock 规则定义 #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct MockRule { diff --git a/src/router.rs b/src/router.rs index 132a94c..d0b5648 100644 --- a/src/router.rs +++ b/src/router.rs @@ -88,45 +88,49 @@ impl MockRouter { } // D. Body 匹配(根据 Payload 类型智能比较) - if let Some(ref yaml_body) = rule.request.body { - return self.match_body(yaml_body, payload); + if let Some(ref rule_body) = rule.request.body { + return self.match_body(rule_body, payload); } true } /// Body 匹配逻辑 - fn match_body(&self, yaml_body: &serde_json::Value, payload: &Payload) -> bool { - // 将 YAML body 规范化:如果是字符串,尝试解析为 JSON - let normalized_body = normalize_yaml_body(yaml_body); - + fn match_body(&self, rule_body: &serde_json::Value, payload: &Payload) -> bool { match payload { Payload::Json(actual) => { - // JSON 对象比较 - &normalized_body == actual + // 如果 rule_body 是字符串,尝试解析为 JSON 后比较 + if let Some(rule_str) = rule_body.as_str() { + // 尝试将字符串解析为 JSON + if let Ok(parsed_rule) = serde_json::from_str::(rule_str) { + return &parsed_rule == actual; + } + } + // 直接比较 + rule_body == actual } Payload::Xml(actual) => { // XML 字符串比较(规范化后比较) - normalized_body.as_str() + rule_body.as_str() .map(|expected| normalize_xml(expected) == normalize_xml(actual)) .unwrap_or(false) } Payload::Form(actual) => { // Form 键值对比较(子集匹配) - compare_form_with_yaml(&normalized_body, actual) + compare_form_with_json(rule_body, actual) } Payload::Multipart(actual_data) => { // Multipart 匹配:支持键值对或字段名列表 - compare_multipart_with_yaml(&normalized_body, actual_data) + compare_multipart_with_json(rule_body, actual_data) } Payload::Text(actual) => { // 字符串比较(去掉首尾空白) - normalized_body.as_str() + rule_body.as_str() .map(|expected| expected.trim() == actual.trim()) - .unwrap_or_else(|| normalized_body.to_string().trim() == actual.trim()) + .unwrap_or_else(|| rule_body.to_string().trim() == actual.trim()) } Payload::None => { - false // YAML 配置了 body,但请求没有 body + false // 配置了 body,但请求没有 body } } } @@ -141,18 +145,6 @@ impl MockRouter { } } -/// 规范化 YAML body:如果是字符串,尝试解析为 JSON -fn normalize_yaml_body(yaml_body: &serde_json::Value) -> serde_json::Value { - if let Some(s) = yaml_body.as_str() { - // 先 trim 去掉前导/尾随空格,再尝试解析为 JSON - let trimmed = s.trim(); - if let Ok(parsed) = serde_json::from_str::(trimmed) { - return parsed; - } - } - yaml_body.clone() -} - /// 规范化 XML 字符串:去掉声明、多余空白、格式化为紧凑形式 fn normalize_xml(xml: &str) -> String { let mut result = xml.to_string(); @@ -182,15 +174,15 @@ fn normalize_xml(xml: &str) -> String { result } -/// Form 比较:YAML 中的键值对必须是请求的子集 -fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap) -> bool { - let yaml_map = match yaml_body.as_object() { +/// Form 比较:JSON 中的键值对必须是请求的子集 +fn compare_form_with_json(rule_body: &serde_json::Value, actual: &HashMap) -> bool { + let rule_map = match rule_body.as_object() { Some(obj) => obj, None => return false, }; - for (key, yaml_val) in yaml_map { - let expected = yaml_val.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_val.to_string()); + 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; } @@ -199,10 +191,10 @@ fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap) -> bool { +fn compare_multipart_with_json(rule_body: &serde_json::Value, actual: &HashMap) -> bool { // 方式 1:对象形式 - 只匹配键名是否存在(忽略值) - if let Some(yaml_map) = yaml_body.as_object() { - for key in yaml_map.keys() { + if let Some(rule_map) = rule_body.as_object() { + for key in rule_map.keys() { if !actual.contains_key(key) { return false; } @@ -211,9 +203,9 @@ fn compare_multipart_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap( - r#" - name: "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(); + let rule_json = r#"{ + "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[0].clone()]); + index.insert("api".to_string(), vec![rule_auth.clone()]); let router = MockRouter::new(index); // 2. 测试场景 A:JSON 完全匹配 @@ -139,48 +122,39 @@ fn test_payload_type_matching() { let mut index = HashMap::new(); // XML 规则 - let xml_rule = serde_yaml::from_str::( - r#" - name: "xml_api" - request: - method: "POST" - path: "/api/xml" - body: "test" - response: { status: 200, body: "ok" } - "#, - ) - .unwrap() - .flatten(); + let xml_rule: MockRule = serde_json::from_str(r#"{ + "name": "xml_api", + "request": { + "method": "POST", + "path": "/api/xml", + "body": "test" + }, + "response": { "status": 200, "body": "ok" } + }"#).unwrap(); // Form 规则 - let form_rule = serde_yaml::from_str::( - r#" - name: "form_api" - request: - method: "POST" - path: "/api/form" - body: { "username": "admin", "password": "123456" } - response: { status: 200, body: "login_ok" } - "#, - ) - .unwrap() - .flatten(); + 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 = serde_yaml::from_str::( - r#" - name: "text_api" - request: - method: "POST" - path: "/api/text" - body: "plain text content" - response: { status: 200, body: "text_ok" } - "#, - ) - .unwrap() - .flatten(); + 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[0].clone(), form_rule[0].clone(), text_rule[0].clone()]); + index.insert("api".to_string(), vec![xml_rule, form_rule, text_rule]); let router = MockRouter::new(index); // 测试 XML 匹配 @@ -252,34 +226,28 @@ fn test_multipart_matching() { let mut index = HashMap::new(); // 数组形式:只匹配字段名 - let array_rule = serde_yaml::from_str::( - r#" -name: "upload_array" -request: - method: "POST" - path: "/api/upload/array" - body: ["file", "description"] -response: { status: 200, body: "ok" } -"#, - ) - .unwrap() - .flatten(); + 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 = serde_yaml::from_str::( - r#" -name: "upload_object" -request: - method: "POST" - path: "/api/upload/object" - body: { "username": "admin", "role": "user" } -response: { status: 200, body: "ok" } -"#, - ) - .unwrap() - .flatten(); + // 对象形式:匹配键名 + 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[0].clone(), object_rule[0].clone()]); + index.insert("api".to_string(), vec![array_rule, object_rule]); let router = MockRouter::new(index); // 测试数组形式:匹配字段名 @@ -299,7 +267,7 @@ response: { status: 200, body: "ok" } // 测试对象形式:键名匹配(值被忽略) let mut correct_data = HashMap::new(); - correct_data.insert("username".to_string(), "any_value".to_string()); // 值不重要 + 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); @@ -308,118 +276,43 @@ response: { status: 200, body: "ok" } // 测试对象形式:键名不匹配 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()); // 缺少 role 字段 + 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(), "缺少键名不应该成功"); } -/// 模块七:验证 YAML 块语法(折叠 `>` 和字面量 `|`) -#[test] -fn test_yaml_block_syntax() { - let mut index = HashMap::new(); - - // 使用折叠语法 `>` 的规则 - let folded_rule = serde_yaml::from_str::( - r#" -name: "folded_api" -request: - method: "POST" - path: "/api/folded" - body: > - {"username":"admin","password":"123456"} -response: - status: 200 - body: "ok" -"#, - ) - .unwrap() - .flatten(); - - // 使用字面量语法 `|` 的规则 - let literal_rule = serde_yaml::from_str::( - r#" -name: "literal_api" -request: - method: "POST" - path: "/api/literal" - body: | - {"username":"test","password":"abcdef"} -response: - status: 200 - body: "ok" -"#, - ) - .unwrap() - .flatten(); - - index.insert("api".to_string(), vec![folded_rule[0].clone(), literal_rule[0].clone()]); - let router = MockRouter::new(index); - - // 测试折叠语法:YAML 中 `>` 会把内容合并成一行,但仍是字符串 - // 程序应该能将字符串解析为 JSON 后匹配 - let folded_payload = Payload::Json(json!({"username":"admin","password":"123456"})); - let folded_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &folded_payload); - assert!(folded_matched.is_some(), "折叠语法应该匹配 JSON 请求"); - assert_eq!(folded_matched.unwrap().name, "folded_api"); - - // 测试字面量语法:YAML 中 `|` 保留换行 - let literal_payload = Payload::Json(json!({"username":"test","password":"abcdef"})); - let literal_matched = router.match_rule("POST", "/api/literal", &HashMap::new(), &HashMap::new(), &literal_payload); - assert!(literal_matched.is_some(), "字面量语法应该匹配 JSON 请求"); - assert_eq!(literal_matched.unwrap().name, "literal_api"); - - // 测试不匹配的情况 - let wrong_payload = Payload::Json(json!({"username":"wrong","password":"wrong"})); - let wrong_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &wrong_payload); - assert!(wrong_matched.is_none(), "错误的 body 不应该匹配"); -} - -/// 模块八:验证 XML 格式化匹配 +/// 模块七:验证 XML 格式化匹配 #[test] fn test_xml_normalized_matching() { let mut index = HashMap::new(); - // YAML 中的 XML(带格式化) - let xml_rule = serde_yaml::from_str::( - r#" -name: "xml_api" -request: - method: "POST" - path: "/api/xml" - body: | - - 1001 - 张三 - -response: - status: 200 - body: "ok" -"#, - ) - .unwrap() - .flatten(); + // JSON 中的 XML(紧凑格式) + let xml_rule: MockRule = serde_json::from_str(r#"{ + "name": "xml_api", + "request": { + "method": "POST", + "path": "/api/xml", + "body": "1001张三" + }, + "response": { "status": 200, "body": "ok" } + }"#).unwrap(); - index.insert("api".to_string(), vec![xml_rule[0].clone()]); + index.insert("api".to_string(), vec![xml_rule]); let router = MockRouter::new(index); - // 测试 1:完全相同的格式化 XML - let formatted_xml = Payload::Xml("\n 1001\n 张三\n".to_string()); - let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &formatted_xml); - assert!(matched1.is_some(), "格式化 XML 应该匹配"); - - // 测试 2:紧凑格式的 XML + // 测试 1:紧凑格式的 XML let compact_xml = Payload::Xml("1001张三".to_string()); - let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml); - assert!(matched2.is_some(), "紧凑格式 XML 应该匹配"); + let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml); + assert!(matched1.is_some(), "紧凑格式 XML 应该匹配"); - // 测试 3:带 XML 声明的请求 + // 测试 2:带 XML 声明的请求 let xml_with_decl = Payload::Xml("1001张三".to_string()); - let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl); - assert!(matched3.is_some(), "带声明的 XML 应该匹配"); + let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl); + assert!(matched2.is_some(), "带声明的 XML 应该匹配"); - // 测试 4:内容不同,不应该匹配 + // 测试 3:内容不同,不应该匹配 let wrong_xml = Payload::Xml("1002李四".to_string()); - let matched4 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml); - assert!(matched4.is_none(), "内容不同的 XML 不应该匹配"); + let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml); + assert!(matched3.is_none(), "内容不同的 XML 不应该匹配"); } diff --git a/tests/v2_integration_test.rs b/tests/v2_integration_test.rs index fd4dfd1..a81ea66 100644 --- a/tests/v2_integration_test.rs +++ b/tests/v2_integration_test.rs @@ -6,37 +6,39 @@ use mock_server::loader::MockLoader; use mock_server::router::MockRouter; /// 加载 v1 目录的 mock 规则 -fn load_v2_mocks() -> HashMap> { - let v2_path = Path::new("../mocks/v1"); - MockLoader::load_all_from_dir(v2_path) +fn load_v1_mocks() -> HashMap> { + let v1_path = Path::new("./mocks/v1"); + MockLoader::load_all_from_dir(v1_path) } -// ========== 模块一:验证所有 YAML 文件正确加载 ========== +// ========== 模块一:验证所有 JSON 文件正确加载 ========== #[test] -fn test_v2_load_all_mocks() { - let index = load_v2_mocks(); +fn test_v1_load_all_mocks() { + let index = load_v1_mocks(); // 验证索引键存在 - assert!(index.contains_key("v1"), "应包含 'v2' 索引键"); + assert!(index.contains_key("v1"), "应包含 'v1' 索引键"); // 验证规则总数 let total: usize = index.values().map(|v| v.len()).sum(); - assert_eq!(total, 9, "v2 目录应有 9 个 mock 规则"); + assert!(total >= 10, "v1 目录应有至少 10 个 mock 规则"); } // ========== 模块二:JSON Payload 测试 ========== #[test] -fn test_v2_json_login() { - let index = load_v2_mocks(); +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": "admin", + "username": "user001", "password": "password123" })); @@ -44,13 +46,13 @@ fn test_v2_json_login() { assert!(matched.is_some(), "JSON 登录应匹配成功"); let rule = matched.unwrap(); - assert_eq!(rule.name, "v2_user_login"); + assert_eq!(rule.name, "user_login_001"); assert_eq!(rule.response.status, 200); } #[test] -fn test_v2_json_register() { - let index = load_v2_mocks(); +fn test_v1_json_register() { + let index = load_v1_mocks(); let router = MockRouter::new(index); let mut headers = HashMap::new(); @@ -66,15 +68,15 @@ fn test_v2_json_register() { assert!(matched.is_some(), "JSON 注册应匹配成功"); let rule = matched.unwrap(); - assert_eq!(rule.name, "v2_user_register"); + assert_eq!(rule.name, "user_register"); assert_eq!(rule.response.status, 201); } // ========== 模块三:Form Payload 测试 ========== #[test] -fn test_v2_form_login() { - let index = load_v2_mocks(); +fn test_v1_form_login() { + let index = load_v1_mocks(); let router = MockRouter::new(index); let mut headers = HashMap::new(); @@ -88,74 +90,74 @@ fn test_v2_form_login() { let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload); assert!(matched.is_some(), "Form 登录应匹配成功"); - assert_eq!(matched.unwrap().name, "v2_user_login_form"); + assert_eq!(matched.unwrap().name, "_user_login_form"); } // ========== 模块四:Text Payload 测试 ========== #[test] -fn test_v2_text_echo() { - let index = load_v2_mocks(); +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 V2 Mock Server".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, "v2_user_echo"); - assert!(rule.response.body.contains("Echo from V2")); + assert_eq!(rule.name, "user_echo"); + assert!(rule.response.body.contains("Echo from V1")); } // ========== 模块五:XML Payload 测试 ========== #[test] -fn test_v2_xml_export() { - let index = load_v2_mocks(); +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#"10001xml"#; + let xml_body = r#"10001xml"#; 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, "v2_data_export"); + assert_eq!(matched.unwrap().name, "data_export"); } // ========== 模块六:Multipart Payload 测试 ========== #[test] -fn test_v2_multipart_upload() { - let index = load_v2_mocks(); +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("avatar".to_string(), "file_content".to_string()); - multipart_data.insert("description".to_string(), "user avatar".to_string()); + 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, "v2_user_upload_avatar"); + assert_eq!(matched.unwrap().name, "user_upload_avatar_001"); } // ========== 模块七:None Payload 测试 (GET 无 Body) ========== #[test] -fn test_v2_health_check() { - let index = load_v2_mocks(); +fn test_v1_health_check() { + let index = load_v1_mocks(); let router = MockRouter::new(index); let payload = Payload::None; @@ -164,31 +166,31 @@ fn test_v2_health_check() { assert!(matched.is_some(), "健康检查应匹配成功"); let rule = matched.unwrap(); - assert_eq!(rule.name, "v2_health_check"); + assert_eq!(rule.name, "health_check"); assert_eq!(rule.response.status, 200); } #[test] -fn test_v2_get_profile() { - let index = load_v2_mocks(); +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 v2_test_token".to_string()); + 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, "v2_user_profile"); + assert_eq!(matched.unwrap().name, "user_profile"); } // ========== 模块八:Query Params 测试 ========== #[test] -fn test_v2_query_params() { - let index = load_v2_mocks(); +fn test_v1_query_params() { + let index = load_v1_mocks(); let router = MockRouter::new(index); let mut query = HashMap::new(); @@ -200,15 +202,15 @@ fn test_v2_query_params() { assert!(matched.is_some(), "带 query params 的下载应匹配成功"); let rule = matched.unwrap(); - assert_eq!(rule.name, "v2_user_download"); + assert_eq!(rule.name, "user_download"); assert!(rule.response.body.starts_with("file://")); } // ========== 模块九:Header 匹配测试 ========== #[test] -fn test_v2_header_required() { - let index = load_v2_mocks(); +fn test_v1_header_required() { + let index = load_v1_mocks(); let router = MockRouter::new(index); // 无 Authorization header 不应匹配 @@ -218,21 +220,7 @@ fn test_v2_header_required() { // 有正确的 Authorization header 应匹配 let mut headers = HashMap::new(); - headers.insert("Authorization".to_string(), "Bearer v2_test_token".to_string()); + 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 应匹配"); } - -// ========== 模块十:name 字段验证 ========== - -#[test] -fn test_v2_all_rules_have_name() { - let index = load_v2_mocks(); - - for (key, rules) in &index { - for rule in rules { - assert!(!rule.name.is_empty(), "规则 name 不能为空"); - assert!(rule.name.starts_with("v2_"), "v2 规则 name 应以 'v2_' 开头"); - } - } -}