3 Commits

Author SHA1 Message Date
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
d364307131 feat: mock配置迁移至JSON格式并修复body匹配
- 将mock配置从YAML格式迁移到JSON格式
- 修复JSON字符串格式body匹配失败问题
- 添加MCP功能模块
- 更新mock-spec.md规范文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 09:43:11 +08:00
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
99 changed files with 3224 additions and 1472 deletions

View File

@@ -1,103 +0,0 @@
---
name: wechat-article-summarizer
description: "Use this agent when the user wants to summarize WeChat official account articles (公众号文章). This includes when the user shares a WeChat article link, asks for a summary of an article, or wants key points extracted from WeChat content.\\n\\nExamples:\\n\\n<example>\\nContext: User shares a WeChat article link and wants a summary.\\nuser: \"帮我总结一下这篇文章https://mp.weixin.qq.com/s/xxxxx\"\\nassistant: \"我来使用公众号文章总结代理来帮你总结这篇文章。\"\\n<commentary>\\nSince the user wants to summarize a WeChat article, use the Agent tool to launch the wechat-article-summarizer agent.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: User has pasted WeChat article content and wants key points.\\nuser: \"这篇文章讲了什么?[粘贴了公众号文章内容]\"\\nassistant: \"我来使用公众号文章总结代理帮你提取这篇文章的要点。\"\\n<commentary>\\nThe user has provided WeChat article content and wants to understand the main points. Use the Agent tool to launch the wechat-article-summarizer agent.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: User wants a quick overview of a shared WeChat article.\\nuser: \"这个公众号文章太长了,帮我看看主要说什么\"\\nassistant: \"我来用公众号文章总结代理帮你快速了解文章主旨。\"\\n<commentary>\\nThe user finds the article too long and wants a quick overview. Use the Agent tool to launch the wechat-article-summarizer agent.\\n</commentary>\\n</example>"
model: sonnet
color: blue
memory: project
---
你是一位专业的公众号文章总结专家擅长快速阅读和提炼中文文章的核心内容。你具备优秀的阅读理解能力和信息提取技巧能够准确把握文章主旨并生成结构清晰的摘要并将总结结果生成rules。
## 核心职责
你的任务是为用户提供高质量的公众号文章总结帮助他们快速理解文章内容并生成rules。
## 工作流程
1. **获取文章内容**
- 如果用户提供链接,使用可用的工具获取文章内容
- 如果用户直接粘贴内容,直接处理该内容
2. **分析文章结构**
- 识别文章标题和主题
- 找出核心论点和关键信息
- 标记重要的数据、案例或引用
- 注意文章的写作目的(科普、观点、新闻等)
3. **生成摘要**
输出格式如下:
### 📌 核心主旨
[一句话概括文章主题]
### 📝 内容概要
[100-200字的文章概述]
### 🔑 关键要点
- 要点1
- 要点2
- 要点3
- ...通常3-6个要点
### 💡 金句摘录
> [文章中的精彩语句,如有]
### ⚖️ 个人观点(可选)
[如果文章有争议性或值得讨论的观点,简要说明]
## 质量标准
- **准确性**:摘要必须忠实于原文,不能曲解或添加原文没有的信息
- **简洁性**:去除冗余信息,保留核心内容
- **完整性**:涵盖文章的主要观点和重要细节
- **可读性**:使用清晰的语言,逻辑结构分明
## 注意事项
- 如果文章内容无法获取,明确告知用户并说明原因
- 对于长文章,优先提取核心观点,次要内容可以略过
- 如果文章包含专业术语,适当提供简单解释
- 保持客观中立的立场,不在摘要中加入个人偏见
- 使用中文进行总结
- 在markdown文件中禁止使用emoji根据用户配置要求在最终输出时移除所有emoji
## 处理特殊情况
- **付费文章**:告知用户无法访问付费内容
- **已删除文章**:说明文章可能已被删除
- **图片为主的内容**:说明文章以图片为主,总结可识别的文字部分
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `D:\CNWei\CNW\Rust\mock-server\.claude\agent-memory\wechat-article-summarizer\`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- When the user corrects you on something you stated from memory, you MUST update or remove the incorrect entry. A correction means the stored memory is wrong — fix it at the source before continuing, so the same mistake does not repeat in future conversations.
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

View File

@@ -1,20 +0,0 @@
# Git 分支管理
读取 `.claude/rules/git-branch-specification.md` 规范,帮助用户:
1. **生成合规分支名** - 根据用户输入的项目名、版本号,自动生成符合规范的分支名
2. **校验分支名** - 检查当前分支名是否符合规范
3. **创建分支** - 按规范创建新分支
## 使用示例
- `/git-branch new feature 项目名 v1.0.0` - 创建功能分支
- `/git-branch new hotfix 项目名 v1.0.1` - 创建热修复分支
- `/git-branch check` - 校验当前分支名
- `/git-branch help` - 显示命名规范
## 执行步骤
1. 读取规范文件 `.claude/rules/git-branch-specification.md`
2. 根据用户指令执行对应操作
3. 生成分支名时使用当天日期格式YYYYMMDD

View File

@@ -1,75 +0,0 @@
# Git分支管理规范
## 分支类型
| 类型 | 分支名称 | 用途 | 特点 |
|------|----------|------|------|
| 主分支 | master | 正式版本代码归档 | 受保护,仅运维可合并 |
| 主分支 | develop | 日常开发主分支 | 团队开发基准 |
| 主分支 | doc | 文档、SQL脚本、配置 | 文档管理 |
| 辅助分支 | feature | 功能开发分支 | 临时性,合并后删除 |
| 辅助分支 | hotfix | Bug紧急修复 | 临时性,合并后删除 |
## 分支命名规则
### 功能分支
```
feature-{项目名}-{版本号}-SNAPSHOT-{日期}
```
示例:`feature-javadog-v2.1.1-SNAPSHOT-20240703`
### 个人分支
```
{功能分支}-{开发者姓名}
```
示例:`feature-javadog-v2.1.1-SNAPSHOT-20240703-zhangsan`
### 预生产分支
```
feature-{项目名}-{版本号}-{日期}
```
示例:`feature-javadog-v2.1.1-20240703`去除SNAPSHOT标识
### 热修复分支
```
hotfix-{项目名}-{版本号}-{日期}
```
示例:`hotfix-javadog-v2.1.2-20240705`
## 分支权限规则
1. master/develop/doc 分支受保护,仅运维可合并
2. 辅助分支为临时分支,合并后询问开发人员是否需要删除
## 开发流程规则
### 功能开发流程(五阶段)
| 阶段 | 操作 | 核心目的 |
|------|------|----------|
| 开发前 | 从develop拉取功能分支 | 保持团队起始点一致 |
| 开发中 | 组员从功能分支拉取个人临时分支 | 保证开发灵活性 |
| 提测中 | 个人分支合并到功能分支 | 流水线打包提测 |
| 预生产 | 从功能分支拉取预生产分支去SNAPSHOT标识 | 环境验证解耦 |
| 上线 | 蓝绿部署,分支合并 | 无缝切换,稳定上线 |
### 热修复流程
1. 从 master 拉取 hotfix 分支
2. 修复 Bug 后合并回 master 和 develop
3. 合并后删除 hotfix 分支
## 蓝绿部署策略
- 定义:同时运行两个生产环境,通过切换实现无缝发布
- 优势:新版本测试期间不影响线上环境
- 流程:蓝线发布新版本 -> 验证通过 -> 蓝绿切换 -> 负载均衡
## 命名示例汇总
| 分支类型 | 命名格式 | 示例 |
|---------|---------|------|
| 功能分支 | `feature-{项目}-{版本}-SNAPSHOT-{日期}` | `feature-javadog-v2.1.1-SNAPSHOT-20240703` |
| 个人分支 | `{功能分支}-{姓名}` | `feature-javadog-v2.1.1-SNAPSHOT-20240703-zhangsan` |
| 预生产分支 | `feature-{项目}-{版本}-{日期}` | `feature-javadog-v2.1.1-20240703` |
| 热修复分支 | `hotfix-{项目}-{版本}-{日期}` | `hotfix-javadog-v2.1.2-20240705` |

View File

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

View File

@@ -3,7 +3,7 @@
"allow": [ "allow": [
"Bash(find:*)", "Bash(find:*)",
"Bash(cargo search:*)", "Bash(cargo search:*)",
"WebSearch" "Bash(cd:*)"
] ]
} }
} }

924
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "mock_server" name = "mock_server"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
[dependencies] [dependencies]
# 核心 Web 框架 # 核心 Web 框架
@@ -12,27 +12,25 @@ tokio={version = "1.48.0",features = ["full"]}
tokio-util = {version = "0.7.17",features = ["io"]} tokio-util = {version = "0.7.17",features = ["io"]}
futures-util = "0.3.31" futures-util = "0.3.31"
# 序列化与 YAML 解析 # 序列化
serde = {version = "1.0.228",features = ["derive"]} serde = {version = "1.0.228",features = ["derive"]}
serde_yaml = "0.9.34+deprecated"
serde_json = "1.0.147" serde_json = "1.0.147"
# 物理目录递归扫描工具 # 物理目录递归扫描工具
walkdir = "2.5.0" walkdir = "2.5.0"
tracing="0.1.44" # 日志系统
tracing-subscriber = "0.3.22" 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" notify = "8.2.0"
notify-debouncer-mini = "0.6.0" notify-debouncer-mini = "0.6.0"
# 路径处理
#pathdiff = "0.2.3" # MCP Server 支持
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
schemars = "1.0"
[dev-dependencies] [dev-dependencies]
tempfile = "3.24.0" tempfile = "3.24.0"
[build-dependencies]
winres = "0.1"

View File

@@ -1,29 +0,0 @@
fn main() {
// 仅在 Windows 平台上编译资源
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
let mut res = winres::WindowsResource::new();
// 1. 设置图标路径 (请确保 icons 文件夹下有 icon.ico)
res.set_icon("icons/icon.ico");
// 2. 自动同步 Cargo.toml 中的元数据
// 使用 env! 宏在编译阶段获取 package 信息
res.set("FileDescription", env!("CARGO_PKG_DESCRIPTION"));
res.set("ProductName", "Mock Server");
res.set("ProductVersion", env!("CARGO_PKG_VERSION"));
res.set("FileVersion", env!("CARGO_PKG_VERSION"));
res.set("InternalName", &format!("{}.exe", env!("CARGO_PKG_NAME")));
// 3. 设置版权信息
res.set("LegalCopyright", "Copyright © 2026 Ways");
// 4. 设置语言为中文 (中国)
res.set_language(0x0804);
// 执行编译,如果失败则打印错误并停止
if let Err(e) = res.compile() {
eprintln!("资源编译失败: {}", e);
std::process::exit(1);
}
}
}

427
docs/mcp-implementation.md Normal file
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/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,28 +0,0 @@
id: "auth_login_001"
request:
method: "POST"
path: "/api/v1/auth/login"
# 必须包含此 Header 才会匹配
headers:
Content-Type: "application/json"
Authorization: "111"
host: "127.0.0.1:8080"
body: >
{
"username":"user",
"password":"123"
}
response:
status: 200
headers:
Content-Type: "application/json"
X-Mock-Engine: "Rust-Gemini-v1.2"
# 直接内联 JSON 字符串
body: >
{
"code": 0,
"data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6" },
"msg": "success"
}
settings:
delay_ms: 2000 # 模拟真实网络延迟

View File

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

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

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

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

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

View File

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

View File

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

18
mocks/v1/data/export.json Normal file
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>admin@example.com</email></user></data></response>"
}
}

14
mocks/v1/health.json Normal file
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\"}"
}
}

View File

@@ -1,12 +0,0 @@
id: "prod_export_pdf"
request:
method: "GET"
path: "/api/v1/products/report"
body: '{"username":"user","password":"123"}'
response:
status: 200
headers:
Content-Type: "application/pdf"
Content-Disposition: "attachment; filename=report.pdf"
# 智能协议:引擎会自动识别前缀并异步读取磁盘文件
body: "file://./storage/reports/annual_2024.pdf"

View File

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

View File

@@ -1,16 +0,0 @@
# 使用 YAML 数组语法定义多个规则
- id: "sys_ping"
request:
method: "GET"
path: "/api/v1/ping"
response:
status: 200
body: "pong"
- id: "sys_version"
request:
method: "GET"
path: "/api/v1/version"
response:
status: 200
body: '{"version": "1.2.0-smart"}'

View File

@@ -1,23 +0,0 @@
id: "upload_file"
request:
method: "POST"
path: "/api/v1/upload"
headers:
Content-Type: "multipart/form-data"
response:
status: 200
headers:
Content-Type: "application/json"
body: >
{
"code": 0,
"data": {
"filename": "example.txt",
"path": "storage/2024-01-15/example.txt",
"size": 1024,
"url": "/storage/2024-01-15/example.txt"
},
"msg": "upload success"
}
settings:
delay_ms: 100

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

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

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

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
mocks/v1/user/echo.json Normal file
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"
}
}

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

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\":\"admin@example.com\",\"nickname\":\"管理员\",\"avatar\":\"https://example.com/avatars/admin.jpg\",\"createdAt\":\"2025-01-01T00:00:00Z\"}}"
}
}

View File

@@ -1,11 +0,0 @@
id: "user_search_admin"
request:
method: "GET"
path: "/api/v1/users"
# 请求中必须包含 role=admin 且 status=active
query_params:
role: "admin"
status: "active"
response:
status: 200
body: '{"users": [{"id": 1, "name": "SuperAdmin"}]}'

239
plan.md
View File

@@ -1,239 +0,0 @@
# Body 匹配优化:基于 Content-Type 的智能解析
## Context
**问题**: 当前只支持 JSON body 匹配,非 JSON 请求无法正确匹配。
**需求**: 根据配置和请求的 Content-Type 智能选择解析方式,支持 JSON、XML、Form、Text 等类型。
---
## 核心设计
### Body 解析规则(优先级)
| 优先级 | YAML Content-Type | 请求 Content-Type | 解析方式 |
|--------|-------------------|-------------------|----------|
| 1 | 有 | 任意 | 按 **YAML** 的类型解析 |
| 2 | 无 | 有 | 按 **请求**的 Content-Type 解析 |
| 3 | 无 | 无 | **字符串**比较 |
### 支持的 Content-Type
| Content-Type | 解析结果 |
|--------------|----------|
| `application/json` | `ParsedBody::Json(Value)` |
| `application/xml`, `text/xml` | `ParsedBody::Xml(String)` |
| `application/x-www-form-urlencoded` | `ParsedBody::Form(HashMap)` |
| `multipart/form-data` | `ParsedBody::Multipart(Vec<String>)` |
| `text/plain` 或其他 | `ParsedBody::Text(String)` |
---
## 实现方案
### Step 1: 新增数据结构 (model.rs)
```rust
/// 解析后的请求 Body
#[derive(Debug, Clone)]
pub enum ParsedBody {
Json(serde_json::Value),
Xml(String),
Form(HashMap<String, String>),
Multipart(Vec<String>), // 字段名列表
Text(String),
None,
}
impl ParsedBody {
/// 转换为字符串(用于兜底比较)
pub fn to_compare_string(&self) -> String {
match self {
ParsedBody::Json(v) => v.to_string(),
ParsedBody::Xml(s) | ParsedBody::Text(s) => s.clone(),
ParsedBody::Form(map) => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by_key(|(k, _)| *k);
pairs.iter().map(|(k, v)| format!("{}={}", k, v)).join("&")
}
ParsedBody::Multipart(fields) => fields.join(","),
ParsedBody::None => String::new(),
}
}
}
```
### Step 2: 解析函数 (handler.rs)
```rust
/// 提取 Content-Type去掉参数部分
fn extract_content_type(headers: &HashMap<String, String>) -> Option<String> {
headers.iter()
.find(|(k, _)| k.to_lowercase() == "content-type")
.map(|(_, v)| v.split(';').next().unwrap_or(v).trim().to_lowercase())
}
/// 根据 Content-Type 解析 Body
fn parse_body(content_type: Option<&str>, bytes: &[u8]) -> ParsedBody {
if bytes.is_empty() {
return ParsedBody::None;
}
match content_type {
Some(ct) if ct.contains("application/json") => {
serde_json::from_slice(bytes)
.map(ParsedBody::Json)
.unwrap_or_else(|_| ParsedBody::Text(String::from_utf8_lossy(bytes).to_string()))
}
Some(ct) if ct.contains("xml") => {
ParsedBody::Xml(String::from_utf8_lossy(bytes).to_string())
}
Some(ct) if ct.contains("form-urlencoded") => {
ParsedBody::Form(parse_urlencoded(bytes))
}
Some(ct) if ct.contains("multipart/form-data") => {
ParsedBody::Multipart(extract_multipart_fields(bytes))
}
_ => {
ParsedBody::Text(String::from_utf8_lossy(bytes).to_string())
}
}
}
```
### Step 3: 确定解析类型 (handler.rs)
```rust
// 在 mock_handler 中:
// 1. 提取请求的 Content-Type
let req_content_type = extract_content_type(&req_headers);
// 2. 读取请求 body
let body_bytes = ...;
let parsed_body = parse_body(req_content_type.as_deref(), &body_bytes);
// 3. 匹配时传递 req_headers 和 parsed_body
// router 会根据 YAML 中是否配置了 Content-Type 来决定使用哪个类型
```
### Step 4: 匹配逻辑 (router.rs)
```rust
fn match_body(
&self,
rule: &MockRule,
parsed_body: &ParsedBody,
req_content_type: Option<&str>,
) -> bool {
let yaml_body = match &rule.request.body {
Some(b) => b,
None => return true, // YAML 没配置 body跳过检查
};
// 确定用于解析/比较的 Content-Type
let effective_content_type = rule.request.headers
.as_ref()
.and_then(|h| h.iter()
.find(|(k, _)| k.to_lowercase() == "content-type")
.map(|(_, v)| v.as_str()))
.or(req_content_type); // YAML 优先,没有则用请求的
match effective_content_type {
Some(ct) if ct.contains("application/json") => {
// JSON 比较
match parsed_body {
ParsedBody::Json(actual) => yaml_body == actual,
_ => false,
}
}
Some(ct) if ct.contains("xml") => {
// XML 字符串比较
match parsed_body {
ParsedBody::Xml(actual) => yaml_body.as_str()
.map(|expected| expected.trim() == actual.trim())
.unwrap_or(false),
_ => false,
}
}
Some(ct) if ct.contains("form-urlencoded") => {
// Form 比较
match parsed_body {
ParsedBody::Form(actual) => compare_form(yaml_body, actual),
_ => false,
}
}
_ => {
// 无 Content-Type 或其他类型:字符串比较
let actual_str = parsed_body.to_compare_string();
let expected_str = yaml_body.to_string();
expected_str.trim() == actual_str.trim()
}
}
}
```
---
## 需要修改的文件
| 文件 | 改动 |
|------|------|
| `src/model.rs` | 新增 `ParsedBody` 枚举和 `to_compare_string()` 方法 |
| `src/handler.rs` | 新增 `extract_content_type()``parse_body()`、辅助解析函数 |
| `src/router.rs` | 新增 `match_body()` 方法,调整 `is_match()` 调用 |
| `Cargo.toml` | 可能需要 `urlencoding` 依赖form 解码) |
---
## YAML 配置示例
```yaml
# JSON 匹配(指定 Content-Type
- id: login-json
request:
method: POST
path: /api/login
headers:
Content-Type: application/json
body:
username: admin
# XML 匹配
- id: login-xml
request:
method: POST
path: /api/login
headers:
Content-Type: application/xml
body: "<user><name>admin</name></user>"
# Form 匹配
- id: login-form
request:
method: POST
path: /api/login
headers:
Content-Type: application/x-www-form-urlencoded
body:
username: admin
# 字符串匹配(无 Content-Type
- id: echo
request:
method: POST
path: /api/echo
body: "hello world"
```
---
## 验证
1. `cargo test`
2. 手动测试各类型:
- JSON 请求 + JSON 规则 → 匹配
- XML 请求 + XML 规则 → 匹配
- Form 请求 + Form 规则 → 匹配
- 无 Content-Type → 字符串比较

375
plan2.md
View File

@@ -1,375 +0,0 @@
# Body 匹配优化:基于 Content-Type 的智能解析
## Context
**问题**: 当前只支持 JSON body 匹配,非 JSON 请求无法正确匹配。
**需求**: 根据请求的 Content-Type 智能解析 body同时支持 header 匹配校验。
---
## 核心设计
### 关键原则
1. **Body 解析**:始终以【请求的 Content-Type】为准因为这是数据的真实格式
2. **Header 匹配**Content-Type 当作普通 header 处理,写了就匹配,没写跳过
3. **自动补充的 header**Content-Length、Accept-Encoding 等不参与匹配(除非 YAML 显式配置)
### 处理流程
```
┌─────────────────────────────────────────────────────────────┐
│ 请求处理流程 │
├─────────────────────────────────────────────────────────────┤
│ 1. 解析请求 body │
│ └── 始终以【请求的 Content-Type】为准解析 │
│ │
│ 2. Header 匹配(包含 Content-Type
│ └── YAML 写了就匹配,没写就跳过 │
│ │
│ 3. Body 匹配 │
│ └── 根据匹配成功的 Content-Type 决定比较方式 │
└─────────────────────────────────────────────────────────────┘
```
### 匹配示例
| YAML Content-Type | 请求 Content-Type | Header 匹配 | Body 解析 | 结果 |
|-------------------|-------------------|-------------|-----------|------|
| `application/xml` | `application/json` | 失败 | - | 不匹配 |
| `application/json` | `application/json` | 成功 | JSON | 比较 body |
| 无 | `application/json` | 跳过 | JSON | 比较 body |
| 无 | `application/xml` | 跳过 | XML | 比较 body |
| 无 | 无 | 跳过 | 字符串 | 比较 body |
---
## 实现方案
### Step 1: 新增数据结构 (model.rs)
```rust
/// 解析后的请求 Body
#[derive(Debug, Clone)]
pub enum ParsedBody {
Json(serde_json::Value),
Xml(String),
Form(HashMap<String, String>),
Multipart(Vec<String>), // 字段名列表
Text(String),
None,
}
impl ParsedBody {
/// 转换为字符串(用于兜底比较)
pub fn to_compare_string(&self) -> String {
match self {
ParsedBody::Json(v) => v.to_string(),
ParsedBody::Xml(s) | ParsedBody::Text(s) => s.clone(),
ParsedBody::Form(map) => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by_key(|(k, _)| *k);
pairs.iter().map(|(k, v)| format!("{}={}", k, v)).join("&")
}
ParsedBody::Multipart(fields) => fields.join(","),
ParsedBody::None => String::new(),
}
}
/// 获取对应的 Content-Type 名称
pub fn content_type_name(&self) -> &'static str {
match self {
ParsedBody::Json(_) => "application/json",
ParsedBody::Xml(_) => "application/xml",
ParsedBody::Form(_) => "application/x-www-form-urlencoded",
ParsedBody::Multipart(_) => "multipart/form-data",
ParsedBody::Text(_) => "text/plain",
ParsedBody::None => "none",
}
}
}
```
### Step 2: Body 解析函数 (handler.rs)
```rust
/// 提取请求的 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]) -> ParsedBody {
if bytes.is_empty() {
return ParsedBody::None;
}
match content_type {
Some(ct) if ct.contains("application/json") => {
serde_json::from_slice(bytes)
.map(ParsedBody::Json)
.unwrap_or_else(|_| {
// JSON 解析失败,降级为文本
ParsedBody::Text(String::from_utf8_lossy(bytes).to_string())
})
}
Some(ct) if ct.contains("xml") => {
ParsedBody::Xml(String::from_utf8_lossy(bytes).to_string())
}
Some(ct) if ct.contains("form-urlencoded") => {
ParsedBody::Form(parse_urlencoded(bytes))
}
Some(ct) if ct.contains("multipart/form-data") => {
ParsedBody::Multipart(extract_multipart_fields(bytes))
}
_ => {
ParsedBody::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('=') {
// URL 解码
let decoded_key = urlencoding_decode(key);
let decoded_value = urlencoding_decode(value);
map.insert(decoded_key, decoded_value);
}
}
map
}
```
### Step 3: 修改 handler.rs 主函数
```rust
pub async fn mock_handler(
State(state): State<Arc<AppState>>,
method: Method,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
req: Request<Body>,
) -> impl IntoResponse {
let path = req.uri().path().to_string();
let method_str = method.as_str().to_string();
// 1. 提取请求的 Content-Type
let req_content_type = extract_content_type(&headers);
// 2. 读取请求 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();
}
};
// 3. 根据【请求的 Content-Type】解析 body
let parsed_body = parse_body(req_content_type.as_deref(), &body_bytes);
// 4. 转换 headers 为 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());
}
}
// 5. 执行匹配
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()
};
// 6. 构建响应(与现有逻辑相同)
// ...
}
```
### Step 4: 修改 router.rs 匹配逻辑
```rust
/// 核心匹配函数
pub fn match_rule(
&self,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
parsed_body: &ParsedBody, // 改为 ParsedBody
) -> Option<&MockRule> {
let key = self.extract_first_segment(path);
if let Some(rules) = self.index.get(&key) {
for rule in rules {
if self.is_match(rule, method, path, queries, headers, parsed_body) {
return Some(rule);
}
}
}
None
}
fn is_match(
&self,
rule: &MockRule,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
parsed_body: &ParsedBody,
) -> bool {
// A. Method 匹配
if rule.request.method.to_uppercase() != method.to_uppercase() {
return false;
}
// B. Path 匹配
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
return false;
}
// C. 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;
}
}
}
// D. Header 匹配(包含 Content-Type
// YAML 写了就匹配,没写就跳过
if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers {
let matched = headers.iter().any(|(k, v)| {
k.to_lowercase() == key.to_lowercase() && v == val
});
if !matched {
return false; // Header 不匹配,包括 Content-Type
}
}
}
// E. Body 匹配
// YAML 写了 body 才匹配,没写跳过
if let Some(ref yaml_body) = rule.request.body {
return self.match_body(yaml_body, parsed_body);
}
true
}
/// Body 匹配逻辑
fn match_body(&self, yaml_body: &serde_json::Value, parsed_body: &ParsedBody) -> bool {
match parsed_body {
ParsedBody::Json(actual) => {
// JSON 对象比较
yaml_body == actual
}
ParsedBody::Xml(actual) => {
// XML 字符串比较
yaml_body.as_str()
.map(|expected| expected.trim() == actual.trim())
.unwrap_or(false)
}
ParsedBody::Form(actual) => {
// Form 键值对比较(子集匹配)
compare_form_with_yaml(yaml_body, actual)
}
ParsedBody::Multipart(actual_fields) => {
// Multipart 字段名比较
compare_multipart_with_yaml(yaml_body, actual_fields)
}
ParsedBody::Text(actual) => {
// 字符串比较
yaml_body.as_str()
.map(|expected| expected.trim() == actual.trim())
.unwrap_or_else(|| yaml_body.to_string().trim() == actual.trim())
}
ParsedBody::None => {
false // YAML 配置了 body但请求没有 body
}
}
}
/// Form 比较YAML 中的键值对必须是请求的子集
fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
let yaml_map = match yaml_body.as_object() {
Some(obj) => obj,
None => return false,
};
for (key, yaml_val) in yaml_map {
let expected = yaml_val.as_str().unwrap_or(&yaml_val.to_string());
if actual.get(key) != Some(&expected.to_string()) {
return false;
}
}
true
}
```
---
## 需要修改的文件
| 文件 | 改动 |
|------|------|
| `src/model.rs` | 新增 `ParsedBody` 枚举和相关方法 |
| `src/handler.rs` | 新增 `extract_content_type()``parse_body()`、修改 `mock_handler()` |
| `src/router.rs` | 修改 `match_rule()``is_match()` 参数,新增 `match_body()` |
| `Cargo.toml` | 可能需要 `urlencoding` 依赖 |
---
## YAML 配置示例
```yaml
# 严格匹配:要求 Content-Type + body
- id: login-strict
request:
method: POST
path: /api/login
headers:
Content-Type: application/json
body:
username: admin
password: "123456"
# 宽松匹配:只匹配 method + path + body不检查 Content-Type
- id: login-loose
request:
method: POST
path: /api/login
body:
username: admin
# 最宽松:只匹配 method + path
- id: any-body
request:
method: POST
path: /api/echo
```
---
## 验证
1. `cargo test`
2. 手动测试:
- YAML 配置 `Content-Type: application/json`,请求发送 JSON → 匹配
- YAML 配置 `Content-Type: application/xml`,请求发送 JSON → Header 不匹配
- YAML 无 Content-Type请求发送 XML → Body 字符串比较
- YAML 无 body 配置 → 跳过 body 检查

View File

@@ -5,9 +5,10 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; // 必须引入 RwLock use std::sync::{Arc, RwLock};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::models::Payload;
use crate::router::MockRouter; use crate::router::MockRouter;
/// 共享的应用状态router 现在由 RwLock 保护以支持热重载 /// 共享的应用状态router 现在由 RwLock 保护以支持热重载
@@ -15,6 +16,131 @@ pub struct AppState {
pub router: RwLock<MockRouter>, pub router: RwLock<MockRouter>,
} }
/// 提取请求的 Content-Type去掉参数部分如 boundary
fn extract_content_type(headers: &HeaderMap) -> Option<String> {
headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_lowercase())
}
/// 根据 Content-Type 解析 Body始终以请求的 Content-Type 为准)
fn parse_body(content_type: Option<&str>, bytes: &[u8]) -> Payload {
if bytes.is_empty() {
return Payload::None;
}
match content_type {
Some(ct) if ct.contains("application/json") => {
serde_json::from_slice(bytes)
.map(Payload::Json)
.unwrap_or_else(|_| {
// JSON 解析失败,降级为文本
Payload::Text(String::from_utf8_lossy(bytes).to_string())
})
}
Some(ct) if ct.contains("xml") => {
Payload::Xml(String::from_utf8_lossy(bytes).to_string())
}
Some(ct) if ct.contains("form-urlencoded") => {
Payload::Form(parse_urlencoded(bytes))
}
Some(ct) if ct.contains("multipart/form-data") => {
Payload::Multipart(extract_multipart_data(bytes))
}
_ => {
Payload::Text(String::from_utf8_lossy(bytes).to_string())
}
}
}
/// 解析 urlencoded 格式
fn parse_urlencoded(bytes: &[u8]) -> HashMap<String, String> {
let body = String::from_utf8_lossy(bytes);
let mut map = HashMap::new();
for pair in body.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let decoded_key = urlencoding_decode(key);
let decoded_value = urlencoding_decode(value);
map.insert(decoded_key, decoded_value);
}
}
map
}
/// URL 解码(简单实现)
fn urlencoding_decode(s: &str) -> String {
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '+' {
result.push(' ');
} else if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else {
result.push(c);
}
}
result
}
/// 从 multipart body 中提取键值对
fn extract_multipart_data(bytes: &[u8]) -> HashMap<String, String> {
let body = String::from_utf8_lossy(bytes);
let mut map = HashMap::new();
// 分割 boundary
let lines: Vec<&str> = body.lines().collect();
let mut current_name: Option<String> = None;
let mut current_value = String::new();
let mut in_value = false;
for line in &lines {
// 检测 Content-Disposition 行,提取 name
if line.contains("Content-Disposition") && line.contains("name=") {
// 保存上一个字段的值
if let Some(name) = current_name.take() {
map.insert(name, current_value.trim().to_string());
current_value.clear();
}
// 提取 name 属性
if let Some(start) = line.find("name=\"") {
let start = start + 6;
if let Some(end) = line[start..].find('"') {
current_name = Some(line[start..start + end].to_string());
in_value = false;
}
}
} else if line.starts_with("Content-Type") {
// 跳过 Content-Type 行
continue;
} else if line.is_empty() {
// 空行后面是值
in_value = true;
} else if in_value {
// 收集值内容
if !current_value.is_empty() {
current_value.push('\n');
}
current_value.push_str(line);
}
}
// 保存最后一个字段
if let Some(name) = current_name {
map.insert(name, current_value.trim().to_string());
}
map
}
/// 全局统一请求处理函数 /// 全局统一请求处理函数
pub async fn mock_handler( pub async fn mock_handler(
State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数 State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数
@@ -27,7 +153,10 @@ pub async fn mock_handler(
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let method_str = method.as_str().to_string(); let method_str = method.as_str().to_string();
// 2. 取请求 body用于 body 字段匹配) // 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 { let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(_) => { Err(_) => {
@@ -37,9 +166,11 @@ pub async fn mock_handler(
.unwrap(); .unwrap();
} }
}; };
let incoming_json: Option<serde_json::Value> = serde_json::from_slice(&body_bytes).ok();
// 3. 将 Axum HeaderMap 转换为简单的 HashMap // 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(); let mut req_headers = HashMap::new();
for (name, value) in headers.iter() { for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() { if let Ok(v) = value.to_str() {
@@ -47,22 +178,22 @@ pub async fn mock_handler(
} }
} }
// 4. 执行匹配逻辑:先获取读锁 (Read Lock) // 6. 执行匹配逻辑:先获取读锁 (Read Lock)
let maybe_rule = { let maybe_rule = {
let router = state.router.read().expect("Failed to acquire read lock"); let router = state.router.read().expect("Failed to acquire read lock");
router.match_rule(&method_str, &path, &params, &req_headers, &incoming_json).cloned() router.match_rule(&method_str, &path, &params, &req_headers, &parsed_body).cloned()
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁 // 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
}; };
if let Some(rule) = maybe_rule { if let Some(rule) = maybe_rule {
// 5. 处理模拟延迟 // 7. 处理模拟延迟
if let Some(ref settings) = rule.settings { if let Some(ref settings) = rule.settings {
if let Some(delay) = settings.delay_ms { if let Some(delay) = settings.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await; tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
} }
} }
// 6. 构建响应 // 8. 构建响应
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK); let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
let mut response_builder = Response::builder().status(status); let mut response_builder = Response::builder().status(status);
@@ -72,7 +203,7 @@ pub async fn mock_handler(
} }
} }
// 7. Smart Body 逻辑 // 9. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() { if let Some(file_path) = rule.response.get_file_path() {
match tokio::fs::File::open(file_path).await { match tokio::fs::File::open(file_path).await {
Ok(file) => { Ok(file) => {

View File

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

View File

@@ -1,25 +1,29 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::{Path}; use std::path::Path;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::model::{MockRule, MockSource}; // 假设 model 中定义了这两个类型 use crate::models::MockRule;
pub struct MockLoader; pub struct MockLoader;
impl MockLoader { impl MockLoader {
/// 递归扫描指定目录并构建索引表 /// 递归扫描指定目录并构建索引表
/// 目录结构mocks/{group}/{rule}.json
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> { pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new(); let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
// 1. 使用 walkdir 递归遍历目录,不限层级 if !dir.exists() {
return index;
}
// 1. 使用 walkdir 递归遍历目录,寻找 JSON 文件
for entry in WalkDir::new(dir) for entry in WalkDir::new(dir)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml")) .filter(|e| e.path().extension().map_or(false, |ext| ext == "json"))
{ {
if let Some(rules) = Self::parse_yaml_file(entry.path()) { if let Some(rule) = Self::parse_json_file(entry.path()) {
for rule in rules {
// 2. 提取路径首段作为索引 Key // 2. 提取路径首段作为索引 Key
let key = Self::extract_first_segment(&rule.request.path); let key = Self::extract_first_segment(&rule.request.path);
@@ -27,21 +31,25 @@ impl MockLoader {
index.entry(key).or_insert_with(Vec::new).push(rule); index.entry(key).or_insert_with(Vec::new).push(rule);
} }
} }
}
println!("Successfully loaded {} segments from {:?}", index.len(), dir); println!("Successfully loaded {} segments from {:?}", index.len(), dir);
index index
} }
/// 解析单个 YAML 文件,支持单接口和多接口模式 /// 解析单个 JSON 文件
fn parse_yaml_file(path: &Path) -> Option<Vec<MockRule>> { fn parse_json_file(path: &Path) -> Option<MockRule> {
let content = fs::read_to_string(path).ok()?; match fs::read_to_string(path) {
Ok(content) => {
// 利用 serde_yaml 的反序列化能力处理 MockSource 枚举 match serde_json::from_str::<MockRule>(&content) {
match serde_yaml::from_str::<MockSource>(&content) { Ok(rule) => Some(rule),
Ok(source) => Some(source.flatten()), // 统一打平为 Vec<MockRule>
Err(e) => { Err(e) => {
eprintln!("Failed to parse YAML at {:?}: {}", path, e); eprintln!("Failed to parse JSON at {:?}: {}", path, e);
None
}
}
}
Err(e) => {
eprintln!("Failed to read file {:?}: {}", path, e);
None None
} }
} }

View File

@@ -1,80 +1,144 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path; use std::path::PathBuf;
use std::sync::{Arc, RwLock}; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use axum::{routing::any, Router};
use notify_debouncer_mini::{new_debouncer, notify::*}; 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::loader::MockLoader;
use mock_server::router::MockRouter; use mock_server::router::MockRouter;
use mock_server::handler::{mock_handler, AppState}; use mock_server::handler::{mock_handler, AppState};
use mock_server::logging;
/// 打印启动 Banner fn print_usage() {
fn print_banner() { println!("Mock Server - A mock API server with hot-reload and MCP support");
let version = env!("CARGO_PKG_VERSION"); println!();
// 蓝色 ANSI 转义码 println!("Usage: mock_server [OPTIONS]");
println!("\x1b[34m"); println!();
println!(" ███╗ ███╗ ██████╗██████╗ ██████╗ "); println!("Options:");
println!(" ████╗ ████║██╔════╝██╔══██╗██╔═══██╗"); println!(" --mocks <DIR> Mocks directory path (default: ./mocks)");
println!(" ██╔████╔██║██║ ██████╔╝██║ ██║"); println!(" --port <PORT> HTTP server port (default: 8080)");
println!(" ██║╚██╔╝██║██║ ██╔══██╗██║ ██║"); println!(" --help Show this help message");
println!(" ██║ ╚═╝ ██║╚██████╗██║ ██║╚██████╔╝"); println!();
println!(" ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ "); println!("MCP Endpoint: POST http://127.0.0.1:<PORT>/mcp");
println!("\x1b[0m"); // 重置颜色
println!(" Mock Server v{}\n", version);
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
print_banner(); // Initialize logging system
tracing_subscriber::fmt::init(); let log_dir = PathBuf::from("./logs");
logging::init(log_dir);
let mocks_dir = Path::new("./mocks"); // Parse command line arguments
if !mocks_dir.exists() { let args: Vec<String> = std::env::args().collect();
std::fs::create_dir_all(mocks_dir).unwrap(); let mut mocks_dir = PathBuf::from("./mocks");
let mut port: u16 = 8080;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--help" | "-h" => {
print_usage();
return;
}
"--mocks" => {
if i + 1 < args.len() {
mocks_dir = PathBuf::from(&args[i + 1]);
i += 1;
} else {
eprintln!("--mocks requires a directory path");
return;
}
}
"--port" => {
if i + 1 < args.len() {
match args[i + 1].parse::<u16>() {
Ok(p) => port = p,
Err(_) => {
eprintln!("Invalid port number: {}", args[i + 1]);
return;
}
}
i += 1;
} else {
eprintln!("--port requires a port number");
return;
}
}
_ => {
eprintln!("Unknown option: {}", args[i]);
print_usage();
return;
}
}
i += 1;
} }
// 1. 初始加载 if !mocks_dir.exists() {
println!("Scanning mocks directory..."); std::fs::create_dir_all(&mocks_dir).unwrap();
let index = MockLoader::load_all_from_dir(mocks_dir); }
// 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 { let shared_state = Arc::new(AppState {
router: RwLock::new(MockRouter::new(index)), router: std::sync::RwLock::new(MockRouter::new(index)),
}); });
// 2. 设置热加载监听器 // Setup hot-reload watcher
let state_for_watcher = shared_state.clone(); let state_for_watcher = shared_state.clone();
let watch_path = mocks_dir.to_path_buf(); let watch_path = mocks_dir.clone();
let manager_for_watcher = manager.clone();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
// 200ms 防抖,防止编辑器保存文件时产生多次干扰
let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap(); let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap();
debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap(); debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap();
// 启动异步任务监听文件变动
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(res) = rx.recv() { while let Ok(res) = rx.recv() {
match res { match res {
Ok(_) => { Ok(_) => {
println!("🔄 Detecting changes in mocks/, reloading..."); info!("Detected changes in mocks/, reloading...");
let new_index = MockLoader::load_all_from_dir(&watch_path); let new_index = MockLoader::load_all_from_dir(&watch_path);
// 获取写锁 (Write Lock) 更新索引
let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock"); let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock");
*writer = MockRouter::new(new_index); *writer = MockRouter::new(new_index);
println!("✅ Mocks reloaded successfully."); // Also reload in manager
manager_for_watcher.reload();
info!("Mocks reloaded successfully.");
} }
Err(e) => eprintln!("Watcher error: {:?}", e), Err(e) => error!("Watcher error: {:?}", e),
} }
} }
}); });
// 3. 配置 Axum 路由 // Create MCP HTTP service (stateless)
let mcp_service = mock_server::mcp::create_mcp_http_service(manager.clone());
let app = Router::new() let app = Router::new()
.fallback(any(mock_handler)) // 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); .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); info!("HTTP server running at http://{}", addr);
info!("MCP endpoint available at http://{}/mcp", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap(); if let Err(e) = axum::serve(listener, app).await {
error!("HTTP server error: {}", e);
}
} }

208
src/manager.rs Normal file
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
src/mcp/mod.rs Normal file
View File

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

199
src/mcp/server.rs Normal file
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(),
},
)
}

View File

@@ -1,22 +1,47 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
/// 顶层包装:支持单对象或数组,自动打平 /// 解析后的请求 Body
#[derive(Debug, Deserialize)] #[derive(Debug, Clone)]
#[serde(untagged)] pub enum Payload {
pub enum MockSource { /// JSON 格式
/// 对应“一个接口一个文件”模式 Json(serde_json::Value),
Single(MockRule), /// XML 格式
/// 对应“一个文件多个接口”模式 Xml(String),
Multiple(Vec<MockRule>), /// URL 编码表单
Form(HashMap<String, String>),
/// Multipart 表单(键值对)
Multipart(HashMap<String, String>),
/// 纯文本
Text(String),
/// 无 Body
None,
} }
impl MockSource { impl Payload {
/// 将不同的解析模式统一转化为列表,供 Loader 构建索引 /// 转换为字符串(用于兜底比较)
pub fn flatten(self) -> Vec<MockRule> { pub fn to_compare_string(&self) -> String {
match self { match self {
Self::Single(rule) => vec![rule], Payload::Json(v) => v.to_string(),
Self::Multiple(rules) => rules, 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",
} }
} }
} }
@@ -24,7 +49,7 @@ impl MockSource {
/// 核心 Mock 规则定义 /// 核心 Mock 规则定义
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule { pub struct MockRule {
pub id: String, pub name: String,
pub request: RequestMatcher, pub request: RequestMatcher,
pub response: MockResponse, pub response: MockResponse,
pub settings: Option<MockSettings>, pub settings: Option<MockSettings>,
@@ -39,7 +64,7 @@ pub struct RequestMatcher {
pub query_params: Option<HashMap<String, String>>, pub query_params: Option<HashMap<String, String>>,
/// 选填:只有请求包含这些 Header 时才匹配 /// 选填:只有请求包含这些 Header 时才匹配
pub headers: Option<HashMap<String, String>>, pub headers: Option<HashMap<String, String>>,
// 修改点:从 String 改为 Option<serde_json::Value> /// 选填:请求体匹配条件
pub body: Option<serde_json::Value>, pub body: Option<serde_json::Value>,
} }
@@ -52,6 +77,13 @@ pub struct MockResponse {
pub body: String, pub body: String,
} }
/// 模拟器行为设置
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockSettings {
/// 模拟网络延迟(毫秒)
pub delay_ms: Option<u64>,
}
impl MockResponse { impl MockResponse {
/// 辅助方法:判断是否为文件协议 /// 辅助方法:判断是否为文件协议
pub fn is_file_protocol(&self) -> bool { pub fn is_file_protocol(&self) -> bool {
@@ -67,10 +99,3 @@ impl MockResponse {
} }
} }
} }
/// 模拟器行为设置
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockSettings {
/// 模拟网络延迟(毫秒)
pub delay_ms: Option<u64>,
}

View File

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

BIN
storage/v1/hello.pdf Normal file

Binary file not shown.

20
storage/v1/user_data.json Normal file
View File

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

View File

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

View File

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

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": "newuser@example.com"
}));
let matched = router.match_rule("POST", "/v1/auth/register", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "JSON 注册应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_register");
assert_eq!(rule.response.status, 201);
}
// ========== 模块三Form Payload 测试 ==========
#[test]
fn test_v1_form_login() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/x-www-form-urlencoded".to_string());
let mut form_data = HashMap::new();
form_data.insert("username".to_string(), "formuser".to_string());
form_data.insert("password".to_string(), "formpass".to_string());
let payload = Payload::Form(form_data);
let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Form 登录应匹配成功");
assert_eq!(matched.unwrap().name, "_user_login_form");
}
// ========== 模块四Text Payload 测试 ==========
#[test]
fn test_v1_text_echo() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "text/plain".to_string());
let payload = Payload::Text("Hello V1 Mock Server".to_string());
let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Text 回显应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_echo");
assert!(rule.response.body.contains("Echo from V1"));
}
// ========== 模块五XML Payload 测试 ==========
#[test]
fn test_v1_xml_export() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/xml".to_string());
let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?><request><userId>10001</userId><format>xml</format></request>"#;
let payload = Payload::Xml(xml_body.to_string());
let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "XML 导出应匹配成功");
assert_eq!(matched.unwrap().name, "data_export");
}
// ========== 模块六Multipart Payload 测试 ==========
#[test]
fn test_v1_multipart_upload() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "multipart/form-data".to_string());
let mut multipart_data = HashMap::new();
multipart_data.insert("avatar1".to_string(), "file_content".to_string());
multipart_data.insert("description1".to_string(), "user avatar".to_string());
let payload = Payload::Multipart(multipart_data);
let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Multipart 上传应匹配成功");
assert_eq!(matched.unwrap().name, "user_upload_avatar_001");
}
// ========== 模块七None Payload 测试 (GET 无 Body) ==========
#[test]
fn test_v1_health_check() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/health", &HashMap::new(), &HashMap::new(), &payload);
assert!(matched.is_some(), "健康检查应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "health_check");
assert_eq!(rule.response.status, 200);
}
#[test]
fn test_v1_get_profile() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "获取用户信息应匹配成功");
assert_eq!(matched.unwrap().name, "user_profile");
}
// ========== 模块八Query Params 测试 ==========
#[test]
fn test_v1_query_params() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
let mut query = HashMap::new();
query.insert("format".to_string(), "json".to_string());
let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/user/download", &query, &HashMap::new(), &payload);
assert!(matched.is_some(), "带 query params 的下载应匹配成功");
let rule = matched.unwrap();
assert_eq!(rule.name, "user_download");
assert!(rule.response.body.starts_with("file://"));
}
// ========== 模块九Header 匹配测试 ==========
#[test]
fn test_v1_header_required() {
let index = load_v1_mocks();
let router = MockRouter::new(index);
// 无 Authorization header 不应匹配
let payload = Payload::None;
let no_match = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &HashMap::new(), &payload);
assert!(no_match.is_none(), "无 Authorization 不应匹配");
// 有正确的 Authorization header 应匹配
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "有 Authorization 应匹配");
}