Author SHA1 Message Date
CNWei cfcebbe300 feat(mcp): 实现HTTP统一方案并添加MCP文档
- 将MCP服务和Mock API合并到单个HTTP服务器(8080端口)
- 添加POST /mcp端点,使用无状态StreamableHttpService
- 新增docs/mcp-implementation.md文档
2026-03-29 22:30:29 +08:00
CNWeiandClaude Opus 4.6 d364307131 feat: mock配置迁移至JSON格式并修复body匹配
- 将mock配置从YAML格式迁移到JSON格式
- 修复JSON字符串格式body匹配失败问题
- 添加MCP功能模块
- 更新mock-spec.md规范文档

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-29 09:43:11 +08:00
CNWei 061ceff4b8 fix: 修复 YAML 块语法 body 匹配失败问题
- normalize_yaml_body 函数在解析 JSON 前添加 trim() 处理,解决 YAML `|` 和 `>` 语法产生的前导空格问题
- 修复 multiple_login.yaml 中 response body 格式错误(YAML 对象改为 JSON 字符串)
2026-03-27 17:33:21 +08:00
CNWei 9c1d0e16b4 docs: 更新README并添加文件上传mock配置
- 移除文件上传相关文档(已从代码中移除)
   - 新增未来计划:动态参数、HTTPS、可视化管理界面
   - 添加 upload.yaml mock 配置文件
2026-03-25 17:40:34 +08:00
CNWeiandClaude Opus 4.6 b579a835de docs: 重构项目文档结构
- 将 CLAUDE.md 移至 .claude/ 目录,精简为 AI 开发指南
- 扩充 README.md 为完整的用户文档
- 新增 rules/mock-spec.md: YAML 配置生成规范
- 新增 rules/commit-spec.md: Git 提交消息格式规范
- 从 .gitignore 移除 .claude/ 目录以便跟踪

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-25 17:11:10 +08:00
CNWei e218ab04fe feat: 优化CLAUDE.md
- 优化 优化CLAUDE.md
2026-03-25 17:10:36 +08:00
CNWei 18e0ccad58 merge: 合并0321分支的热重载功能与body字段匹配支持 2026-03-25 16:59:09 +08:00
CNWei 3097a16465 feat: 实现配置热重载功能
- 引入热加载机制:集成 `notify-debouncer-mini` 监听 `./mocks` 目录,实现 YAML 变动自动重载。
- 优化并发控制:将 `MockRouter` 包装在 `RwLock` 中,确保 Web 线程(读)与监控线程(写)的数据安全。
- 增强系统健壮性:增加防抖处理防止频繁 IO 触发重载。
2026-01-04 11:44:18 +08:00
CNWei 5f3269bad5 feat: 增加request对body字段的支持
- 实现body字段对json语法,yaml语法的支持的支持
- 新增测试用例login_out.yaml
2025-12-28 21:54:34 +08:00
CNWei 1775d3659d feat: 实现核心匹配引擎与请求处理器并修复解析逻辑
- 新增 router 模块:实现基于路径首段索引子集匹配。
- 新增 handler 模块:集成 Axum 处理器,支持 Smart Body 协议与延迟模拟。
- 修复解析与匹配故障:修正 YAML 字段类型解析错误。
2025-12-26 15:21:03 +08:00
CNWei 748cfa8e7f feat: 实现配置加载系统与多级目录递归扫描
- 定义支持“一接口一文件”与“一文件多接口”的数据模型
- 实现基于路径首段的 HashMap 索引构建逻辑
- 新增集成测试 tests/integration_test.rs,验证 YAML 解析与目录递归加载
- 优化 Cargo.toml 配置,解决连字符项目名引用问题
2025-12-25 18:01:09 +08:00
CNWei 9a48c156c8 init: 初始化mock-server项目
- 搭建Rust项目基础结构(src/mocks目录、核心模块空文件)
- 配置Cargo.toml核心依赖(Axum/Tokio/Serde等)
- 添加.gitignore和README初始文件
- 适配Rust开发环境基础配置

项目说明:
- 名称:mock-server | 描述:Rust/Axum配置驱动Mock服务 | 环境:Rust 1.70+
- 下一步:完善文档、补充核心业务逻辑、配置CI/CD"
2025-12-25 13:56:11 +08:00
39 changed files with 4129 additions and 57 deletions
+76
View File
@@ -0,0 +1,76 @@
# Mock Server 开发指南
## 构建与测试命令
```bash
cargo build # 构建项目
cargo run # 启动服务 http://127.0.0.1:8080
cargo test # 运行所有测试
cargo test <pattern> # 运行匹配的测试(如 `cargo test router`)
cargo clippy # 代码检查
cargo fmt # 格式化代码
```
## 架构
```
┌─────────────┐ ┌──────────────┐ ┌────────────────┐
│ loader.rs │───▶│ router.rs │───▶│ handler.rs │
│ YAML → 索引 │ │ HashMap 索引 │ │ 请求/响应处理 │
└─────────────┘ └──────────────┘ └────────────────┘
▲ │
│ ┌──────────────────┐ │
└─────────│ main.rs │◀─────────┘
热重载 │ AppState(RwLock)│
└──────────────────┘
```
**数据流:**
1. `MockLoader` 扫描 `mocks/` 目录,解析 YAML 为 `MockRule` 结构体
2. 规则按路径首段建立索引(如 `/api/users` → key: `api`)存储于 `MockRouter`
3. 请求匹配顺序:method → path → query_params → headers → body
4. `AppState` 使用 `RwLock` 包装 router,支持线程安全的热重载
## 核心类型 (config.rs)
- `MockRule`: 完整规则(id, request, response, settings)
- `RequestMatcher`: 请求匹配条件(method, path, query_params, headers, body)
- `MockResponse`: 响应配置(status, headers, body)
- `MockSource`: 枚举类型,支持单接口/多接口 YAML 格式
## 请求匹配 (router.rs)
- 路径首段 HashMap 查找,O(1) 获取候选规则
- 候选集内线性扫描进行精确匹配
- Body 匹配:支持 JSON 对象和字符串两种比较方式
## 响应处理 (handler.rs)
- 内联 body:直接返回
- `file://` 前缀:从磁盘流式读取文件(低内存占用)
- `settings.delay_ms`:模拟网络延迟
## 文件结构
```
src/
├── main.rs # 入口,热重载监听,Axum 路由配置
├── config.rs # 数据结构定义(MockRule, RequestMatcher 等)
├── loader.rs # YAML 解析,目录扫描
├── router.rs # 路径首段索引,匹配逻辑
├── handler.rs # 统一请求处理器,文件流式响应
└── upload.rs # Multipart 文件上传处理
tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件
storage/ # 上传文件存储(按 YYYY-MM-DD 分目录)
```
## 开发注意事项
- **Rust Edition 2024**,Axum 0.8.8
- 测试使用 `tempfile` crate 处理临时文件
- 请求体大小限制:10MB (handler.rs:30)
- 热重载防抖时间:200ms (main.rs:46)
- Header 匹配大小写不敏感 (router.rs:75)
+89
View File
@@ -0,0 +1,89 @@
---
paths:
- "**/*"
---
## Git 提交消息规范
AI 在生成 commit 消息时必须遵循以下格式。
### 格式
```
<类型>(<范围>): <描述>
[可选的详细说明]
[可选的脚注]
```
### 类型(Type)
| 类型 | 说明 |
|------|------|
| feat | 新功能(feature) |
| fix | 修复 bug |
| docs | 文档更新 |
| style | 代码格式(不影响功能的变动) |
| refactor | 代码重构(既非新功能也非 bug 修复) |
| test | 添加或修改测试 |
| chore | 构建系统或辅助工具的变动 |
| perf | 性能优化 |
### 范围(Scope)
描述提交影响的功能模块或组件(可选)。
### 描述(Description)
- 简明扼要描述变动内容
- 首字母小写
- 不超过 100 个字符
- 使用祈使句(如 "add" 而非 "added")
### 详细说明(Body)
- 提供更多上下文和详细说明(可选)
- 每行不超过 72 个字符
- 说明"做了什么"和"为什么"
### 脚注(Footer)
- 破坏性变动说明(如 `BREAKING CHANGE: ...`)
- 关闭的 Issue(如 `Closes #123`)
### 示例
**新功能:**
```
feat(api): 添加用户登录端点
- 实现 JWT 认证逻辑
- 添加登录表单前端组件
- 更新用户服务处理登录请求
Closes #45
```
**Bug 修复:**
```
fix(router): 修复路径匹配大小写问题
路径匹配在某些情况下区分大小写导致无法正确路由到处理器。
```
**文档更新:**
```
docs: 更新 README 中的安装说明
```
**重构:**
```
refactor(handler): 简化响应处理逻辑
将文件响应和内联响应的处理逻辑分离到独立函数中。
```
+122
View File
@@ -0,0 +1,122 @@
---
paths:
- "mocks/**/*.json"
---
## JSON 配置规范
AI 在生成 Mock 规则时必须遵循以下格式。
### 字段说明
| 字段 | 类型 | 必填 | 说明 |
|----------------------|--------|:--:|----------------------------------|
| 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 | 否 | 延迟响应(毫秒) |
### 匹配规则
1. **路径精确匹配**(非前缀匹配)
2. **请求头匹配大小写不敏感**
3. **Body 匹配**支持 JSON 对象或字符串比较
### 配置示例
**基础示例:**
```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
}
}
```
**带查询参数和请求头:**
```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\": []}"
}
}
```
**文件响应:**
```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://` 支持两种路径:
> - 相对路径:`file://./data/file.pdf`(相对于项目根目录)
> - 绝对路径:`file:///C:/path/to/file.pdf`
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(cargo search:*)",
"Bash(cd:*)"
]
}
}
Generated
+1159 -18
View File
File diff suppressed because it is too large Load Diff
+18 -10
View File
@@ -1,7 +1,7 @@
[package]
name = "mock-server"
name = "mock_server"
version = "0.1.0"
edition = "2024"
edition = "2021"
[dependencies]
# 核心 Web 框架
@@ -12,17 +12,25 @@ 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"
# 物理目录递归扫描工具
#walkdir = "2.5.0"
walkdir = "2.5.0"
# 日志系统
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["fmt", "env-filter"] }
tracing-appender = "0.2"
# 性能优化:快速哈希(可选,用于路由匹配)
#dashmap = "7.0.0-rc2"
# 热加载支持(扩展功能)
#notify = "8.2.0"
# 路径处理
#pathdiff = "0.2.3"
notify = "8.2.0"
notify-debouncer-mini = "0.6.0"
# MCP Server 支持
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
schemars = "1.0"
[dev-dependencies]
tempfile = "3.24.0"
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 CNWei
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70 -5
View File
@@ -1,11 +1,76 @@
# mock-server
基于Rust/Axum的配置驱动型Mock服务,支持YAML配置、请求匹配、延迟响应、大文件流式返回等特性。
基于 Rust/Axum 的配置驱动型 Mock 服务,支持 YAML 配置、请求匹配、热重载、延迟响应、大文件流式返回等特性。
## 特性
- 配置驱动:YAML定义API行为,无需修改代码
- 高性能:基于Rust异步运行时,哈希索引匹配请求
- 低内存:大响应体支持磁盘文件流式读取,不占用常驻内存
- 易扩展:模块化设计,支持动态占位符、热加载(规划中)
- **配置驱动**:YAML 定义 API 行为,无需修改代码
- **热重载**:`mocks/*.yaml` 变更自动生效,无需重启服务
- **高性能**:基于 Rust 异步运行时,路径首段哈希索引 O(1) 匹配
- **低内存**:大响应体支持 `file://` 协议从磁盘流式读取
- **请求匹配**:支持 method、path、headers、query_params、body 多维度匹配
## 快速开始
### 1. 安装依赖
确保已安装 Rust 工具链:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### 2. 构建与运行
```bash
cargo build # 构建项目
cargo run # 启动服务 http://127.0.0.1:8080
cargo test # 运行所有测试
cargo test <pattern> # 运行匹配的测试(如 cargo test router)
cargo clippy # 代码检查
cargo fmt # 格式化代码
```
## 文件结构
```
src/
├── main.rs # 入口,热重载监听,Axum 路由配置
├── config.rs # 数据结构定义(MockRule, RequestMatcher 等)
├── loader.rs # YAML 解析,目录扫描
├── router.rs # 路径首段索引,匹配逻辑
└── handler.rs # 统一请求处理器,文件流式响应
tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件
```
## Mock 配置
详细配置规范请参考 [.claude/rules/mock-spec.md](.claude/rules/mock-spec.md)
## 未来计划
### 1. 动态参数与模板响应
支持路径参数捕获(如 `/api/users/{id}`)和响应模板,可在响应中引用请求参数:
```yaml
# 请求: GET /api/users/123
# 响应: { "id": "123", "name": "User 123" }
```
### 2. HTTPS 支持
内置 TLS/SSL 支持,提供安全的 HTTPS 服务。
### 3. 可视化管理界面
- **TUI(终端界面)**:基于 Ratatui 的交互式终端管理
- **GUI(图形界面)**:Web Dashboard 或桌面应用,可视化管理 Mock 规则
> 此功能需要深入讨论,欢迎提出建议和需求
## 许可证
MIT
+427
View File
@@ -0,0 +1,427 @@
# rmcp 0.11 MCP Server 实现指南
本文档详细介绍了如何使用 rmcp 0.11 实现 MCP Server,包括核心概念、关键代码模式和常见陷阱。
## 目录
- [核心概念](#核心概念)
- [Tool 注册三要素](#tool-注册三要素)
- [完整代码示例](#完整代码示例)
- [常见错误和解决方案](#常见错误和解决方案)
- [HTTP 传输配置](#http-传输配置)
- [客户端配置](#客户端配置)
---
## 核心概念
### rmcp 简介
rmcp 是 Rust 官方的 MCP SDK,提供了实现 MCP (Model Context Protocol) 服务器和客户端的完整工具链。
### MCP 协议
MCP (Model Context Protocol) 是一个开放协议,用于连接 AI 助手与外部系统。它定义了一套标准化的接口,允许 AI 模型:
- **Tools**: 调用外部工具/函数
- **Resources**: 访问外部资源
- **Prompts**: 使用预定义的提示模板
### 依赖配置
```toml
# Cargo.toml
[dependencies]
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
schemars = "1.0"
tokio-util = { version = "0.7", features = ["io"] }
```
---
## Tool 注册三要素
要使 MCP tools 在 rmcp 0.11 中正常工作,**必须**同时具备以下三个要素:
### 1. `#[tool_router]` 宏
放在包含 tool 方法的 impl 块上:
```rust
#[tool_router]
impl MockMcpServer {
// tool 方法...
}
```
### 2. `tool_router: ToolRouter<Self>` 字段
在结构体中必须包含此字段:
```rust
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>, // 必需字段
}
```
### 3. `#[tool_handler]` 宏
放在 `ServerHandler` trait 实现块上:
```rust
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
// ...
}
}
```
> **重要**: 缺少 `#[tool_handler]` 会导致 `tools/list` 返回空数组 `{"tools": []}`!
---
## 完整代码示例
### 结构体定义
```rust
use std::sync::Arc;
use rmcp::{
handler::server::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::*,
tool, tool_handler, tool_router,
ErrorData as McpError, ServerHandler,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// MCP Server for Mock Server management
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>, // 必需字段
}
```
### 请求参数结构体
```rust
/// 使用 schemars 和 serde 自动生成 JSON Schema
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListRulesRequest {
#[schemars(description = "Optional group name to filter by")]
pub group: Option<String>,
}
```
### Tool 方法实现
```rust
#[tool_router]
impl MockMcpServer {
pub fn new(manager: Arc<MockManager>) -> Self {
Self {
manager,
tool_router: Self::tool_router(), // 初始化 tool_router
}
}
/// 无参数 tool
#[tool(description = "List all groups (directories)")]
async fn list_groups(&self) -> Result<CallToolResult, McpError> {
let groups = self.manager.list_groups();
let result = serde_json::to_string_pretty(&groups)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
/// 带参数 tool - 使用 Parameters<T> 包装器
#[tool(description = "Get a specific mock rule by group and name")]
async fn get_mock_rule(
&self,
params: Parameters<GetRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.get(&params.0.group, &params.0.name) {
Some(rule) => {
let result = serde_json::to_string_pretty(&rule)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
None => Ok(CallToolResult::error(vec![Content::text(
format!("Rule not found: {}/{}", params.0.group, params.0.name),
)])),
}
}
/// 可选参数
#[tool(description = "List all mock rules, optionally filtered by group")]
async fn list_mock_rules(
&self,
params: Parameters<ListRulesRequest>,
) -> Result<CallToolResult, McpError> {
let rules = self.manager.list(params.0.group.as_deref());
let result = serde_json::to_string_pretty(&rules)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
}
```
### ServerHandler 实现
```rust
#[tool_handler] // 关键宏!
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools() // 必须启用 tools 能力
.build(),
instructions: Some("Mock Server MCP - Manage mock API rules.".to_string()),
..Default::default()
}
}
}
```
---
## 常见错误和解决方案
### 1. Missing `#[tool_handler]`
**症状**: `tools/list` 返回空数组 `{"tools": []}`
**原因**: 没有在 `ServerHandler` impl 块上添加 `#[tool_handler]` 宏
**解决方案**:
```rust
// 错误 - 缺少宏
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo { ... }
}
// 正确
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo { ... }
}
```
### 2. Missing `enable_tools()`
**症状**: Tools 不被广播,客户端无法发现 tools
**原因**: `ServerCapabilities` 没有启用 tools
**解决方案**:
```rust
// 错误
ServerInfo {
capabilities: ServerCapabilities::default (),
...
}
// 正确
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
...
}
```
### 3. 使用 `#[tool(aggr)]`
**症状**: 编译错误
**原因**: rmcp 0.11 不支持 `aggr` 参数
**解决方案**:
```rust
// 错误 - rmcp 0.11 不支持
#[tool(aggr)]
async fn my_tool(&self, params: MyRequest) -> Result<...>
// 正确 - 使用 Parameters<T> 包装器
#[tool(description = "...")]
async fn my_tool(&self, params: Parameters<MyRequest>) -> Result<...>
```
### 4. 使用 `#[tool(tool_box)]`
**症状**: 编译错误
**原因**: rmcp 0.11 使用不同的宏名称
**解决方案**:
```rust
// 错误
#[tool(tool_box)]
impl MockMcpServer { ... }
// 正确
#[tool_router]
impl MockMcpServer { ... }
```
---
## HTTP 传输配置
### StreamableHttpService 无状态模式
适用于简单的 HTTP 集成,无需维护会话状态:
```rust
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::never::NeverSessionManager,
};
use tokio_util::sync::CancellationToken;
/// 创建无状态 MCP HTTP 服务
pub fn create_mcp_http_service(
manager: Arc<MockManager>,
) -> StreamableHttpService<MockMcpServer, NeverSessionManager> {
StreamableHttpService::new(
// 每次请求创建新的 server 实例
move || Ok(MockMcpServer::new(manager.clone())),
// 无状态会话管理器
Arc::new(NeverSessionManager::default()),
StreamableHttpServerConfig {
sse_keep_alive: None, // SSE 保活配置(可选)
stateful_mode: false, // 无状态模式
cancellation_token: CancellationToken::new(),
},
)
}
```
### 与 Axum 集成
```rust
use axum::{
routing::post,
Router,
body::Body,
http::Request,
};
let mcp_service = create_mcp_http_service(manager);
let app = Router::new()
.route("/mcp", post({
let service = mcp_service.clone();
move | req: Request < Body > | {
let service = service.clone();
async move { service.handle(req).await }
}
}));
```
### 配置选项说明
| 选项 | 类型 | 说明 |
|----------------------|---------------------|------------|
| `sse_keep_alive` | `Option<Duration>` | SSE 连接保活间隔 |
| `stateful_mode` | `bool` | 是否维护会话状态 |
| `cancellation_token` | `CancellationToken` | 用于优雅关闭 |
---
## 客户端配置
### Claude Code 配置
在 Claude Code 的 `settings.json` 中添加:
```json
{
"mcpServers": {
"mock-server": {
"type": "http",
"url": "http://127.0.0.1:8080/mcp"
}
}
}
```
### Claude Desktop 配置
在 Claude Desktop 的配置文件中添加:
```json
{
"mcpServers": {
"mock-server": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}
```
---
## 架构说明
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP Server (Axum) │
│ Port 8080 │
├─────────────────────────────────────────────────────────────┤
│ │
│ POST /mcp │ /* (fallback) │
│ ├─ tools/list │ Mock API endpoints │
│ ├─ tools/call │ │
│ └─ other MCP methods │ │
│ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ StreamableHttpService<MockMcpServer> │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ MockMcpServer │ │ NeverSessionManager │ │
│ │ │ │ (无状态) │ │
│ │ - tool_router │ │ │ │
│ │ - manager │ │ │ │
│ └─────────────────┘ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ MockManager (Arc) │
│ │
│ 共享于 HTTP MCP 端点和 Mock API handler │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 参考资源
- [rmcp GitHub Repository](https://github.com/anthropics/rmcp)
- [MCP Specification](https://spec.modelcontextprotocol.io/)
- [schemars Documentation](https://docs.rs/schemars/)
+26
View File
@@ -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
}
}
+26
View File
@@ -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
}
}
+26
View File
@@ -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
}
}
+23
View File
@@ -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\"}}"
}
}
+23
View File
@@ -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\"}}"
}
}
+22
View File
@@ -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": "[email protected]"
}
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"注册成功\",\"data\":{\"userId\":10002,\"username\":\"newuser\",\"email\":\"[email protected]\",\"createdAt\":\"2026-03-27T10:00:00Z\"}}"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "data_export",
"request": {
"method": "POST",
"path": "/v1/data/export",
"headers": {
"Content-Type": "application/xml"
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><request><userId>10001</userId><format>xml</format></request>"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/xml"
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><response><code>0</code><message>导出成功</message><data><user><id>10001</id><name>管理员</name><email>[email protected]</email></user></data></response>"
}
}
+14
View File
@@ -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\"}"
}
}
+16
View File
@@ -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"
}
}
+18
View File
@@ -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\"}}"
}
}
+21
View File
@@ -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\"}}"
}
}
+21
View File
@@ -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\"}}"
}
}
+18
View File
@@ -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"
}
}
+18
View File
@@ -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"
}
}
+21
View File
@@ -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\"}}"
}
}
+17
View File
@@ -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\":\"[email protected]\",\"nickname\":\"管理员\",\"avatar\":\"https://example.com/avatars/admin.jpg\",\"createdAt\":\"2025-01-01T00:00:00Z\"}}"
}
}
+235
View File
@@ -0,0 +1,235 @@
use axum::{
body::Body,
extract::{Query, State},
http::{HeaderMap, Method, Request, StatusCode},
response::{IntoResponse, Response},
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio_util::io::ReaderStream;
use crate::models::Payload;
use crate::router::MockRouter;
/// 共享的应用状态,router 现在由 RwLock 保护以支持热重载
pub struct AppState {
pub router: RwLock<MockRouter>,
}
/// 提取请求的 Content-Type(去掉参数部分,如 boundary)
fn extract_content_type(headers: &HeaderMap) -> Option<String> {
headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_lowercase())
}
/// 根据 Content-Type 解析 Body(始终以请求的 Content-Type 为准)
fn parse_body(content_type: Option<&str>, bytes: &[u8]) -> Payload {
if bytes.is_empty() {
return Payload::None;
}
match content_type {
Some(ct) if ct.contains("application/json") => {
serde_json::from_slice(bytes)
.map(Payload::Json)
.unwrap_or_else(|_| {
// JSON 解析失败,降级为文本
Payload::Text(String::from_utf8_lossy(bytes).to_string())
})
}
Some(ct) if ct.contains("xml") => {
Payload::Xml(String::from_utf8_lossy(bytes).to_string())
}
Some(ct) if ct.contains("form-urlencoded") => {
Payload::Form(parse_urlencoded(bytes))
}
Some(ct) if ct.contains("multipart/form-data") => {
Payload::Multipart(extract_multipart_data(bytes))
}
_ => {
Payload::Text(String::from_utf8_lossy(bytes).to_string())
}
}
}
/// 解析 urlencoded 格式
fn parse_urlencoded(bytes: &[u8]) -> HashMap<String, String> {
let body = String::from_utf8_lossy(bytes);
let mut map = HashMap::new();
for pair in body.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let decoded_key = urlencoding_decode(key);
let decoded_value = urlencoding_decode(value);
map.insert(decoded_key, decoded_value);
}
}
map
}
/// URL 解码(简单实现)
fn urlencoding_decode(s: &str) -> String {
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '+' {
result.push(' ');
} else if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else {
result.push(c);
}
}
result
}
/// 从 multipart body 中提取键值对
fn extract_multipart_data(bytes: &[u8]) -> HashMap<String, String> {
let body = String::from_utf8_lossy(bytes);
let mut map = HashMap::new();
// 分割 boundary
let lines: Vec<&str> = body.lines().collect();
let mut current_name: Option<String> = None;
let mut current_value = String::new();
let mut in_value = false;
for line in &lines {
// 检测 Content-Disposition 行,提取 name
if line.contains("Content-Disposition") && line.contains("name=") {
// 保存上一个字段的值
if let Some(name) = current_name.take() {
map.insert(name, current_value.trim().to_string());
current_value.clear();
}
// 提取 name 属性
if let Some(start) = line.find("name=\"") {
let start = start + 6;
if let Some(end) = line[start..].find('"') {
current_name = Some(line[start..start + end].to_string());
in_value = false;
}
}
} else if line.starts_with("Content-Type") {
// 跳过 Content-Type 行
continue;
} else if line.is_empty() {
// 空行后面是值
in_value = true;
} else if in_value {
// 收集值内容
if !current_value.is_empty() {
current_value.push('\n');
}
current_value.push_str(line);
}
}
// 保存最后一个字段
if let Some(name) = current_name {
map.insert(name, current_value.trim().to_string());
}
map
}
/// 全局统一请求处理函数
pub async fn mock_handler(
State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数
method: Method,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
req: Request<Body>, // Request<Body> 必须是最后一个参数
) -> impl IntoResponse {
// 1. 提取 path 和 method
let path = req.uri().path().to_string();
let method_str = method.as_str().to_string();
// 2. 提取请求的 Content-Type
let req_content_type = extract_content_type(&headers);
// 3. 读取请求 body
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
Ok(bytes) => bytes,
Err(_) => {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("Read body error"))
.unwrap();
}
};
// 4. 根据【请求的 Content-Type】解析 body
let parsed_body = parse_body(req_content_type.as_deref(), &body_bytes);
// 5. 将 Axum HeaderMap 转换为简单的 HashMap
let mut req_headers = HashMap::new();
for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() {
req_headers.insert(name.as_str().to_string(), v.to_string());
}
}
// 6. 执行匹配逻辑:先获取读锁 (Read Lock)
let maybe_rule = {
let router = state.router.read().expect("Failed to acquire read lock");
router.match_rule(&method_str, &path, &params, &req_headers, &parsed_body).cloned()
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
};
if let Some(rule) = maybe_rule {
// 7. 处理模拟延迟
if let Some(ref settings) = rule.settings {
if let Some(delay) = settings.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
// 8. 构建响应
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
let mut response_builder = Response::builder().status(status);
if let Some(ref h) = rule.response.headers {
for (k, v) in h {
response_builder = response_builder.header(k, v);
}
}
// 9. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() {
match tokio::fs::File::open(file_path).await {
Ok(file) => {
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
response_builder.body(body).unwrap()
}
Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(format!(
"Mock Error: File not found at {}",
file_path
)))
.unwrap(),
}
} else {
// 内联模式:直接返回字符串内容
response_builder
.body(Body::from(rule.response.body.clone()))
.unwrap()
}
} else {
// 匹配失败返回 404
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("No mock rule matched this request"))
.unwrap()
}
}
+8
View File
@@ -0,0 +1,8 @@
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
pub mod models;
pub mod loader;
pub mod router;
pub mod handler;
pub mod manager;
pub mod logging;
pub mod mcp;
+66
View File
@@ -0,0 +1,66 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
use crate::models::MockRule;
pub struct MockLoader;
impl MockLoader {
/// 递归扫描指定目录并构建索引表
/// 目录结构:mocks/{group}/{rule}.json
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
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 == "json"))
{
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);
}
}
println!("Successfully loaded {} segments from {:?}", index.len(), dir);
index
}
/// 解析单个 JSON 文件
fn parse_json_file(path: &Path) -> Option<MockRule> {
match fs::read_to_string(path) {
Ok(content) => {
match serde_json::from_str::<MockRule>(&content) {
Ok(rule) => Some(rule),
Err(e) => {
eprintln!("Failed to parse JSON at {:?}: {}", path, e);
None
}
}
}
Err(e) => {
eprintln!("Failed to read file {:?}: {}", path, e);
None
}
}
}
/// 提取路径的第一级作为 Key(例如 "/api/v1/login" -> "api")
fn extract_first_segment(path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root") // 如果是根路径 "/",则归类到 "root"
.to_string()
}
}
+143 -2
View File
@@ -1,3 +1,144 @@
fn main() {
println!("Hello, mock-server!");
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::{routing::post, Router};
use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode};
use tracing::{info, error};
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use mock_server::handler::{mock_handler, AppState};
use mock_server::logging;
fn print_usage() {
println!("Mock Server - A mock API server with hot-reload and MCP support");
println!();
println!("Usage: mock_server [OPTIONS]");
println!();
println!("Options:");
println!(" --mocks <DIR> Mocks directory path (default: ./mocks)");
println!(" --port <PORT> HTTP server port (default: 8080)");
println!(" --help Show this help message");
println!();
println!("MCP Endpoint: POST http://127.0.0.1:<PORT>/mcp");
}
#[tokio::main]
async fn main() {
// Initialize logging system
let log_dir = PathBuf::from("./logs");
logging::init(log_dir);
// Parse command line arguments
let args: Vec<String> = std::env::args().collect();
let mut mocks_dir = PathBuf::from("./mocks");
let mut port: u16 = 8080;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--help" | "-h" => {
print_usage();
return;
}
"--mocks" => {
if i + 1 < args.len() {
mocks_dir = PathBuf::from(&args[i + 1]);
i += 1;
} else {
eprintln!("--mocks requires a directory path");
return;
}
}
"--port" => {
if i + 1 < args.len() {
match args[i + 1].parse::<u16>() {
Ok(p) => port = p,
Err(_) => {
eprintln!("Invalid port number: {}", args[i + 1]);
return;
}
}
i += 1;
} else {
eprintln!("--port requires a port number");
return;
}
}
_ => {
eprintln!("Unknown option: {}", args[i]);
print_usage();
return;
}
}
i += 1;
}
if !mocks_dir.exists() {
std::fs::create_dir_all(&mocks_dir).unwrap();
}
// Create shared MockManager for both HTTP and MCP
let manager = Arc::new(mock_server::manager::MockManager::new(mocks_dir.clone()));
info!("Loaded {} groups", manager.list_groups().len());
// Run unified HTTP server (includes MCP endpoint)
run_http_server(mocks_dir, port, manager).await;
}
async fn run_http_server(mocks_dir: PathBuf, port: u16, manager: Arc<mock_server::manager::MockManager>) {
info!("Scanning mocks directory...");
let index = MockLoader::load_all_from_dir(&mocks_dir);
let shared_state = Arc::new(AppState {
router: std::sync::RwLock::new(MockRouter::new(index)),
});
// Setup hot-reload watcher
let state_for_watcher = shared_state.clone();
let watch_path = mocks_dir.clone();
let manager_for_watcher = manager.clone();
let (tx, rx) = std::sync::mpsc::channel();
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(_) => {
info!("Detected changes in mocks/, reloading...");
let new_index = MockLoader::load_all_from_dir(&watch_path);
let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock");
*writer = MockRouter::new(new_index);
// Also reload in manager
manager_for_watcher.reload();
info!("Mocks reloaded successfully.");
}
Err(e) => error!("Watcher error: {:?}", e),
}
}
});
// Create MCP HTTP service (stateless)
let mcp_service = mock_server::mcp::create_mcp_http_service(manager.clone());
let app = Router::new()
// MCP endpoint (stateless HTTP transport)
.route("/mcp", post(|req| async move {
mcp_service.handle(req).await
}))
// Mock API fallback
.fallback(axum::routing::any(mock_handler))
.with_state(shared_state);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
info!("HTTP server running at http://{}", addr);
info!("MCP endpoint available at http://{}/mcp", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
if let Err(e) = axum::serve(listener, app).await {
error!("HTTP server error: {}", e);
}
}
+208
View File
@@ -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<MockRule>
groups: RwLock<HashMap<String, Vec<MockRule>>>,
/// 路由索引
router: RwLock<MockRouter>,
}
impl MockManager {
/// 创建新的 MockManager
pub fn new(base_path: PathBuf) -> Self {
let groups = MockLoader::load_all_from_dir(&base_path);
let index = Self::build_index(&groups);
let router = MockRouter::new(index);
Self {
base_path,
groups: RwLock::new(groups),
router: RwLock::new(router),
}
}
/// 从分组构建路由索引
fn build_index(groups: &HashMap<String, Vec<MockRule>>) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
for rules in groups.values() {
for rule in rules {
let key = Self::extract_first_segment(&rule.request.path);
index.entry(key).or_default().push(rule.clone());
}
}
index
}
/// 提取路径首段
fn extract_first_segment(path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string()
}
/// 列出所有规则(可按分组过滤)
pub fn list(&self, group: Option<&str>) -> Vec<(String, MockRule)> {
let groups = self.groups.read().unwrap();
match group {
Some(g) => groups
.get(g)
.map(|rules| rules.iter().map(|r| (g.to_string(), r.clone())).collect())
.unwrap_or_default(),
None => groups
.iter()
.flat_map(|(g, rules)| rules.iter().map(|r| (g.clone(), r.clone())))
.collect(),
}
}
/// 获取单个规则
pub fn get(&self, group: &str, name: &str) -> Option<MockRule> {
let groups = self.groups.read().unwrap();
groups
.get(group)?
.iter()
.find(|r| r.name == name)
.cloned()
}
/// 创建规则
pub fn create(&self, group: &str, rule: MockRule) -> Result<(), String> {
// 1. 写入文件
let dir = self.base_path.join(group);
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?;
let file_path = dir.join(format!("{}.json", rule.name));
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
groups
.entry(group.to_string())
.or_default()
.push(rule.clone());
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 更新规则
pub fn update(&self, group: &str, name: &str, rule: MockRule) -> Result<(), String> {
// 1. 更新文件
let file_path = self.base_path.join(group).join(format!("{}.json", name));
// 如果 name 变化,需要删除旧文件
if name != rule.name {
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除旧文件失败: {}", e))?;
}
let new_path = self.base_path.join(group).join(format!("{}.json", rule.name));
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&new_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
} else {
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
}
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
if let Some(rules) = groups.get_mut(group) {
if let Some(pos) = rules.iter().position(|r| r.name == name) {
rules[pos] = rule.clone();
}
}
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 删除规则
pub fn delete(&self, group: &str, name: &str) -> Result<(), String> {
// 1. 删除文件
let file_path = self.base_path.join(group).join(format!("{}.json", name));
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
}
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
if let Some(rules) = groups.get_mut(group) {
rules.retain(|r| r.name != name);
if rules.is_empty() {
groups.remove(group);
// 尝试删除空目录
let _ = fs::remove_dir(self.base_path.join(group));
}
}
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 重载所有规则(从磁盘重新加载)
pub fn reload(&self) {
let new_groups = MockLoader::load_all_from_dir(&self.base_path);
let new_index = Self::build_index(&new_groups);
let new_router = MockRouter::new(new_index);
{
let mut groups = self.groups.write().unwrap();
*groups = new_groups;
}
{
let mut router = self.router.write().unwrap();
*router = new_router;
}
}
/// 重建 router 索引
fn rebuild_router(&self) {
let groups = self.groups.read().unwrap();
let index = Self::build_index(&groups);
let new_router = MockRouter::new(index);
let mut router = self.router.write().unwrap();
*router = new_router;
}
/// 获取 router 的读锁引用(用于 handler)
pub fn get_router(&self) -> &RwLock<MockRouter> {
&self.router
}
/// 列出所有分组
pub fn list_groups(&self) -> Vec<String> {
let groups = self.groups.read().unwrap();
groups.keys().cloned().collect()
}
}
+3
View File
@@ -0,0 +1,3 @@
mod server;
pub use server::create_mcp_http_service;
+199
View File
@@ -0,0 +1,199 @@
use std::sync::Arc;
use rmcp::{
handler::server::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::*,
tool, tool_handler, tool_router,
ErrorData as McpError, ServerHandler,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::manager::MockManager;
use crate::models::MockRule;
/// MCP Server for Mock Server management
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>,
}
/// Request for getting a specific mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
/// Request for creating a new mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CreateRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Mock rule definition (JSON object)")]
pub rule_json: String,
}
/// Request for updating a mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateRuleRequest {
#[schemars(description = "Group name")]
pub group: String,
#[schemars(description = "Current rule name")]
pub name: String,
#[schemars(description = "Updated rule definition (JSON object)")]
pub rule_json: String,
}
/// Request for deleting a mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteRuleRequest {
#[schemars(description = "Group name")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
/// Request for listing mock rules with optional filter
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListRulesRequest {
#[schemars(description = "Optional group name to filter by")]
pub group: Option<String>,
}
#[tool_router]
impl MockMcpServer {
pub fn new(manager: Arc<MockManager>) -> Self {
Self {
manager,
tool_router: Self::tool_router(),
}
}
#[tool(description = "List all mock rules, optionally filtered by group")]
async fn list_mock_rules(
&self,
params: Parameters<ListRulesRequest>,
) -> Result<CallToolResult, McpError> {
let rules = self.manager.list(params.0.group.as_deref());
let result = serde_json::to_string_pretty(&rules)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
#[tool(description = "Get a specific mock rule by group and name")]
async fn get_mock_rule(
&self,
params: Parameters<GetRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.get(&params.0.group, &params.0.name) {
Some(rule) => {
let result = serde_json::to_string_pretty(&rule)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
None => Ok(CallToolResult::error(vec![Content::text(
format!("Rule not found: {}/{}", params.0.group, params.0.name),
)])),
}
}
#[tool(description = "Create a new mock rule")]
async fn create_mock_rule(
&self,
params: Parameters<CreateRuleRequest>,
) -> Result<CallToolResult, McpError> {
let rule: MockRule = serde_json::from_str(&params.0.rule_json)
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
match self.manager.create(&params.0.group, rule) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Created rule in group '{}'", params.0.group),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Update an existing mock rule")]
async fn update_mock_rule(
&self,
params: Parameters<UpdateRuleRequest>,
) -> Result<CallToolResult, McpError> {
let rule: MockRule = serde_json::from_str(&params.0.rule_json)
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
match self.manager.update(&params.0.group, &params.0.name, rule) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Updated rule {}/{}", params.0.group, params.0.name),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Delete a mock rule")]
async fn delete_mock_rule(
&self,
params: Parameters<DeleteRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.delete(&params.0.group, &params.0.name) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Deleted rule {}/{}", params.0.group, params.0.name),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Reload all mock rules from disk")]
async fn reload_mock_rules(&self) -> Result<CallToolResult, McpError> {
self.manager.reload();
Ok(CallToolResult::success(vec![Content::text(
"Reloaded all rules from disk".to_string(),
)]))
}
#[tool(description = "List all groups (directories)")]
async fn list_groups(&self) -> Result<CallToolResult, McpError> {
let groups = self.manager.list_groups();
let result = serde_json::to_string_pretty(&groups)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
}
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
instructions: Some("Mock Server MCP - Manage mock API rules for development.\n\nUse list_mock_rules to see all rules, create_mock_rule to add new ones, and delete_mock_rule to remove them.".to_string()),
..Default::default()
}
}
}
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::never::NeverSessionManager,
};
use tokio_util::sync::CancellationToken;
/// Create stateless MCP HTTP service for integration with Axum
pub fn create_mcp_http_service(
manager: Arc<MockManager>,
) -> StreamableHttpService<MockMcpServer, NeverSessionManager> {
StreamableHttpService::new(
move || Ok(MockMcpServer::new(manager.clone())),
Arc::new(NeverSessionManager::default()),
StreamableHttpServerConfig {
sse_keep_alive: None,
stateful_mode: false,
cancellation_token: CancellationToken::new(),
},
)
}
+101
View File
@@ -0,0 +1,101 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// 解析后的请求 Body
#[derive(Debug, Clone)]
pub enum Payload {
/// JSON 格式
Json(serde_json::Value),
/// XML 格式
Xml(String),
/// URL 编码表单
Form(HashMap<String, String>),
/// Multipart 表单(键值对)
Multipart(HashMap<String, String>),
/// 纯文本
Text(String),
/// 无 Body
None,
}
impl Payload {
/// 转换为字符串(用于兜底比较)
pub fn to_compare_string(&self) -> String {
match self {
Payload::Json(v) => v.to_string(),
Payload::Xml(s) | Payload::Text(s) => s.clone(),
Payload::Form(map) | Payload::Multipart(map) => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by_key(|(k, _)| *k);
pairs.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
}
Payload::None => String::new(),
}
}
/// 获取对应的 Content-Type 名称
pub fn content_type_name(&self) -> &'static str {
match self {
Payload::Json(_) => "application/json",
Payload::Xml(_) => "application/xml",
Payload::Form(_) => "application/x-www-form-urlencoded",
Payload::Multipart(_) => "multipart/form-data",
Payload::Text(_) => "text/plain",
Payload::None => "none",
}
}
}
/// 核心 Mock 规则定义
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule {
pub name: String,
pub request: RequestMatcher,
pub response: MockResponse,
pub settings: Option<MockSettings>,
}
/// 请求匹配条件
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct RequestMatcher {
pub method: String,
pub path: String,
/// 选填:只有请求包含这些参数时才匹配
pub query_params: Option<HashMap<String, String>>,
/// 选填:只有请求包含这些 Header 时才匹配
pub headers: Option<HashMap<String, String>>,
/// 选填:请求体匹配条件
pub body: Option<serde_json::Value>,
}
/// 响应内容定义
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockResponse {
pub status: u16,
pub headers: Option<HashMap<String, String>>,
/// 统一字段:支持 Inline 文本或 file:// 前缀的路径
pub body: String,
}
/// 模拟器行为设置
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockSettings {
/// 模拟网络延迟(毫秒)
pub delay_ms: Option<u64>,
}
impl MockResponse {
/// 辅助方法:判断是否为文件协议
pub fn is_file_protocol(&self) -> bool {
self.body.starts_with("file://")
}
/// 获取去掉协议前缀后的纯物理路径
pub fn get_file_path(&self) -> Option<&str> {
if self.is_file_protocol() {
Some(&self.body[7..]) // 截取 "file://" 之后的内容
} else {
None
}
}
}
+217
View File
@@ -0,0 +1,217 @@
use std::collections::HashMap;
use crate::models::{MockRule, Payload};
pub struct MockRouter {
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
pub index: HashMap<String, Vec<MockRule>>,
}
impl MockRouter {
pub fn new(index: HashMap<String, Vec<MockRule>>) -> Self {
Self { index }
}
/// 核心匹配函数:根据请求信息寻找匹配的 Mock 规则
pub fn match_rule(
&self,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
payload: &Payload,
) -> Option<&MockRule> {
// 1. 提取请求路径的首段作为索引 Key
let key = self.extract_first_segment(path);
println!("DEBUG: Request Key: '{}', Available Keys: {:?}", key, self.index.keys());
// 2. 从 HashMap 中快速定位候选规则列表 (O(k) 复杂度)
if let Some(rules) = self.index.get(&key) {
// 3. 在候选集中进行线性深度匹配
for rule in rules {
if self.is_match(rule, method, path, queries, headers, payload) {
return Some(rule);
}
}
}
None
}
/// 细粒度匹配逻辑
fn is_match(
&self,
rule: &MockRule,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
payload: &Payload,
) -> bool {
// A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠)
if rule.request.method.to_uppercase() != method.to_uppercase() {
println!("DEBUG: [NAME:{}] Method Mismatch: YAML={}, Req={}", rule.name, rule.request.method, method);
return false;
}
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
println!("DEBUG: [NAME:{}] Path Mismatch: YAML='{}', Req='{}'", rule.name, rule.request.path, path);
return false;
}
println!("DEBUG: [NAME:{}] Method and Path matched! Checking headers...", rule.name);
// B. Query 参数校验 (子集匹配原则)
if let Some(ref required_queries) = rule.request.query_params {
for (key, val) in required_queries {
if queries.get(key) != Some(val) {
return false;
}
}
}
// C. Header 校验 (大小写不敏感,Content-Type 使用前缀匹配)
if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers {
let key_lower = key.to_lowercase();
let matched = headers.iter().any(|(k, v)| {
if k.to_lowercase() != key_lower {
return false;
}
// Content-Type 使用前缀匹配(因为可能包含 boundary 等参数)
if key_lower == "content-type" {
v.to_lowercase().starts_with(&val.to_lowercase())
} else {
v == val
}
});
if !matched {
return false;
}
}
}
// D. Body 匹配(根据 Payload 类型智能比较)
if let Some(ref rule_body) = rule.request.body {
return self.match_body(rule_body, payload);
}
true
}
/// Body 匹配逻辑
fn match_body(&self, rule_body: &serde_json::Value, payload: &Payload) -> bool {
match payload {
Payload::Json(actual) => {
// 如果 rule_body 是字符串,尝试解析为 JSON 后比较
if let Some(rule_str) = rule_body.as_str() {
// 尝试将字符串解析为 JSON
if let Ok(parsed_rule) = serde_json::from_str::<serde_json::Value>(rule_str) {
return &parsed_rule == actual;
}
}
// 直接比较
rule_body == actual
}
Payload::Xml(actual) => {
// XML 字符串比较(规范化后比较)
rule_body.as_str()
.map(|expected| normalize_xml(expected) == normalize_xml(actual))
.unwrap_or(false)
}
Payload::Form(actual) => {
// Form 键值对比较(子集匹配)
compare_form_with_json(rule_body, actual)
}
Payload::Multipart(actual_data) => {
// Multipart 匹配:支持键值对或字段名列表
compare_multipart_with_json(rule_body, actual_data)
}
Payload::Text(actual) => {
// 字符串比较(去掉首尾空白)
rule_body.as_str()
.map(|expected| expected.trim() == actual.trim())
.unwrap_or_else(|| rule_body.to_string().trim() == actual.trim())
}
Payload::None => {
false // 配置了 body,但请求没有 body
}
}
}
/// 与 Loader 保持一致的 Key 提取算法
fn extract_first_segment(&self, path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string()
}
}
/// 规范化 XML 字符串:去掉声明、多余空白、格式化为紧凑形式
fn normalize_xml(xml: &str) -> String {
let mut result = xml.to_string();
// 去掉 XML 声明
if let Some(pos) = result.find("?>") {
if result[..pos].contains("<?xml") {
result = result[pos + 2..].to_string();
}
}
// 去掉多余空白字符
result = result
.chars()
.collect::<Vec<_>>()
.chunks(1)
.map(|c| c[0])
.collect::<String>();
// 分割成行,去掉每行首尾空白,过滤空行
result = result
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<String>();
result
}
/// Form 比较:JSON 中的键值对必须是请求的子集
fn compare_form_with_json(rule_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
let rule_map = match rule_body.as_object() {
Some(obj) => obj,
None => return false,
};
for (key, rule_val) in rule_map {
let expected = rule_val.as_str().map(|s| s.to_string()).unwrap_or_else(|| rule_val.to_string());
if actual.get(key) != Some(&expected) {
return false;
}
}
true
}
/// Multipart 比较:对象和数组形式都只匹配字段名是否存在
fn compare_multipart_with_json(rule_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
// 方式 1:对象形式 - 只匹配键名是否存在(忽略值)
if let Some(rule_map) = rule_body.as_object() {
for key in rule_map.keys() {
if !actual.contains_key(key) {
return false;
}
}
return true;
}
// 方式 2:数组形式 - 只匹配字段名是否存在
if let Some(rule_array) = rule_body.as_array() {
for rule_field in rule_array {
let field_name = rule_field.as_str().map(|s| s.to_string()).unwrap_or_else(|| rule_field.to_string());
if !actual.contains_key(&field_name) {
return false;
}
}
return true;
}
false
}
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
{
"exportInfo": {
"exportedAt": "2026-03-27T10:00:00Z",
"version": "2.0.0"
},
"user": {
"id": 10001,
"username": "admin",
"email": "[email protected]",
"nickname": "管理员",
"role": "administrator",
"createdAt": "2025-01-01T00:00:00Z",
"lastLoginAt": "2026-03-27T09:30:00Z"
},
"preferences": {
"theme": "dark",
"language": "zh-CN",
"notifications": true
}
}
+86
View File
@@ -0,0 +1,86 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
use mock_server::models::MockRule;
use mock_server::loader::MockLoader;
#[test]
fn test_config_deserialization() {
// 测试 1:验证 JSON 解析
let single_json = r#"{
"name": "auth_v1",
"request": {
"method": "POST",
"path": "/api/v1/login"
},
"response": {
"status": 200,
"body": "inline_content"
}
}"#;
let res: MockRule = serde_json::from_str(single_json).expect("应该成功解析 JSON");
assert_eq!(res.name, "auth_v1");
}
#[test]
fn test_recursive_loading_logic() {
// 创建临时 Mock 目录结构
let root_dir = tempdir().expect("创建临时目录失败");
let root_path = root_dir.path();
// 构造物理层级:mocks/v1/user/
let user_dir = root_path.join("v1/user");
fs::create_dir_all(&user_dir).unwrap();
// 在深层目录创建单接口 JSON 文件
let mut file1 = File::create(user_dir.join("get_profile.json")).unwrap();
writeln!(
file1,
r#"{{
"name": "user_profile",
"request": {{
"method": "GET",
"path": "/api/v1/user/profile"
}},
"response": {{
"status": 200,
"body": "profile_data"
}}
}}"#
)
.unwrap();
// 在根目录创建另一个 JSON 文件
let mut file2 = File::create(root_path.join("health.json")).unwrap();
writeln!(
file2,
r#"{{
"name": "sys_health",
"request": {{
"method": "GET",
"path": "/health"
}},
"response": {{
"status": 200,
"body": "ok"
}}
}}"#
)
.unwrap();
// 执行加载
let index = MockLoader::load_all_from_dir(root_path);
// 断言结果:
// 1. 检查 Key 是否根据路径首段正确提取
assert!(index.contains_key("api"), "索引应包含 'api' 键");
assert!(index.contains_key("health"), "索引应包含 'health' 键");
// 2. 检查规则总数
let total_rules: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total_rules, 2, "总规则数应为 2");
// 3. 检查深层文件是否被正确读取
let api_rules = &index["api"];
assert_eq!(api_rules[0].name, "user_profile");
}
+318
View File
@@ -0,0 +1,318 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
use serde_json::json;
use mock_server::models::{MockRule, Payload};
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑
#[test]
fn test_config_parsing_scenarios() {
// 场景 A: 验证单接口配置 (增加 body 结构化校验)
let json_single = r#"{
"name": "auth_v1",
"request": {
"method": "POST",
"path": "/api/v1/login",
"body": { "user": "admin" }
},
"response": { "status": 200, "body": "welcome" }
}"#;
let rule: MockRule = serde_json::from_str(json_single).expect("解析单接口失败");
assert!(rule.request.body.is_some());
assert_eq!(rule.request.body.as_ref().unwrap()["user"], "admin");
// 场景 B: 验证 Smart Body 的 file:// 协议字符串解析
let json_file = r#"{
"name": "export_api",
"request": { "method": "GET", "path": "/download" },
"response": { "status": 200, "body": "file://./storage/data.zip" }
}"#;
let rule_file: MockRule = serde_json::from_str(json_file).unwrap();
assert!(rule_file.response.body.starts_with("file://"));
}
/// 模块二:验证 Loader 递归扫描与索引构建
#[test]
fn test_loader_recursive_indexing() {
let temp_root = tempdir().expect("无法创建临时目录");
let root_path = temp_root.path();
let auth_path = root_path.join("v1/auth");
fs::create_dir_all(&auth_path).unwrap();
let mut f1 = File::create(auth_path.join("login.json")).unwrap();
writeln!(f1, r#"{{"name":"l1","request":{{"method":"POST","path":"/api/v1/login"}},"response":{{"status":200,"body":"ok"}}}}"#).unwrap();
let mut f2 = File::create(root_path.join("health.json")).unwrap();
writeln!(f2, r#"{{"name":"s1","request":{{"method":"GET","path":"/health"}},"response":{{"status":200,"body":"up"}}}}"#).unwrap();
let index = MockLoader::load_all_from_dir(root_path);
assert!(index.contains_key("api"));
assert!(index.contains_key("health"));
let total: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total, 2);
}
/// 模块三:验证 Router 匹配逻辑(使用新的 Payload 类型)
#[test]
fn test_router_matching_logic() {
let mut index = HashMap::new();
// 1. 准备带有 Body 的规则
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.clone()]);
let router = MockRouter::new(index);
// 2. 测试场景 A:JSON 完全匹配
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let payload = Payload::Json(json!({ "code": 123 }));
let matched = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&payload,
);
assert!(matched.is_some());
assert_eq!(matched.unwrap().name, "auth_v1");
// 3. 测试场景 B:Body 不匹配
let wrong_payload = Payload::Json(json!({ "code": 456 }));
let matched_fail = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&wrong_payload,
);
assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
// 4. 测试场景 C:末尾斜杠兼容性测试
let matched_slash = router.match_rule(
"POST",
"/api/v1/login/",
&HashMap::new(),
&headers,
&payload,
);
assert!(matched_slash.is_some());
}
/// 模块四:验证不同 Payload 类型的匹配
#[test]
fn test_payload_type_matching() {
let mut index = HashMap::new();
// XML 规则
let xml_rule: MockRule = serde_json::from_str(r#"{
"name": "xml_api",
"request": {
"method": "POST",
"path": "/api/xml",
"body": "<root><name>test</name></root>"
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
// Form 规则
let form_rule: MockRule = serde_json::from_str(r#"{
"name": "form_api",
"request": {
"method": "POST",
"path": "/api/form",
"body": { "username": "admin", "password": "123456" }
},
"response": { "status": 200, "body": "login_ok" }
}"#).unwrap();
// Text 规则
let text_rule: MockRule = serde_json::from_str(r#"{
"name": "text_api",
"request": {
"method": "POST",
"path": "/api/text",
"body": "plain text content"
},
"response": { "status": 200, "body": "text_ok" }
}"#).unwrap();
index.insert("api".to_string(), vec![xml_rule, form_rule, text_rule]);
let router = MockRouter::new(index);
// 测试 XML 匹配
let xml_payload = Payload::Xml("<root><name>test</name></root>".to_string());
let xml_matched = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_payload);
assert!(xml_matched.is_some(), "XML 应该匹配");
assert_eq!(xml_matched.unwrap().name, "xml_api");
// 测试 Form 匹配
let mut form_data = HashMap::new();
form_data.insert("username".to_string(), "admin".to_string());
form_data.insert("password".to_string(), "123456".to_string());
let form_payload = Payload::Form(form_data);
let form_matched = router.match_rule("POST", "/api/form", &HashMap::new(), &HashMap::new(), &form_payload);
assert!(form_matched.is_some(), "Form 应该匹配");
assert_eq!(form_matched.unwrap().name, "form_api");
// 测试 Text 匹配
let text_payload = Payload::Text("plain text content".to_string());
let text_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &text_payload);
assert!(text_matched.is_some(), "Text 应该匹配");
assert_eq!(text_matched.unwrap().name, "text_api");
// 测试 None 不匹配
let none_payload = Payload::None;
let none_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &none_payload);
assert!(none_matched.is_none(), "None 不应该匹配有 body 的规则");
}
/// 模块五:验证 Payload 方法
#[test]
fn test_payload_methods() {
// 测试 content_type_name
assert_eq!(Payload::Json(json!({})).content_type_name(), "application/json");
assert_eq!(Payload::Xml("".to_string()).content_type_name(), "application/xml");
assert_eq!(Payload::Form(HashMap::new()).content_type_name(), "application/x-www-form-urlencoded");
assert_eq!(Payload::Multipart(HashMap::new()).content_type_name(), "multipart/form-data");
assert_eq!(Payload::Text("".to_string()).content_type_name(), "text/plain");
assert_eq!(Payload::None.content_type_name(), "none");
// 测试 to_compare_string
let json_payload = Payload::Json(json!({"a": 1, "b": 2}));
assert!(json_payload.to_compare_string().contains("\"a\":1"));
let text_payload = Payload::Text("hello world".to_string());
assert_eq!(text_payload.to_compare_string(), "hello world");
let none_payload = Payload::None;
assert_eq!(none_payload.to_compare_string(), "");
// 测试 Form 排序后的字符串
let mut form_map = HashMap::new();
form_map.insert("b".to_string(), "2".to_string());
form_map.insert("a".to_string(), "1".to_string());
let form_payload = Payload::Form(form_map);
assert_eq!(form_payload.to_compare_string(), "a=1&b=2");
// 测试 Multipart 排序后的字符串
let mut multipart_map = HashMap::new();
multipart_map.insert("b".to_string(), "2".to_string());
multipart_map.insert("a".to_string(), "1".to_string());
let multipart_payload = Payload::Multipart(multipart_map);
assert_eq!(multipart_payload.to_compare_string(), "a=1&b=2");
}
/// 模块六:验证 Multipart 匹配
#[test]
fn test_multipart_matching() {
let mut index = HashMap::new();
// 数组形式:只匹配字段名
let array_rule: MockRule = serde_json::from_str(r#"{
"name": "upload_array",
"request": {
"method": "POST",
"path": "/api/upload/array",
"body": ["file", "description"]
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
// 对象形式:匹配键名
let object_rule: MockRule = serde_json::from_str(r#"{
"name": "upload_object",
"request": {
"method": "POST",
"path": "/api/upload/object",
"body": { "username": "admin", "role": "user" }
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
index.insert("api".to_string(), vec![array_rule, object_rule]);
let router = MockRouter::new(index);
// 测试数组形式:匹配字段名
let mut full_data = HashMap::new();
full_data.insert("file".to_string(), "file_content".to_string());
full_data.insert("description".to_string(), "test file".to_string());
let full_payload = Payload::Multipart(full_data);
let matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &full_payload);
assert!(matched.is_some(), "包含所有字段应该匹配");
// 测试数组形式:缺少字段
let mut partial_data = HashMap::new();
partial_data.insert("file".to_string(), "file_content".to_string());
let partial_payload = Payload::Multipart(partial_data);
let not_matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &partial_payload);
assert!(not_matched.is_none(), "缺少字段不应该匹配");
// 测试对象形式:键名匹配(值被忽略)
let mut correct_data = HashMap::new();
correct_data.insert("username".to_string(), "any_value".to_string());
correct_data.insert("role".to_string(), "any_role".to_string());
let correct_payload = Payload::Multipart(correct_data);
let object_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &correct_payload);
assert!(object_matched.is_some(), "键名匹配应该成功");
// 测试对象形式:键名不匹配
let mut wrong_data = HashMap::new();
wrong_data.insert("username".to_string(), "admin".to_string());
wrong_data.insert("other_field".to_string(), "value".to_string());
let wrong_payload = Payload::Multipart(wrong_data);
let wrong_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &wrong_payload);
assert!(wrong_matched.is_none(), "缺少键名不应该成功");
}
/// 模块七:验证 XML 格式化匹配
#[test]
fn test_xml_normalized_matching() {
let mut index = HashMap::new();
// JSON 中的 XML(紧凑格式)
let xml_rule: MockRule = serde_json::from_str(r#"{
"name": "xml_api",
"request": {
"method": "POST",
"path": "/api/xml",
"body": "<user><id>1001</id><name>张三</name></user>"
},
"response": { "status": 200, "body": "ok" }
}"#).unwrap();
index.insert("api".to_string(), vec![xml_rule]);
let router = MockRouter::new(index);
// 测试 1:紧凑格式的 XML
let compact_xml = Payload::Xml("<user><id>1001</id><name>张三</name></user>".to_string());
let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml);
assert!(matched1.is_some(), "紧凑格式 XML 应该匹配");
// 测试 2:带 XML 声明的请求
let xml_with_decl = Payload::Xml("<?xml version=\"1.0\" encoding=\"UTF-8\"?><user><id>1001</id><name>张三</name></user>".to_string());
let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl);
assert!(matched2.is_some(), "带声明的 XML 应该匹配");
// 测试 3:内容不同,不应该匹配
let wrong_xml = Payload::Xml("<user><id>1002</id><name>李四</name></user>".to_string());
let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml);
assert!(matched3.is_none(), "内容不同的 XML 不应该匹配");
}
+226
View File
@@ -0,0 +1,226 @@
use std::path::Path;
use std::collections::HashMap;
use serde_json::json;
use mock_server::models::Payload;
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
/// 加载 v1 目录的 mock 规则
fn load_v1_mocks() -> HashMap<String, Vec<mock_server::models::MockRule>> {
let v1_path = Path::new("./mocks/v1");
MockLoader::load_all_from_dir(v1_path)
}
// ========== 模块一:验证所有 JSON 文件正确加载 ==========
#[test]
fn test_v1_load_all_mocks() {
let index = load_v1_mocks();
// 验证索引键存在
assert!(index.contains_key("v1"), "应包含 'v1' 索引键");
// 验证规则总数
let total: usize = index.values().map(|v| v.len()).sum();
assert!(total >= 10, "v1 目录应有至少 10 个 mock 规则");
}
// ========== 模块二:JSON Payload 测试 ==========
#[test]
fn test_v1_json_login() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
headers.insert("Authorization".to_string(), "eyJhbGciOiJIUzI1NiIsInR5cCI6".to_string());
headers.insert("host".to_string(), "127.0.0.1:8080".to_string());
let payload = Payload::Json(json!({
"username": "user001",
"password": "password123"
}));
let matched = router.match_rule("POST", "/v1/auth/login", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "JSON 登录应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_login_001");
assert_eq!(rule.response.status, 200);
}
#[test]
fn test_v1_json_register() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let payload = Payload::Json(json!({
"username": "newuser",
"password": "newpass123",
"email": "[email protected]"
}));
let matched = router.match_rule("POST", "/v1/auth/register", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "JSON 注册应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_register");
assert_eq!(rule.response.status, 201);
}
// ========== 模块三:Form Payload 测试 ==========
#[test]
fn test_v1_form_login() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/x-www-form-urlencoded".to_string());
let mut form_data = HashMap::new();
form_data.insert("username".to_string(), "formuser".to_string());
form_data.insert("password".to_string(), "formpass".to_string());
let payload = Payload::Form(form_data);
let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Form 登录应匹配成功");
assert_eq!(matched.unwrap().name, "_user_login_form");
}
// ========== 模块四:Text Payload 测试 ==========
#[test]
fn test_v1_text_echo() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "text/plain".to_string());
let payload = Payload::Text("Hello V1 Mock Server".to_string());
let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Text 回显应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_echo");
assert!(rule.response.body.contains("Echo from V1"));
}
// ========== 模块五:XML Payload 测试 ==========
#[test]
fn test_v1_xml_export() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/xml".to_string());
let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?><request><userId>10001</userId><format>xml</format></request>"#;
let payload = Payload::Xml(xml_body.to_string());
let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "XML 导出应匹配成功");
assert_eq!(matched.unwrap().name, "data_export");
}
// ========== 模块六:Multipart Payload 测试 ==========
#[test]
fn test_v1_multipart_upload() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "multipart/form-data".to_string());
let mut multipart_data = HashMap::new();
multipart_data.insert("avatar1".to_string(), "file_content".to_string());
multipart_data.insert("description1".to_string(), "user avatar".to_string());
let payload = Payload::Multipart(multipart_data);
let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Multipart 上传应匹配成功");
assert_eq!(matched.unwrap().name, "user_upload_avatar_001");
}
// ========== 模块七:None Payload 测试 (GET 无 Body) ==========
#[test]
fn test_v1_health_check() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/health", &HashMap::new(), &HashMap::new(), &payload);
assert!(matched.is_some(), "健康检查应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "health_check");
assert_eq!(rule.response.status, 200);
}
#[test]
fn test_v1_get_profile() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "获取用户信息应匹配成功");
assert_eq!(matched.unwrap().name, "user_profile");
}
// ========== 模块八:Query Params 测试 ==========
#[test]
fn test_v1_query_params() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut query = HashMap::new();
query.insert("format".to_string(), "json".to_string());
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/user/download", &query, &HashMap::new(), &payload);
assert!(matched.is_some(), "带 query params 的下载应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_download");
assert!(rule.response.body.starts_with("file://"));
}
// ========== 模块九:Header 匹配测试 ==========
#[test]
fn test_v1_header_required() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
// 无 Authorization header 不应匹配
let payload = Payload::None;
let no_match = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &HashMap::new(), &payload);
assert!(no_match.is_none(), "无 Authorization 不应匹配");
// 有正确的 Authorization header 应匹配
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "有 Authorization 应匹配");
}