Compare commits
1 Commits
feature-ms
...
feature-ms
| Author | SHA1 | Date | |
|---|---|---|---|
| 78fb8951ab |
103
.claude/agents/wechat-article-summarizer.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
20
.claude/commands/git-branch.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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)
|
||||||
75
.claude/rules/git-branch-specification.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 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` |
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
paths:
|
paths:
|
||||||
- "mocks/**/*.json"
|
- "mocks/**/*.{yml,yaml}"
|
||||||
---
|
---
|
||||||
|
|
||||||
## JSON 配置规范
|
## YAML 配置规范
|
||||||
|
|
||||||
AI 在生成 Mock 规则时必须遵循以下格式。
|
AI 在生成 Mock 规则时必须遵循以下格式。
|
||||||
|
|
||||||
### 字段说明
|
### 字段说明
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|----------------------|--------|:--:|----------------------------------|
|
|------|------|:----:|------|
|
||||||
| name | string | 是 | 规则唯一标识 |
|
| id | string | 是 | 规则唯一标识 |
|
||||||
| request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) |
|
| request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) |
|
||||||
| request.path | string | 是 | 请求路径,精确匹配 |
|
| request.path | string | 是 | 请求路径,精确匹配 |
|
||||||
| request.query_params | object | 否 | 查询参数匹配 |
|
| request.query_params | object | 否 | 查询参数匹配 |
|
||||||
@@ -30,91 +30,58 @@ AI 在生成 Mock 规则时必须遵循以下格式。
|
|||||||
|
|
||||||
### 配置示例
|
### 配置示例
|
||||||
|
|
||||||
**基础示例:**
|
**单接口模式:**
|
||||||
|
|
||||||
```json
|
```yaml
|
||||||
{
|
id: "login"
|
||||||
"name": "login",
|
request:
|
||||||
"request": {
|
method: "POST"
|
||||||
"method": "POST",
|
path: "/api/login"
|
||||||
"path": "/v1/auth/login",
|
body: { "username": "test" }
|
||||||
"body": {
|
response:
|
||||||
"username": "user001",
|
status: 200
|
||||||
"password": "password123"
|
body: '{"token": "xxx"}'
|
||||||
}
|
settings:
|
||||||
},
|
delay_ms: 100
|
||||||
"response": {
|
```
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
**多接口模式(数组):**
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
```yaml
|
||||||
"body": "{\"token\": \"xxx\"}"
|
- id: "get-users"
|
||||||
},
|
request: { method: "GET", path: "/api/users" }
|
||||||
"settings": {
|
response: { status: 200, body: '{"users": []}' }
|
||||||
"delay_ms": 100
|
|
||||||
}
|
- id: "create-user"
|
||||||
}
|
request: { method: "POST", path: "/api/users" }
|
||||||
|
response: { status: 201, body: '{"id": 1}' }
|
||||||
```
|
```
|
||||||
|
|
||||||
**带查询参数和请求头:**
|
**带查询参数和请求头:**
|
||||||
|
|
||||||
```json
|
```yaml
|
||||||
{
|
id: "search-users"
|
||||||
"name": "search_users",
|
request:
|
||||||
"request": {
|
method: "GET"
|
||||||
"method": "GET",
|
path: "/api/users"
|
||||||
"path": "/v1/users",
|
query_params: { "role": "admin" }
|
||||||
"query_params": {
|
headers: { "Authorization": "Bearer token" }
|
||||||
"role": "admin"
|
response:
|
||||||
},
|
status: 200
|
||||||
"headers": {
|
headers: { "X-Total-Count": "100" }
|
||||||
"Authorization": "Bearer token"
|
body: '{"users": []}'
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"X-Total-Count": "100"
|
|
||||||
},
|
|
||||||
"body": "{\"users\": []}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**文件响应:**
|
**文件响应:**
|
||||||
|
|
||||||
```json
|
```yaml
|
||||||
{
|
id: "download-pdf"
|
||||||
"name": "download_pdf",
|
request:
|
||||||
"request": {
|
method: "GET"
|
||||||
"method": "GET",
|
path: "/api/download"
|
||||||
"path": "/v1/download"
|
response:
|
||||||
},
|
status: 200
|
||||||
"response": {
|
body: "file://./data/large-file.pdf"
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/pdf"
|
|
||||||
},
|
|
||||||
"body": "file://./storage/data/report.pdf"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**字符串格式 Body(兼容格式):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "string_body_example",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/api/test",
|
|
||||||
"body": "{\"username\":\"user001\",\"password\":\"password123\"}"
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\": true}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> `file://` 支持两种路径:
|
> `file://` 支持两种路径:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(cargo search:*)",
|
"Bash(cargo search:*)",
|
||||||
"Bash(cd:*)"
|
"WebSearch"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
922
Cargo.lock
generated
22
Cargo.toml
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mock_server"
|
name = "mock_server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# 核心 Web 框架
|
# 核心 Web 框架
|
||||||
@@ -12,25 +12,27 @@ 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 = "0.1.44"
|
tracing-subscriber = "0.3.22"
|
||||||
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"
|
||||||
|
# 路径处理
|
||||||
# MCP Server 支持
|
#pathdiff = "0.2.3"
|
||||||
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
|
|
||||||
schemars = "1.0"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.24.0"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
winres = "0.1"
|
||||||
29
build.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
# rmcp 0.11 MCP Server 实现指南
|
|
||||||
|
|
||||||
本文档详细介绍了如何使用 rmcp 0.11 实现 MCP Server,包括核心概念、关键代码模式和常见陷阱。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
- [核心概念](#核心概念)
|
|
||||||
- [Tool 注册三要素](#tool-注册三要素)
|
|
||||||
- [完整代码示例](#完整代码示例)
|
|
||||||
- [常见错误和解决方案](#常见错误和解决方案)
|
|
||||||
- [HTTP 传输配置](#http-传输配置)
|
|
||||||
- [客户端配置](#客户端配置)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心概念
|
|
||||||
|
|
||||||
### rmcp 简介
|
|
||||||
|
|
||||||
rmcp 是 Rust 官方的 MCP SDK,提供了实现 MCP (Model Context Protocol) 服务器和客户端的完整工具链。
|
|
||||||
|
|
||||||
### MCP 协议
|
|
||||||
|
|
||||||
MCP (Model Context Protocol) 是一个开放协议,用于连接 AI 助手与外部系统。它定义了一套标准化的接口,允许 AI 模型:
|
|
||||||
|
|
||||||
- **Tools**: 调用外部工具/函数
|
|
||||||
- **Resources**: 访问外部资源
|
|
||||||
- **Prompts**: 使用预定义的提示模板
|
|
||||||
|
|
||||||
### 依赖配置
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# Cargo.toml
|
|
||||||
[dependencies]
|
|
||||||
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
|
|
||||||
schemars = "1.0"
|
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tool 注册三要素
|
|
||||||
|
|
||||||
要使 MCP tools 在 rmcp 0.11 中正常工作,**必须**同时具备以下三个要素:
|
|
||||||
|
|
||||||
### 1. `#[tool_router]` 宏
|
|
||||||
|
|
||||||
放在包含 tool 方法的 impl 块上:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tool_router]
|
|
||||||
impl MockMcpServer {
|
|
||||||
// tool 方法...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `tool_router: ToolRouter<Self>` 字段
|
|
||||||
|
|
||||||
在结构体中必须包含此字段:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct MockMcpServer {
|
|
||||||
manager: Arc<MockManager>,
|
|
||||||
tool_router: ToolRouter<Self>, // 必需字段
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. `#[tool_handler]` 宏
|
|
||||||
|
|
||||||
放在 `ServerHandler` trait 实现块上:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tool_handler]
|
|
||||||
impl ServerHandler for MockMcpServer {
|
|
||||||
fn get_info(&self) -> ServerInfo {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **重要**: 缺少 `#[tool_handler]` 会导致 `tools/list` 返回空数组 `{"tools": []}`!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整代码示例
|
|
||||||
|
|
||||||
### 结构体定义
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use rmcp::{
|
|
||||||
handler::server::tool::ToolRouter,
|
|
||||||
handler::server::wrapper::Parameters,
|
|
||||||
model::*,
|
|
||||||
tool, tool_handler, tool_router,
|
|
||||||
ErrorData as McpError, ServerHandler,
|
|
||||||
};
|
|
||||||
use schemars::JsonSchema;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// MCP Server for Mock Server management
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct MockMcpServer {
|
|
||||||
manager: Arc<MockManager>,
|
|
||||||
tool_router: ToolRouter<Self>, // 必需字段
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 请求参数结构体
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// 使用 schemars 和 serde 自动生成 JSON Schema
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
|
||||||
pub struct GetRuleRequest {
|
|
||||||
#[schemars(description = "Group name (directory name)")]
|
|
||||||
pub group: String,
|
|
||||||
|
|
||||||
#[schemars(description = "Rule name")]
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
|
||||||
pub struct ListRulesRequest {
|
|
||||||
#[schemars(description = "Optional group name to filter by")]
|
|
||||||
pub group: Option<String>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool 方法实现
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tool_router]
|
|
||||||
impl MockMcpServer {
|
|
||||||
pub fn new(manager: Arc<MockManager>) -> Self {
|
|
||||||
Self {
|
|
||||||
manager,
|
|
||||||
tool_router: Self::tool_router(), // 初始化 tool_router
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 无参数 tool
|
|
||||||
#[tool(description = "List all groups (directories)")]
|
|
||||||
async fn list_groups(&self) -> Result<CallToolResult, McpError> {
|
|
||||||
let groups = self.manager.list_groups();
|
|
||||||
let result = serde_json::to_string_pretty(&groups)
|
|
||||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
|
||||||
Ok(CallToolResult::success(vec![Content::text(result)]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 带参数 tool - 使用 Parameters<T> 包装器
|
|
||||||
#[tool(description = "Get a specific mock rule by group and name")]
|
|
||||||
async fn get_mock_rule(
|
|
||||||
&self,
|
|
||||||
params: Parameters<GetRuleRequest>,
|
|
||||||
) -> Result<CallToolResult, McpError> {
|
|
||||||
match self.manager.get(¶ms.0.group, ¶ms.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/)
|
|
||||||
BIN
icons/128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/64x64.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
5
icons/android/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?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>
|
||||||
BIN
icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
4
icons/android/values/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
BIN
icons/icon.icns
Normal file
BIN
icons/icon.ico
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
icons/icon.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 930 B |
BIN
icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
28
mocks/v1/auth/login.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
id: "auth_login_001"
|
||||||
|
request:
|
||||||
|
method: "POST"
|
||||||
|
path: "/api/v1/auth/login"
|
||||||
|
# 必须包含此 Header 才会匹配
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/json"
|
||||||
|
Authorization: "111"
|
||||||
|
host: "127.0.0.1:8080"
|
||||||
|
body: >
|
||||||
|
{
|
||||||
|
"username":"user",
|
||||||
|
"password":"123"
|
||||||
|
}
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/json"
|
||||||
|
X-Mock-Engine: "Rust-Gemini-v1.2"
|
||||||
|
# 直接内联 JSON 字符串
|
||||||
|
body: >
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6" },
|
||||||
|
"msg": "success"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
delay_ms: 2000 # 模拟真实网络延迟
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_login_001",
|
|
||||||
"request": {
|
|
||||||
"path": "/v1/auth/login",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
|
|
||||||
"host": "127.0.0.1:8080"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"username": "user001",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10001,\"username\":\"user001\",\"role\":\"administrator\"}}"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"delay_ms": 2000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_login_002",
|
|
||||||
"request": {
|
|
||||||
"path": "/v1/auth/login",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
|
|
||||||
"host": "127.0.0.1:8080"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"username": "user002",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10002,\"username\":\"user002\",\"role\":\"administrator\"}}"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"delay_ms": 2000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_login_003",
|
|
||||||
"request": {
|
|
||||||
"path": "/v1/auth/login",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
|
|
||||||
"host": "127.0.0.1:8080"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"username": "user003",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10003,\"username\":\"user003\",\"role\":\"administrator\"}}"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"delay_ms": 2000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_login_004",
|
|
||||||
"request": {
|
|
||||||
"path": "/v1/auth/login",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
|
|
||||||
"host": "127.0.0.1:8080"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"username": "user004",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10004,\"username\":\"user004\",\"role\":\"administrator\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_login_005",
|
|
||||||
"request": {
|
|
||||||
"path": "/v1/auth/login",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
|
|
||||||
"host": "127.0.0.1:8080"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"username": "user005",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10005,\"username\":\"user005\",\"role\":\"administrator\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
mocks/v1/auth/login_out.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
- id: "auth_login_out_001"
|
||||||
|
request:
|
||||||
|
method: "POST"
|
||||||
|
path: "/api/v1/auth/login_out"
|
||||||
|
# 必须包含此 Header 才会匹配
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/json"
|
||||||
|
Authorization: "111"
|
||||||
|
host: "127.0.0.1:8080"
|
||||||
|
body:
|
||||||
|
type: true
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/json"
|
||||||
|
X-Mock-Engine: "Rust-Gemini-v1.2"
|
||||||
|
# 直接内联 JSON 字符串
|
||||||
|
body: >
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": "退出成功",
|
||||||
|
"msg": "success"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
delay_ms: 200 # 模拟真实网络延迟
|
||||||
|
- id: "auth_login_out_002"
|
||||||
|
request:
|
||||||
|
method: "POST"
|
||||||
|
path: "/api/v1/auth/login_out"
|
||||||
|
# 必须包含此 Header 才会匹配
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/json"
|
||||||
|
Authorization: "111"
|
||||||
|
host: "127.0.0.1:8080"
|
||||||
|
body:
|
||||||
|
type: false
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/json"
|
||||||
|
X-Mock-Engine: "Rust-Gemini-v1.2"
|
||||||
|
# 直接内联 JSON 字符串
|
||||||
|
body: >
|
||||||
|
{
|
||||||
|
"code": 1,
|
||||||
|
"data": "退出失败",
|
||||||
|
"msg": "success"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
delay_ms: 200 # 模拟真实网络延迟
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_register",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/auth/register",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"username": "newuser",
|
|
||||||
"password": "newpass123",
|
|
||||||
"email": "newuser@example.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 201,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"注册成功\",\"data\":{\"userId\":10002,\"username\":\"newuser\",\"email\":\"newuser@example.com\",\"createdAt\":\"2026-03-27T10:00:00Z\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "data_export",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/data/export",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/xml"
|
|
||||||
},
|
|
||||||
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><request><userId>10001</userId><format>xml</format></request>"
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/xml"
|
|
||||||
},
|
|
||||||
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><response><code>0</code><message>导出成功</message><data><user><id>10001</id><name>管理员</name><email>admin@example.com</email></user></data></response>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "health_check",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/v1/health"
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"status\":\"healthy\",\"version\":\"2.0.0\",\"timestamp\":\"2026-03-27T10:00:00Z\"}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
mocks/v1/products/export.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
id: "prod_export_pdf"
|
||||||
|
request:
|
||||||
|
method: "GET"
|
||||||
|
path: "/api/v1/products/report"
|
||||||
|
body: '{"username":"user","password":"123"}'
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
headers:
|
||||||
|
Content-Type: "application/pdf"
|
||||||
|
Content-Disposition: "attachment; filename=report.pdf"
|
||||||
|
# 智能协议:引擎会自动识别前缀并异步读取磁盘文件
|
||||||
|
body: "file://./storage/reports/annual_2024.pdf"
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "prod_export_pdf",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/v1/products/report",
|
|
||||||
"body": "{\"username\":\"user001\",\"password\":\"password123\"}"
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/pdf",
|
|
||||||
"Content-Disposition": "attachment; filename=report.pdf"
|
|
||||||
},
|
|
||||||
"body": "file://./storage/v1/hello.pdf"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
mocks/v1/system/health.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 使用 YAML 数组语法定义多个规则
|
||||||
|
- id: "sys_ping"
|
||||||
|
request:
|
||||||
|
method: "GET"
|
||||||
|
path: "/api/v1/ping"
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
body: "pong"
|
||||||
|
|
||||||
|
- id: "sys_version"
|
||||||
|
request:
|
||||||
|
method: "GET"
|
||||||
|
path: "/api/v1/version"
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
body: '{"version": "1.2.0-smart"}'
|
||||||
23
mocks/v1/upload.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_upload_avatar_001",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/user/avatar",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "multipart/form-data"
|
|
||||||
},
|
|
||||||
"body": ["avatar1", "description1"]
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10001.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_upload_avatar_002",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/user/avatar",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "multipart/form-data"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"avatar2": "avatar",
|
|
||||||
"description2": "description"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10002.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_upload_avatar_003",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/user/avatar",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "multipart/form-data"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"avatar3": "avatar",
|
|
||||||
"description3": "description"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10003.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_download",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/v1/user/download",
|
|
||||||
"query_params": {
|
|
||||||
"format": "json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Disposition": "attachment; filename=user_data.json"
|
|
||||||
},
|
|
||||||
"body": "file://./storage/v1/user_data.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_echo",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/user/echo",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "text/plain"
|
|
||||||
},
|
|
||||||
"body": "Hello V1 Mock Server"
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "text/plain"
|
|
||||||
},
|
|
||||||
"body": "Echo from V1: Hello V1 Mock Server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "_user_login_form",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v1/user/login/form",
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"username": "formuser",
|
|
||||||
"password": "formpass"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"表单登录成功\",\"data\":{\"token\":\"v2_form_token_xyz\",\"userId\":20001,\"username\":\"formuser\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user_profile",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/v1/user/profile",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer v1_test_token"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"body": "{\"code\":0,\"message\":\"获取成功\",\"data\":{\"userId\":10001,\"username\":\"admin\",\"email\":\"admin@example.com\",\"nickname\":\"管理员\",\"avatar\":\"https://example.com/avatars/admin.jpg\",\"createdAt\":\"2025-01-01T00:00:00Z\"}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
mocks/v1/user/search.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
id: "user_search_admin"
|
||||||
|
request:
|
||||||
|
method: "GET"
|
||||||
|
path: "/api/v1/users"
|
||||||
|
# 请求中必须包含 role=admin 且 status=active
|
||||||
|
query_params:
|
||||||
|
role: "admin"
|
||||||
|
status: "active"
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
body: '{"users": [{"id": 1, "name": "SuperAdmin"}]}'
|
||||||
239
plan.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 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
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
# 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, ¶ms, &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 检查
|
||||||
149
src/handler.rs
@@ -5,10 +5,9 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock}; // 必须引入 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 保护以支持热重载
|
||||||
@@ -16,131 +15,6 @@ 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 必须是第一个或靠前的参数
|
||||||
@@ -153,10 +27,7 @@ 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. 提取请求的 Content-Type
|
// 2. 读取请求 body(用于 body 字段匹配)
|
||||||
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(_) => {
|
||||||
@@ -166,11 +37,9 @@ pub async fn mock_handler(
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let incoming_json: Option<serde_json::Value> = serde_json::from_slice(&body_bytes).ok();
|
||||||
|
|
||||||
// 4. 根据【请求的 Content-Type】解析 body
|
// 3. 将 Axum HeaderMap 转换为简单的 HashMap
|
||||||
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() {
|
||||||
@@ -178,22 +47,22 @@ pub async fn mock_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 执行匹配逻辑:先获取读锁 (Read Lock)
|
// 4. 执行匹配逻辑:先获取读锁 (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, ¶ms, &req_headers, &parsed_body).cloned()
|
router.match_rule(&method_str, &path, ¶ms, &req_headers, &incoming_json).cloned()
|
||||||
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
|
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(rule) = maybe_rule {
|
if let Some(rule) = maybe_rule {
|
||||||
// 7. 处理模拟延迟
|
// 5. 处理模拟延迟
|
||||||
if let Some(ref settings) = rule.settings {
|
if let Some(ref settings) = rule.settings {
|
||||||
if let Some(delay) = settings.delay_ms {
|
if let Some(delay) = settings.delay_ms {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 构建响应
|
// 6. 构建响应
|
||||||
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
|
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
|
||||||
let mut response_builder = Response::builder().status(status);
|
let mut response_builder = Response::builder().status(status);
|
||||||
|
|
||||||
@@ -203,7 +72,7 @@ pub async fn mock_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Smart Body 逻辑
|
// 7. 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) => {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
|
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
|
||||||
pub mod models;
|
pub mod model;
|
||||||
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;
|
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
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::models::MockRule;
|
use crate::model::{MockRule, MockSource}; // 假设 model 中定义了这两个类型
|
||||||
|
|
||||||
pub struct MockLoader;
|
pub struct MockLoader;
|
||||||
|
|
||||||
impl MockLoader {
|
impl MockLoader {
|
||||||
/// 递归扫描指定目录并构建索引表
|
/// 递归扫描指定目录并构建索引表
|
||||||
/// 目录结构:mocks/{group}/{rule}.json
|
|
||||||
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
|
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
|
||||||
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
|
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
|
||||||
|
|
||||||
if !dir.exists() {
|
// 1. 使用 walkdir 递归遍历目录,不限层级
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 使用 walkdir 递归遍历目录,寻找 JSON 文件
|
|
||||||
for entry in WalkDir::new(dir)
|
for entry in WalkDir::new(dir)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "json"))
|
.filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml"))
|
||||||
{
|
{
|
||||||
if let Some(rule) = Self::parse_json_file(entry.path()) {
|
if let Some(rules) = Self::parse_yaml_file(entry.path()) {
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -31,25 +27,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析单个 JSON 文件
|
/// 解析单个 YAML 文件,支持单接口和多接口模式
|
||||||
fn parse_json_file(path: &Path) -> Option<MockRule> {
|
fn parse_yaml_file(path: &Path) -> Option<Vec<MockRule>> {
|
||||||
match fs::read_to_string(path) {
|
let content = fs::read_to_string(path).ok()?;
|
||||||
Ok(content) => {
|
|
||||||
match serde_json::from_str::<MockRule>(&content) {
|
// 利用 serde_yaml 的反序列化能力处理 MockSource 枚举
|
||||||
Ok(rule) => Some(rule),
|
match serde_yaml::from_str::<MockSource>(&content) {
|
||||||
|
Ok(source) => Some(source.flatten()), // 统一打平为 Vec<MockRule>
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to parse JSON at {:?}: {}", path, e);
|
eprintln!("Failed to parse YAML at {:?}: {}", path, e);
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to read file {:?}: {}", path, e);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
src/main.rs
@@ -1,144 +1,80 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, RwLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use axum::{routing::any, Router};
|
||||||
use axum::{routing::post, Router};
|
use notify_debouncer_mini::{new_debouncer, notify::*};
|
||||||
use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode};
|
|
||||||
use tracing::{info, error};
|
|
||||||
|
|
||||||
use mock_server::loader::MockLoader;
|
use mock_server::loader::MockLoader;
|
||||||
use mock_server::router::MockRouter;
|
use mock_server::router::MockRouter;
|
||||||
use mock_server::handler::{mock_handler, AppState};
|
use mock_server::handler::{mock_handler, AppState};
|
||||||
use mock_server::logging;
|
|
||||||
|
|
||||||
fn print_usage() {
|
/// 打印启动 Banner
|
||||||
println!("Mock Server - A mock API server with hot-reload and MCP support");
|
fn print_banner() {
|
||||||
println!();
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
println!("Usage: mock_server [OPTIONS]");
|
// 蓝色 ANSI 转义码
|
||||||
println!();
|
println!("\x1b[34m");
|
||||||
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!(" ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ");
|
||||||
|
println!("\x1b[0m"); // 重置颜色
|
||||||
|
println!(" Mock Server v{}\n", version);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Initialize logging system
|
print_banner();
|
||||||
let log_dir = PathBuf::from("./logs");
|
tracing_subscriber::fmt::init();
|
||||||
logging::init(log_dir);
|
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
|
||||||
let mut mocks_dir = PathBuf::from("./mocks");
|
|
||||||
let mut port: u16 = 8080;
|
|
||||||
|
|
||||||
let mut i = 1;
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--help" | "-h" => {
|
|
||||||
print_usage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
"--mocks" => {
|
|
||||||
if i + 1 < args.len() {
|
|
||||||
mocks_dir = PathBuf::from(&args[i + 1]);
|
|
||||||
i += 1;
|
|
||||||
} else {
|
|
||||||
eprintln!("--mocks requires a directory path");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--port" => {
|
|
||||||
if i + 1 < args.len() {
|
|
||||||
match args[i + 1].parse::<u16>() {
|
|
||||||
Ok(p) => port = p,
|
|
||||||
Err(_) => {
|
|
||||||
eprintln!("Invalid port number: {}", args[i + 1]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
} else {
|
|
||||||
eprintln!("--port requires a port number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
eprintln!("Unknown option: {}", args[i]);
|
|
||||||
print_usage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let mocks_dir = Path::new("./mocks");
|
||||||
if !mocks_dir.exists() {
|
if !mocks_dir.exists() {
|
||||||
std::fs::create_dir_all(&mocks_dir).unwrap();
|
std::fs::create_dir_all(mocks_dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create shared MockManager for both HTTP and MCP
|
// 1. 初始加载
|
||||||
let manager = Arc::new(mock_server::manager::MockManager::new(mocks_dir.clone()));
|
println!("Scanning mocks directory...");
|
||||||
info!("Loaded {} groups", manager.list_groups().len());
|
let index = MockLoader::load_all_from_dir(mocks_dir);
|
||||||
|
|
||||||
// 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: std::sync::RwLock::new(MockRouter::new(index)),
|
router: RwLock::new(MockRouter::new(index)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup hot-reload watcher
|
// 2. 设置热加载监听器
|
||||||
let state_for_watcher = shared_state.clone();
|
let state_for_watcher = shared_state.clone();
|
||||||
let watch_path = mocks_dir.clone();
|
let watch_path = mocks_dir.to_path_buf();
|
||||||
let manager_for_watcher = manager.clone();
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
// 200ms 防抖,防止编辑器保存文件时产生多次干扰
|
||||||
let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap();
|
let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap();
|
||||||
debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap();
|
debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap();
|
||||||
|
|
||||||
|
// 启动异步任务监听文件变动
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(res) = rx.recv() {
|
while let Ok(res) = rx.recv() {
|
||||||
match res {
|
match res {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("Detected changes in mocks/, reloading...");
|
println!("🔄 Detecting changes in mocks/, reloading...");
|
||||||
let new_index = MockLoader::load_all_from_dir(&watch_path);
|
let new_index = MockLoader::load_all_from_dir(&watch_path);
|
||||||
|
// 获取写锁 (Write Lock) 更新索引
|
||||||
let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock");
|
let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock");
|
||||||
*writer = MockRouter::new(new_index);
|
*writer = MockRouter::new(new_index);
|
||||||
// Also reload in manager
|
println!("✅ Mocks reloaded successfully.");
|
||||||
manager_for_watcher.reload();
|
|
||||||
info!("Mocks reloaded successfully.");
|
|
||||||
}
|
}
|
||||||
Err(e) => error!("Watcher error: {:?}", e),
|
Err(e) => eprintln!("Watcher error: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create MCP HTTP service (stateless)
|
// 3. 配置 Axum 路由
|
||||||
let mcp_service = mock_server::mcp::create_mcp_http_service(manager.clone());
|
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
// MCP endpoint (stateless HTTP transport)
|
.fallback(any(mock_handler))
|
||||||
.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], port));
|
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
|
||||||
info!("HTTP server running at http://{}", addr);
|
println!("🚀 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();
|
||||||
if let Err(e) = axum::serve(listener, app).await {
|
axum::serve(listener, app).await.unwrap();
|
||||||
error!("HTTP server error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
208
src/manager.rs
@@ -1,208 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::models::MockRule;
|
|
||||||
use crate::loader::MockLoader;
|
|
||||||
use crate::router::MockRouter;
|
|
||||||
|
|
||||||
/// Mock 规则管理器
|
|
||||||
/// 提供统一的 CRUD 操作,同时管理内存和文件持久化
|
|
||||||
pub struct MockManager {
|
|
||||||
/// mocks 目录的基础路径
|
|
||||||
base_path: PathBuf,
|
|
||||||
/// 内存中的规则分组:group -> Vec<MockRule>
|
|
||||||
groups: RwLock<HashMap<String, Vec<MockRule>>>,
|
|
||||||
/// 路由索引
|
|
||||||
router: RwLock<MockRouter>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockManager {
|
|
||||||
/// 创建新的 MockManager
|
|
||||||
pub fn new(base_path: PathBuf) -> Self {
|
|
||||||
let groups = MockLoader::load_all_from_dir(&base_path);
|
|
||||||
let index = Self::build_index(&groups);
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
base_path,
|
|
||||||
groups: RwLock::new(groups),
|
|
||||||
router: RwLock::new(router),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从分组构建路由索引
|
|
||||||
fn build_index(groups: &HashMap<String, Vec<MockRule>>) -> HashMap<String, Vec<MockRule>> {
|
|
||||||
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
|
|
||||||
|
|
||||||
for rules in groups.values() {
|
|
||||||
for rule in rules {
|
|
||||||
let key = Self::extract_first_segment(&rule.request.path);
|
|
||||||
index.entry(key).or_default().push(rule.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
index
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提取路径首段
|
|
||||||
fn extract_first_segment(path: &str) -> String {
|
|
||||||
path.trim_start_matches('/')
|
|
||||||
.split('/')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("root")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出所有规则(可按分组过滤)
|
|
||||||
pub fn list(&self, group: Option<&str>) -> Vec<(String, MockRule)> {
|
|
||||||
let groups = self.groups.read().unwrap();
|
|
||||||
match group {
|
|
||||||
Some(g) => groups
|
|
||||||
.get(g)
|
|
||||||
.map(|rules| rules.iter().map(|r| (g.to_string(), r.clone())).collect())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
None => groups
|
|
||||||
.iter()
|
|
||||||
.flat_map(|(g, rules)| rules.iter().map(|r| (g.clone(), r.clone())))
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个规则
|
|
||||||
pub fn get(&self, group: &str, name: &str) -> Option<MockRule> {
|
|
||||||
let groups = self.groups.read().unwrap();
|
|
||||||
groups
|
|
||||||
.get(group)?
|
|
||||||
.iter()
|
|
||||||
.find(|r| r.name == name)
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建规则
|
|
||||||
pub fn create(&self, group: &str, rule: MockRule) -> Result<(), String> {
|
|
||||||
// 1. 写入文件
|
|
||||||
let dir = self.base_path.join(group);
|
|
||||||
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
|
||||||
|
|
||||||
let file_path = dir.join(format!("{}.json", rule.name));
|
|
||||||
let content =
|
|
||||||
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
|
|
||||||
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
|
|
||||||
|
|
||||||
// 2. 更新内存
|
|
||||||
{
|
|
||||||
let mut groups = self.groups.write().unwrap();
|
|
||||||
groups
|
|
||||||
.entry(group.to_string())
|
|
||||||
.or_default()
|
|
||||||
.push(rule.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 重建 router
|
|
||||||
self.rebuild_router();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新规则
|
|
||||||
pub fn update(&self, group: &str, name: &str, rule: MockRule) -> Result<(), String> {
|
|
||||||
// 1. 更新文件
|
|
||||||
let file_path = self.base_path.join(group).join(format!("{}.json", name));
|
|
||||||
|
|
||||||
// 如果 name 变化,需要删除旧文件
|
|
||||||
if name != rule.name {
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path).map_err(|e| format!("删除旧文件失败: {}", e))?;
|
|
||||||
}
|
|
||||||
let new_path = self.base_path.join(group).join(format!("{}.json", rule.name));
|
|
||||||
let content =
|
|
||||||
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
|
|
||||||
fs::write(&new_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
|
|
||||||
} else {
|
|
||||||
let content =
|
|
||||||
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
|
|
||||||
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 更新内存
|
|
||||||
{
|
|
||||||
let mut groups = self.groups.write().unwrap();
|
|
||||||
if let Some(rules) = groups.get_mut(group) {
|
|
||||||
if let Some(pos) = rules.iter().position(|r| r.name == name) {
|
|
||||||
rules[pos] = rule.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 重建 router
|
|
||||||
self.rebuild_router();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除规则
|
|
||||||
pub fn delete(&self, group: &str, name: &str) -> Result<(), String> {
|
|
||||||
// 1. 删除文件
|
|
||||||
let file_path = self.base_path.join(group).join(format!("{}.json", name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 更新内存
|
|
||||||
{
|
|
||||||
let mut groups = self.groups.write().unwrap();
|
|
||||||
if let Some(rules) = groups.get_mut(group) {
|
|
||||||
rules.retain(|r| r.name != name);
|
|
||||||
if rules.is_empty() {
|
|
||||||
groups.remove(group);
|
|
||||||
// 尝试删除空目录
|
|
||||||
let _ = fs::remove_dir(self.base_path.join(group));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 重建 router
|
|
||||||
self.rebuild_router();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重载所有规则(从磁盘重新加载)
|
|
||||||
pub fn reload(&self) {
|
|
||||||
let new_groups = MockLoader::load_all_from_dir(&self.base_path);
|
|
||||||
let new_index = Self::build_index(&new_groups);
|
|
||||||
let new_router = MockRouter::new(new_index);
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut groups = self.groups.write().unwrap();
|
|
||||||
*groups = new_groups;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut router = self.router.write().unwrap();
|
|
||||||
*router = new_router;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重建 router 索引
|
|
||||||
fn rebuild_router(&self) {
|
|
||||||
let groups = self.groups.read().unwrap();
|
|
||||||
let index = Self::build_index(&groups);
|
|
||||||
let new_router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut router = self.router.write().unwrap();
|
|
||||||
*router = new_router;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 router 的读锁引用(用于 handler)
|
|
||||||
pub fn get_router(&self) -> &RwLock<MockRouter> {
|
|
||||||
&self.router
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出所有分组
|
|
||||||
pub fn list_groups(&self) -> Vec<String> {
|
|
||||||
let groups = self.groups.read().unwrap();
|
|
||||||
groups.keys().cloned().collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
mod server;
|
|
||||||
|
|
||||||
pub use server::create_mcp_http_service;
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use rmcp::{
|
|
||||||
handler::server::tool::ToolRouter,
|
|
||||||
handler::server::wrapper::Parameters,
|
|
||||||
model::*,
|
|
||||||
tool, tool_handler, tool_router,
|
|
||||||
ErrorData as McpError, ServerHandler,
|
|
||||||
};
|
|
||||||
use schemars::JsonSchema;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::manager::MockManager;
|
|
||||||
use crate::models::MockRule;
|
|
||||||
|
|
||||||
/// MCP Server for Mock Server management
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct MockMcpServer {
|
|
||||||
manager: Arc<MockManager>,
|
|
||||||
tool_router: ToolRouter<Self>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request for getting a specific mock rule
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
|
||||||
pub struct GetRuleRequest {
|
|
||||||
#[schemars(description = "Group name (directory name)")]
|
|
||||||
pub group: String,
|
|
||||||
#[schemars(description = "Rule name")]
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request for creating a new mock rule
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
|
||||||
pub struct CreateRuleRequest {
|
|
||||||
#[schemars(description = "Group name (directory name)")]
|
|
||||||
pub group: String,
|
|
||||||
#[schemars(description = "Mock rule definition (JSON object)")]
|
|
||||||
pub rule_json: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request for updating a mock rule
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
|
||||||
pub struct UpdateRuleRequest {
|
|
||||||
#[schemars(description = "Group name")]
|
|
||||||
pub group: String,
|
|
||||||
#[schemars(description = "Current rule name")]
|
|
||||||
pub name: String,
|
|
||||||
#[schemars(description = "Updated rule definition (JSON object)")]
|
|
||||||
pub rule_json: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request for deleting a mock rule
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
|
||||||
pub struct DeleteRuleRequest {
|
|
||||||
#[schemars(description = "Group name")]
|
|
||||||
pub group: String,
|
|
||||||
#[schemars(description = "Rule name")]
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request for listing mock rules with optional filter
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
|
||||||
pub struct ListRulesRequest {
|
|
||||||
#[schemars(description = "Optional group name to filter by")]
|
|
||||||
pub group: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool_router]
|
|
||||||
impl MockMcpServer {
|
|
||||||
pub fn new(manager: Arc<MockManager>) -> Self {
|
|
||||||
Self {
|
|
||||||
manager,
|
|
||||||
tool_router: Self::tool_router(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(description = "List all mock rules, optionally filtered by group")]
|
|
||||||
async fn list_mock_rules(
|
|
||||||
&self,
|
|
||||||
params: Parameters<ListRulesRequest>,
|
|
||||||
) -> Result<CallToolResult, McpError> {
|
|
||||||
let rules = self.manager.list(params.0.group.as_deref());
|
|
||||||
let result = serde_json::to_string_pretty(&rules)
|
|
||||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
|
||||||
Ok(CallToolResult::success(vec![Content::text(result)]))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(description = "Get a specific mock rule by group and name")]
|
|
||||||
async fn get_mock_rule(
|
|
||||||
&self,
|
|
||||||
params: Parameters<GetRuleRequest>,
|
|
||||||
) -> Result<CallToolResult, McpError> {
|
|
||||||
match self.manager.get(¶ms.0.group, ¶ms.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(¶ms.0.rule_json)
|
|
||||||
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
|
|
||||||
|
|
||||||
match self.manager.create(¶ms.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(¶ms.0.rule_json)
|
|
||||||
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
|
|
||||||
|
|
||||||
match self.manager.update(¶ms.0.group, ¶ms.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(¶ms.0.group, ¶ms.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(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,47 +1,22 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// 解析后的请求 Body
|
/// 顶层包装:支持单对象或数组,自动打平
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub enum Payload {
|
#[serde(untagged)]
|
||||||
/// JSON 格式
|
pub enum MockSource {
|
||||||
Json(serde_json::Value),
|
/// 对应“一个接口一个文件”模式
|
||||||
/// XML 格式
|
Single(MockRule),
|
||||||
Xml(String),
|
/// 对应“一个文件多个接口”模式
|
||||||
/// URL 编码表单
|
Multiple(Vec<MockRule>),
|
||||||
Form(HashMap<String, String>),
|
|
||||||
/// Multipart 表单(键值对)
|
|
||||||
Multipart(HashMap<String, String>),
|
|
||||||
/// 纯文本
|
|
||||||
Text(String),
|
|
||||||
/// 无 Body
|
|
||||||
None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Payload {
|
impl MockSource {
|
||||||
/// 转换为字符串(用于兜底比较)
|
/// 将不同的解析模式统一转化为列表,供 Loader 构建索引
|
||||||
pub fn to_compare_string(&self) -> String {
|
pub fn flatten(self) -> Vec<MockRule> {
|
||||||
match self {
|
match self {
|
||||||
Payload::Json(v) => v.to_string(),
|
Self::Single(rule) => vec![rule],
|
||||||
Payload::Xml(s) | Payload::Text(s) => s.clone(),
|
Self::Multiple(rules) => rules,
|
||||||
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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +24,7 @@ impl Payload {
|
|||||||
/// 核心 Mock 规则定义
|
/// 核心 Mock 规则定义
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
pub struct MockRule {
|
pub struct MockRule {
|
||||||
pub name: String,
|
pub id: String,
|
||||||
pub request: RequestMatcher,
|
pub request: RequestMatcher,
|
||||||
pub response: MockResponse,
|
pub response: MockResponse,
|
||||||
pub settings: Option<MockSettings>,
|
pub settings: Option<MockSettings>,
|
||||||
@@ -64,7 +39,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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +52,6 @@ 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 {
|
||||||
@@ -99,3 +67,10 @@ impl MockResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 模拟器行为设置
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
|
pub struct MockSettings {
|
||||||
|
/// 模拟网络延迟(毫秒)
|
||||||
|
pub delay_ms: Option<u64>,
|
||||||
|
}
|
||||||
165
src/router.rs
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::models::{MockRule, Payload};
|
use crate::model::MockRule;
|
||||||
|
|
||||||
pub struct MockRouter {
|
pub struct MockRouter {
|
||||||
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
|
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
|
||||||
@@ -18,7 +18,7 @@ impl MockRouter {
|
|||||||
path: &str,
|
path: &str,
|
||||||
queries: &HashMap<String, String>,
|
queries: &HashMap<String, String>,
|
||||||
headers: &HashMap<String, String>,
|
headers: &HashMap<String, String>,
|
||||||
payload: &Payload,
|
incoming_body: &Option<serde_json::Value>, // 修改 1: 增加参数
|
||||||
) -> Option<&MockRule> {
|
) -> Option<&MockRule> {
|
||||||
// 1. 提取请求路径的首段作为索引 Key
|
// 1. 提取请求路径的首段作为索引 Key
|
||||||
let key = self.extract_first_segment(path);
|
let key = self.extract_first_segment(path);
|
||||||
@@ -27,7 +27,7 @@ impl MockRouter {
|
|||||||
if let Some(rules) = self.index.get(&key) {
|
if let Some(rules) = self.index.get(&key) {
|
||||||
// 3. 在候选集中进行线性深度匹配
|
// 3. 在候选集中进行线性深度匹配
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
if self.is_match(rule, method, path, queries, headers, payload) {
|
if self.is_match(rule, method, path, queries, headers,incoming_body) {
|
||||||
return Some(rule);
|
return Some(rule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,19 +44,18 @@ impl MockRouter {
|
|||||||
path: &str,
|
path: &str,
|
||||||
queries: &HashMap<String, String>,
|
queries: &HashMap<String, String>,
|
||||||
headers: &HashMap<String, String>,
|
headers: &HashMap<String, String>,
|
||||||
payload: &Payload,
|
incoming_body: &Option<serde_json::Value>, // 修改 3: 增加参数
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠)
|
// A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠)
|
||||||
if rule.request.method.to_uppercase() != method.to_uppercase() {
|
if rule.request.method.to_uppercase() != method.to_uppercase() {
|
||||||
println!("DEBUG: [NAME:{}] Method Mismatch: YAML={}, Req={}", rule.name, rule.request.method, method);
|
println!("DEBUG: [ID:{}] Method Mismatch: YAML={}, Req={}", rule.id, rule.request.method, method);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
|
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
|
||||||
println!("DEBUG: [NAME:{}] Path Mismatch: YAML='{}', Req='{}'", rule.name, rule.request.path, path);
|
println!("DEBUG: [ID:{}] Path Mismatch: YAML='{}', Req='{}'", rule.id, rule.request.path, path);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
println!("DEBUG: [NAME:{}] Method and Path matched! Checking headers...", rule.name);
|
println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id);
|
||||||
|
|
||||||
// B. Query 参数校验 (子集匹配原则)
|
// B. Query 参数校验 (子集匹配原则)
|
||||||
if let Some(ref required_queries) = rule.request.query_params {
|
if let Some(ref required_queries) = rule.request.query_params {
|
||||||
for (key, val) in required_queries {
|
for (key, val) in required_queries {
|
||||||
@@ -66,75 +65,46 @@ impl MockRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// C. Header 校验 (大小写不敏感,Content-Type 使用前缀匹配)
|
// C. Header 校验 (优化版)
|
||||||
if let Some(ref required_headers) = rule.request.headers {
|
if let Some(ref required_headers) = rule.request.headers {
|
||||||
for (key, val) in required_headers {
|
for (key, val) in required_headers {
|
||||||
let key_lower = key.to_lowercase();
|
println!("{}:{}",key.clone(), val.clone());
|
||||||
|
// 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值
|
||||||
let matched = headers.iter().any(|(k, v)| {
|
let matched = headers.iter().any(|(k, v)| {
|
||||||
if k.to_lowercase() != key_lower {
|
// k.to_lowercase() == key.to_lowercase() && v.contains(val)
|
||||||
return false;
|
k.to_lowercase() == key.to_lowercase() && v==val
|
||||||
}
|
|
||||||
// Content-Type 使用前缀匹配(因为可能包含 boundary 等参数)
|
|
||||||
if key_lower == "content-type" {
|
|
||||||
v.to_lowercase().starts_with(&val.to_lowercase())
|
|
||||||
} else {
|
|
||||||
v == val
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if !matched {
|
if !matched {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// D. 智能 Body 全量比对逻辑
|
||||||
|
if let Some(ref required_val) = rule.request.body {
|
||||||
|
match incoming_body {
|
||||||
|
Some(actual_val) => {
|
||||||
|
// 实现你的想法:尝试将 YAML 中的 String 转换为 Object 再对比
|
||||||
|
let final_required = if let Some(s) = required_val.as_str() {
|
||||||
|
// 如果能解析成 JSON,就用解析后的对象,否则用原始字符串 Value
|
||||||
|
serde_json::from_str::<serde_json::Value>(s).unwrap_or_else(|_| required_val.clone())
|
||||||
|
} else {
|
||||||
|
required_val.clone()
|
||||||
|
};
|
||||||
|
|
||||||
// D. Body 匹配(根据 Payload 类型智能比较)
|
// 执行全量相等比对
|
||||||
if let Some(ref rule_body) = rule.request.body {
|
if final_required != *actual_val {
|
||||||
return self.match_body(rule_body, payload);
|
println!("DEBUG: [ID:{}] Body Mismatch", rule.id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => return false, // YAML 要求有 Body 但请求为空
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body 匹配逻辑
|
|
||||||
fn match_body(&self, rule_body: &serde_json::Value, payload: &Payload) -> bool {
|
|
||||||
match payload {
|
|
||||||
Payload::Json(actual) => {
|
|
||||||
// 如果 rule_body 是字符串,尝试解析为 JSON 后比较
|
|
||||||
if let Some(rule_str) = rule_body.as_str() {
|
|
||||||
// 尝试将字符串解析为 JSON
|
|
||||||
if let Ok(parsed_rule) = serde_json::from_str::<serde_json::Value>(rule_str) {
|
|
||||||
return &parsed_rule == actual;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 直接比较
|
|
||||||
rule_body == actual
|
|
||||||
}
|
|
||||||
Payload::Xml(actual) => {
|
|
||||||
// XML 字符串比较(规范化后比较)
|
|
||||||
rule_body.as_str()
|
|
||||||
.map(|expected| normalize_xml(expected) == normalize_xml(actual))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
Payload::Form(actual) => {
|
|
||||||
// Form 键值对比较(子集匹配)
|
|
||||||
compare_form_with_json(rule_body, actual)
|
|
||||||
}
|
|
||||||
Payload::Multipart(actual_data) => {
|
|
||||||
// Multipart 匹配:支持键值对或字段名列表
|
|
||||||
compare_multipart_with_json(rule_body, actual_data)
|
|
||||||
}
|
|
||||||
Payload::Text(actual) => {
|
|
||||||
// 字符串比较(去掉首尾空白)
|
|
||||||
rule_body.as_str()
|
|
||||||
.map(|expected| expected.trim() == actual.trim())
|
|
||||||
.unwrap_or_else(|| rule_body.to_string().trim() == actual.trim())
|
|
||||||
}
|
|
||||||
Payload::None => {
|
|
||||||
false // 配置了 body,但请求没有 body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 与 Loader 保持一致的 Key 提取算法
|
/// 与 Loader 保持一致的 Key 提取算法
|
||||||
fn extract_first_segment(&self, path: &str) -> String {
|
fn extract_first_segment(&self, path: &str) -> String {
|
||||||
path.trim_start_matches('/')
|
path.trim_start_matches('/')
|
||||||
@@ -144,74 +114,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"exportInfo": {
|
|
||||||
"exportedAt": "2026-03-27T10:00:00Z",
|
|
||||||
"version": "2.0.0"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"id": 10001,
|
|
||||||
"username": "admin",
|
|
||||||
"email": "admin@example.com",
|
|
||||||
"nickname": "管理员",
|
|
||||||
"role": "administrator",
|
|
||||||
"createdAt": "2025-01-01T00:00:00Z",
|
|
||||||
"lastLoginAt": "2026-03-27T09:30:00Z"
|
|
||||||
},
|
|
||||||
"preferences": {
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN",
|
|
||||||
"notifications": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +1,160 @@
|
|||||||
use std::fs::{self, File};
|
use std::fs::{self, File};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use mock_server::models::MockRule;
|
// 确保 Cargo.toml 中有 serde_json
|
||||||
|
use serde_json::json;
|
||||||
|
use mock_server::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_deserialization() {
|
fn test_config_parsing_scenarios() {
|
||||||
// 测试 1:验证 JSON 解析
|
// 场景 A: 验证单接口配置 (增加 body 结构化校验)
|
||||||
let single_json = r#"{
|
let yaml_single = r#"
|
||||||
"name": "auth_v1",
|
id: "auth_v1"
|
||||||
"request": {
|
request:
|
||||||
"method": "POST",
|
method: "POST"
|
||||||
"path": "/api/v1/login"
|
path: "/api/v1/login"
|
||||||
},
|
body: { "user": "admin" }
|
||||||
"response": {
|
response: { status: 200, body: "welcome" }
|
||||||
"status": 200,
|
"#;
|
||||||
"body": "inline_content"
|
let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
|
||||||
}
|
let rules = source_s.flatten();
|
||||||
}"#;
|
assert_eq!(rules.len(), 1);
|
||||||
let res: MockRule = serde_json::from_str(single_json).expect("应该成功解析 JSON");
|
// 验证 body 是否被成功解析为 Value::Object
|
||||||
assert_eq!(res.name, "auth_v1");
|
assert!(rules[0].request.body.is_some());
|
||||||
|
assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin");
|
||||||
|
|
||||||
|
// 场景 B: 验证多接口配置 (原有逻辑不变)
|
||||||
|
let yaml_multi = r#"
|
||||||
|
- id: "api_1"
|
||||||
|
request: { method: "GET", path: "/health" }
|
||||||
|
response: { status: 200, body: "ok" }
|
||||||
|
- id: "api_2"
|
||||||
|
request: { method: "GET", path: "/version" }
|
||||||
|
response: { status: 200, body: "1.0" }
|
||||||
|
"#;
|
||||||
|
let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败");
|
||||||
|
assert_eq!(source_m.flatten().len(), 2);
|
||||||
|
|
||||||
|
// 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 (原有逻辑不变)
|
||||||
|
let yaml_file = r#"
|
||||||
|
id: "export_api"
|
||||||
|
request: { method: "GET", path: "/download" }
|
||||||
|
response: { status: 200, body: "file://./storage/data.zip" }
|
||||||
|
"#;
|
||||||
|
let source_f: MockSource = serde_yaml::from_str(yaml_file).unwrap();
|
||||||
|
let rule = &source_f.flatten()[0];
|
||||||
|
assert!(rule.response.body.starts_with("file://"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 模块二:验证 Loader 递归扫描与索引构建 (不涉及 Matcher 逻辑,基本保持不变)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_recursive_loading_logic() {
|
fn test_loader_recursive_indexing() {
|
||||||
// 创建临时 Mock 目录结构
|
let temp_root = tempdir().expect("无法创建临时目录");
|
||||||
let root_dir = tempdir().expect("创建临时目录失败");
|
let root_path = temp_root.path();
|
||||||
let root_path = root_dir.path();
|
|
||||||
|
|
||||||
// 构造物理层级:mocks/v1/user/
|
let auth_path = root_path.join("v1/auth");
|
||||||
let user_dir = root_path.join("v1/user");
|
fs::create_dir_all(&auth_path).unwrap();
|
||||||
fs::create_dir_all(&user_dir).unwrap();
|
|
||||||
|
|
||||||
// 在深层目录创建单接口 JSON 文件
|
let mut f1 = File::create(auth_path.join("login.yaml")).unwrap();
|
||||||
let mut file1 = File::create(user_dir.join("get_profile.json")).unwrap();
|
writeln!(f1, "id: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap();
|
||||||
writeln!(
|
|
||||||
file1,
|
|
||||||
r#"{{
|
|
||||||
"name": "user_profile",
|
|
||||||
"request": {{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/user/profile"
|
|
||||||
}},
|
|
||||||
"response": {{
|
|
||||||
"status": 200,
|
|
||||||
"body": "profile_data"
|
|
||||||
}}
|
|
||||||
}}"#
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// 在根目录创建另一个 JSON 文件
|
let mut f2 = File::create(root_path.join("sys.yaml")).unwrap();
|
||||||
let mut file2 = File::create(root_path.join("health.json")).unwrap();
|
writeln!(f2, "- id: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap();
|
||||||
writeln!(
|
|
||||||
file2,
|
|
||||||
r#"{{
|
|
||||||
"name": "sys_health",
|
|
||||||
"request": {{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/health"
|
|
||||||
}},
|
|
||||||
"response": {{
|
|
||||||
"status": 200,
|
|
||||||
"body": "ok"
|
|
||||||
}}
|
|
||||||
}}"#
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// 执行加载
|
|
||||||
let index = MockLoader::load_all_from_dir(root_path);
|
let index = MockLoader::load_all_from_dir(root_path);
|
||||||
|
|
||||||
// 断言结果:
|
assert!(index.contains_key("api"));
|
||||||
// 1. 检查 Key 是否根据路径首段正确提取
|
assert!(index.contains_key("health"));
|
||||||
assert!(index.contains_key("api"), "索引应包含 'api' 键");
|
let total: usize = index.values().map(|v| v.len()).sum();
|
||||||
assert!(index.contains_key("health"), "索引应包含 'health' 键");
|
assert_eq!(total, 2);
|
||||||
|
}
|
||||||
// 2. 检查规则总数
|
|
||||||
let total_rules: usize = index.values().map(|v| v.len()).sum();
|
#[test]
|
||||||
assert_eq!(total_rules, 2, "总规则数应为 2");
|
fn test_router_matching_logic() {
|
||||||
|
let mut index = HashMap::new();
|
||||||
// 3. 检查深层文件是否被正确读取
|
|
||||||
let api_rules = &index["api"];
|
// 1. 准备带有 Body 的规则
|
||||||
assert_eq!(api_rules[0].name, "user_profile");
|
let rule_auth = serde_yaml::from_str::<MockSource>(
|
||||||
|
r#"
|
||||||
|
id: "auth_v1"
|
||||||
|
request:
|
||||||
|
method: "POST"
|
||||||
|
path: "/api/v1/login"
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
body: { "code": 123 }
|
||||||
|
response: { status: 200, body: "token_123" }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
index.insert("api".to_string(), vec![rule_auth[0].clone()]);
|
||||||
|
let router = MockRouter::new(index);
|
||||||
|
|
||||||
|
// 2. 测试场景 A:完全匹配 (包括 Body)
|
||||||
|
let mut headers = HashMap::new();
|
||||||
|
headers.insert("Content-Type".to_string(), "application/json".to_string());
|
||||||
|
|
||||||
|
// 构造请求 Body
|
||||||
|
let incoming_body = Some(json!({ "code": 123 }));
|
||||||
|
|
||||||
|
let matched = router.match_rule(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/login",
|
||||||
|
&HashMap::new(),
|
||||||
|
&headers,
|
||||||
|
&incoming_body // 传入新参数
|
||||||
|
);
|
||||||
|
assert!(matched.is_some());
|
||||||
|
assert_eq!(matched.unwrap().id, "auth_v1");
|
||||||
|
|
||||||
|
// 3. 测试场景 B:Body 不匹配
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
@@ -1,318 +1,114 @@
|
|||||||
use std::fs::{self, File};
|
use std::fs::{self, File};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use serde_json::json;
|
// 假设你的项目名在 Cargo.toml 中叫 mock_server
|
||||||
use mock_server::models::{MockRule, Payload};
|
use mock_server::model::MockSource;
|
||||||
use mock_server::loader::MockLoader;
|
use mock_server::loader::MockLoader;
|
||||||
use mock_server::router::MockRouter;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// 模块一:验证 Config 反序列化逻辑
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_parsing_scenarios() {
|
fn test_config_deserialization() {
|
||||||
// 场景 A: 验证单接口配置 (增加 body 结构化校验)
|
// 测试 1:验证单接口 YAML 解析
|
||||||
let json_single = r#"{
|
let single_yaml = r#"
|
||||||
"name": "auth_v1",
|
id: "auth_v1"
|
||||||
"request": {
|
request:
|
||||||
"method": "POST",
|
method: "POST"
|
||||||
"path": "/api/v1/login",
|
path: "/api/v1/login"
|
||||||
"body": { "user": "admin" }
|
response:
|
||||||
},
|
status: 200
|
||||||
"response": { "status": 200, "body": "welcome" }
|
body: "inline_content"
|
||||||
}"#;
|
"#;
|
||||||
let rule: MockRule = serde_json::from_str(json_single).expect("解析单接口失败");
|
let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口");
|
||||||
assert!(rule.request.body.is_some());
|
assert_eq!(res.flatten().len(), 1);
|
||||||
assert_eq!(rule.request.body.as_ref().unwrap()["user"], "admin");
|
// assert_eq!(res.flatten()[0].id, "auth_v1");
|
||||||
|
|
||||||
// 场景 B: 验证 Smart Body 的 file:// 协议字符串解析
|
// 测试 2:验证多接口 YAML 数组解析
|
||||||
let json_file = r#"{
|
let multi_yaml = r#"
|
||||||
"name": "export_api",
|
- id: "api_1"
|
||||||
"request": { "method": "GET", "path": "/download" },
|
request: { method: "GET", path: "/1" }
|
||||||
"response": { "status": 200, "body": "file://./storage/data.zip" }
|
response: { status: 200, body: "b1" }
|
||||||
}"#;
|
- id: "api_2"
|
||||||
let rule_file: MockRule = serde_json::from_str(json_file).unwrap();
|
request: { method: "GET", path: "/2" }
|
||||||
assert!(rule_file.response.body.starts_with("file://"));
|
response: { status: 200, body: "b2" }
|
||||||
|
"#;
|
||||||
|
let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
|
||||||
|
assert_eq!(res_multi.flatten().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模块二:验证 Loader 递归扫描与索引构建
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_loader_recursive_indexing() {
|
fn test_recursive_loading_logic() {
|
||||||
let temp_root = tempdir().expect("无法创建临时目录");
|
// 创建临时 Mock 目录结构
|
||||||
let root_path = temp_root.path();
|
let root_dir = tempdir().expect("创建临时目录失败");
|
||||||
|
let root_path = root_dir.path();
|
||||||
|
|
||||||
let auth_path = root_path.join("v1/auth");
|
// 构造物理层级:mocks/v1/user/
|
||||||
fs::create_dir_all(&auth_path).unwrap();
|
let user_dir = root_path.join("v1/user");
|
||||||
|
fs::create_dir_all(&user_dir).unwrap();
|
||||||
|
|
||||||
let mut f1 = File::create(auth_path.join("login.json")).unwrap();
|
// 在深层目录创建单接口文件
|
||||||
writeln!(f1, r#"{{"name":"l1","request":{{"method":"POST","path":"/api/v1/login"}},"response":{{"status":200,"body":"ok"}}}}"#).unwrap();
|
let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap();
|
||||||
|
writeln!(
|
||||||
|
file1,
|
||||||
|
r#"
|
||||||
|
id: "user_profile"
|
||||||
|
request:
|
||||||
|
method: "GET"
|
||||||
|
path: "/api/v1/user/profile"
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
body: "profile_data"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut f2 = File::create(root_path.join("health.json")).unwrap();
|
// 在根目录创建多接口文件
|
||||||
writeln!(f2, r#"{{"name":"s1","request":{{"method":"GET","path":"/health"}},"response":{{"status":200,"body":"up"}}}}"#).unwrap();
|
let mut file2 = File::create(root_path.join("system.yaml")).unwrap();
|
||||||
|
writeln!(
|
||||||
|
file2,
|
||||||
|
r#"
|
||||||
|
- id: "sys_health"
|
||||||
|
request: {{ method: "GET", path: "/health" }}
|
||||||
|
response: {{ status: 200, body: "ok" }}
|
||||||
|
- id: "sys_version"
|
||||||
|
request: {{ method: "GET", path: "/version" }}
|
||||||
|
response: {{ status: 200, body: "1.0.0" }}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// writeln!(
|
||||||
|
// file2,
|
||||||
|
// r#"
|
||||||
|
// - id: "sys_health"
|
||||||
|
// request:
|
||||||
|
// method: "GET"
|
||||||
|
// path: "/health"
|
||||||
|
// response:
|
||||||
|
// status: 200
|
||||||
|
// body: "ok"
|
||||||
|
// - id: "sys_version"
|
||||||
|
// request:
|
||||||
|
// method: "GET"
|
||||||
|
// path: "/version"
|
||||||
|
// response:
|
||||||
|
// status: 200
|
||||||
|
// body: "1.0.0"
|
||||||
|
// "#
|
||||||
|
// )
|
||||||
|
// .unwrap();
|
||||||
|
|
||||||
|
// 执行加载
|
||||||
let index = MockLoader::load_all_from_dir(root_path);
|
let index = MockLoader::load_all_from_dir(root_path);
|
||||||
|
|
||||||
assert!(index.contains_key("api"));
|
// 断言结果:
|
||||||
assert!(index.contains_key("health"));
|
// 1. 检查 Key 是否根据路径首段正确提取(/api/v1/... -> api, /health -> health)
|
||||||
let total: usize = index.values().map(|v| v.len()).sum();
|
assert!(index.contains_key("api"), "索引应包含 'api' 键");
|
||||||
assert_eq!(total, 2);
|
assert!(index.contains_key("health"), "索引应包含 'health' 键");
|
||||||
}
|
assert!(index.contains_key("version"), "索引应包含 'version' 键");
|
||||||
|
|
||||||
/// 模块三:验证 Router 匹配逻辑(使用新的 Payload 类型)
|
// 2. 检查规则总数
|
||||||
#[test]
|
let total_rules: usize = index.values().map(|v| v.len()).sum();
|
||||||
fn test_router_matching_logic() {
|
assert_eq!(total_rules, 3, "总规则数应为 3");
|
||||||
let mut index = HashMap::new();
|
|
||||||
|
// 3. 检查深层文件是否被正确读取
|
||||||
// 1. 准备带有 Body 的规则
|
let api_rules = &index["api"];
|
||||||
let rule_json = r#"{
|
assert_eq!(api_rules[0].id, "user_profile");
|
||||||
"name": "auth_v1",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/login",
|
|
||||||
"headers": { "Content-Type": "application/json" },
|
|
||||||
"body": { "code": 123 }
|
|
||||||
},
|
|
||||||
"response": { "status": 200, "body": "token_123" }
|
|
||||||
}"#;
|
|
||||||
let rule_auth: MockRule = serde_json::from_str(rule_json).unwrap();
|
|
||||||
|
|
||||||
index.insert("api".to_string(), vec![rule_auth.clone()]);
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
// 2. 测试场景 A:JSON 完全匹配
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Content-Type".to_string(), "application/json".to_string());
|
|
||||||
|
|
||||||
let payload = Payload::Json(json!({ "code": 123 }));
|
|
||||||
|
|
||||||
let matched = router.match_rule(
|
|
||||||
"POST",
|
|
||||||
"/api/v1/login",
|
|
||||||
&HashMap::new(),
|
|
||||||
&headers,
|
|
||||||
&payload,
|
|
||||||
);
|
|
||||||
assert!(matched.is_some());
|
|
||||||
assert_eq!(matched.unwrap().name, "auth_v1");
|
|
||||||
|
|
||||||
// 3. 测试场景 B:Body 不匹配
|
|
||||||
let wrong_payload = Payload::Json(json!({ "code": 456 }));
|
|
||||||
let matched_fail = router.match_rule(
|
|
||||||
"POST",
|
|
||||||
"/api/v1/login",
|
|
||||||
&HashMap::new(),
|
|
||||||
&headers,
|
|
||||||
&wrong_payload,
|
|
||||||
);
|
|
||||||
assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
|
|
||||||
|
|
||||||
// 4. 测试场景 C:末尾斜杠兼容性测试
|
|
||||||
let matched_slash = router.match_rule(
|
|
||||||
"POST",
|
|
||||||
"/api/v1/login/",
|
|
||||||
&HashMap::new(),
|
|
||||||
&headers,
|
|
||||||
&payload,
|
|
||||||
);
|
|
||||||
assert!(matched_slash.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模块四:验证不同 Payload 类型的匹配
|
|
||||||
#[test]
|
|
||||||
fn test_payload_type_matching() {
|
|
||||||
let mut index = HashMap::new();
|
|
||||||
|
|
||||||
// XML 规则
|
|
||||||
let xml_rule: MockRule = serde_json::from_str(r#"{
|
|
||||||
"name": "xml_api",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/xml",
|
|
||||||
"body": "<root><name>test</name></root>"
|
|
||||||
},
|
|
||||||
"response": { "status": 200, "body": "ok" }
|
|
||||||
}"#).unwrap();
|
|
||||||
|
|
||||||
// Form 规则
|
|
||||||
let form_rule: MockRule = serde_json::from_str(r#"{
|
|
||||||
"name": "form_api",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/form",
|
|
||||||
"body": { "username": "admin", "password": "123456" }
|
|
||||||
},
|
|
||||||
"response": { "status": 200, "body": "login_ok" }
|
|
||||||
}"#).unwrap();
|
|
||||||
|
|
||||||
// Text 规则
|
|
||||||
let text_rule: MockRule = serde_json::from_str(r#"{
|
|
||||||
"name": "text_api",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/text",
|
|
||||||
"body": "plain text content"
|
|
||||||
},
|
|
||||||
"response": { "status": 200, "body": "text_ok" }
|
|
||||||
}"#).unwrap();
|
|
||||||
|
|
||||||
index.insert("api".to_string(), vec![xml_rule, form_rule, text_rule]);
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
// 测试 XML 匹配
|
|
||||||
let xml_payload = Payload::Xml("<root><name>test</name></root>".to_string());
|
|
||||||
let xml_matched = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_payload);
|
|
||||||
assert!(xml_matched.is_some(), "XML 应该匹配");
|
|
||||||
assert_eq!(xml_matched.unwrap().name, "xml_api");
|
|
||||||
|
|
||||||
// 测试 Form 匹配
|
|
||||||
let mut form_data = HashMap::new();
|
|
||||||
form_data.insert("username".to_string(), "admin".to_string());
|
|
||||||
form_data.insert("password".to_string(), "123456".to_string());
|
|
||||||
let form_payload = Payload::Form(form_data);
|
|
||||||
let form_matched = router.match_rule("POST", "/api/form", &HashMap::new(), &HashMap::new(), &form_payload);
|
|
||||||
assert!(form_matched.is_some(), "Form 应该匹配");
|
|
||||||
assert_eq!(form_matched.unwrap().name, "form_api");
|
|
||||||
|
|
||||||
// 测试 Text 匹配
|
|
||||||
let text_payload = Payload::Text("plain text content".to_string());
|
|
||||||
let text_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &text_payload);
|
|
||||||
assert!(text_matched.is_some(), "Text 应该匹配");
|
|
||||||
assert_eq!(text_matched.unwrap().name, "text_api");
|
|
||||||
|
|
||||||
// 测试 None 不匹配
|
|
||||||
let none_payload = Payload::None;
|
|
||||||
let none_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &none_payload);
|
|
||||||
assert!(none_matched.is_none(), "None 不应该匹配有 body 的规则");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模块五:验证 Payload 方法
|
|
||||||
#[test]
|
|
||||||
fn test_payload_methods() {
|
|
||||||
// 测试 content_type_name
|
|
||||||
assert_eq!(Payload::Json(json!({})).content_type_name(), "application/json");
|
|
||||||
assert_eq!(Payload::Xml("".to_string()).content_type_name(), "application/xml");
|
|
||||||
assert_eq!(Payload::Form(HashMap::new()).content_type_name(), "application/x-www-form-urlencoded");
|
|
||||||
assert_eq!(Payload::Multipart(HashMap::new()).content_type_name(), "multipart/form-data");
|
|
||||||
assert_eq!(Payload::Text("".to_string()).content_type_name(), "text/plain");
|
|
||||||
assert_eq!(Payload::None.content_type_name(), "none");
|
|
||||||
|
|
||||||
// 测试 to_compare_string
|
|
||||||
let json_payload = Payload::Json(json!({"a": 1, "b": 2}));
|
|
||||||
assert!(json_payload.to_compare_string().contains("\"a\":1"));
|
|
||||||
|
|
||||||
let text_payload = Payload::Text("hello world".to_string());
|
|
||||||
assert_eq!(text_payload.to_compare_string(), "hello world");
|
|
||||||
|
|
||||||
let none_payload = Payload::None;
|
|
||||||
assert_eq!(none_payload.to_compare_string(), "");
|
|
||||||
|
|
||||||
// 测试 Form 排序后的字符串
|
|
||||||
let mut form_map = HashMap::new();
|
|
||||||
form_map.insert("b".to_string(), "2".to_string());
|
|
||||||
form_map.insert("a".to_string(), "1".to_string());
|
|
||||||
let form_payload = Payload::Form(form_map);
|
|
||||||
assert_eq!(form_payload.to_compare_string(), "a=1&b=2");
|
|
||||||
|
|
||||||
// 测试 Multipart 排序后的字符串
|
|
||||||
let mut multipart_map = HashMap::new();
|
|
||||||
multipart_map.insert("b".to_string(), "2".to_string());
|
|
||||||
multipart_map.insert("a".to_string(), "1".to_string());
|
|
||||||
let multipart_payload = Payload::Multipart(multipart_map);
|
|
||||||
assert_eq!(multipart_payload.to_compare_string(), "a=1&b=2");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模块六:验证 Multipart 匹配
|
|
||||||
#[test]
|
|
||||||
fn test_multipart_matching() {
|
|
||||||
let mut index = HashMap::new();
|
|
||||||
|
|
||||||
// 数组形式:只匹配字段名
|
|
||||||
let array_rule: MockRule = serde_json::from_str(r#"{
|
|
||||||
"name": "upload_array",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/upload/array",
|
|
||||||
"body": ["file", "description"]
|
|
||||||
},
|
|
||||||
"response": { "status": 200, "body": "ok" }
|
|
||||||
}"#).unwrap();
|
|
||||||
|
|
||||||
// 对象形式:匹配键名
|
|
||||||
let object_rule: MockRule = serde_json::from_str(r#"{
|
|
||||||
"name": "upload_object",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/upload/object",
|
|
||||||
"body": { "username": "admin", "role": "user" }
|
|
||||||
},
|
|
||||||
"response": { "status": 200, "body": "ok" }
|
|
||||||
}"#).unwrap();
|
|
||||||
|
|
||||||
index.insert("api".to_string(), vec![array_rule, object_rule]);
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
// 测试数组形式:匹配字段名
|
|
||||||
let mut full_data = HashMap::new();
|
|
||||||
full_data.insert("file".to_string(), "file_content".to_string());
|
|
||||||
full_data.insert("description".to_string(), "test file".to_string());
|
|
||||||
let full_payload = Payload::Multipart(full_data);
|
|
||||||
let matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &full_payload);
|
|
||||||
assert!(matched.is_some(), "包含所有字段应该匹配");
|
|
||||||
|
|
||||||
// 测试数组形式:缺少字段
|
|
||||||
let mut partial_data = HashMap::new();
|
|
||||||
partial_data.insert("file".to_string(), "file_content".to_string());
|
|
||||||
let partial_payload = Payload::Multipart(partial_data);
|
|
||||||
let not_matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &partial_payload);
|
|
||||||
assert!(not_matched.is_none(), "缺少字段不应该匹配");
|
|
||||||
|
|
||||||
// 测试对象形式:键名匹配(值被忽略)
|
|
||||||
let mut correct_data = HashMap::new();
|
|
||||||
correct_data.insert("username".to_string(), "any_value".to_string());
|
|
||||||
correct_data.insert("role".to_string(), "any_role".to_string());
|
|
||||||
let correct_payload = Payload::Multipart(correct_data);
|
|
||||||
let object_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &correct_payload);
|
|
||||||
assert!(object_matched.is_some(), "键名匹配应该成功");
|
|
||||||
|
|
||||||
// 测试对象形式:键名不匹配
|
|
||||||
let mut wrong_data = HashMap::new();
|
|
||||||
wrong_data.insert("username".to_string(), "admin".to_string());
|
|
||||||
wrong_data.insert("other_field".to_string(), "value".to_string());
|
|
||||||
let wrong_payload = Payload::Multipart(wrong_data);
|
|
||||||
let wrong_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &wrong_payload);
|
|
||||||
assert!(wrong_matched.is_none(), "缺少键名不应该成功");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模块七:验证 XML 格式化匹配
|
|
||||||
#[test]
|
|
||||||
fn test_xml_normalized_matching() {
|
|
||||||
let mut index = HashMap::new();
|
|
||||||
|
|
||||||
// JSON 中的 XML(紧凑格式)
|
|
||||||
let xml_rule: MockRule = serde_json::from_str(r#"{
|
|
||||||
"name": "xml_api",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/xml",
|
|
||||||
"body": "<user><id>1001</id><name>张三</name></user>"
|
|
||||||
},
|
|
||||||
"response": { "status": 200, "body": "ok" }
|
|
||||||
}"#).unwrap();
|
|
||||||
|
|
||||||
index.insert("api".to_string(), vec![xml_rule]);
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
// 测试 1:紧凑格式的 XML
|
|
||||||
let compact_xml = Payload::Xml("<user><id>1001</id><name>张三</name></user>".to_string());
|
|
||||||
let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml);
|
|
||||||
assert!(matched1.is_some(), "紧凑格式 XML 应该匹配");
|
|
||||||
|
|
||||||
// 测试 2:带 XML 声明的请求
|
|
||||||
let xml_with_decl = Payload::Xml("<?xml version=\"1.0\" encoding=\"UTF-8\"?><user><id>1001</id><name>张三</name></user>".to_string());
|
|
||||||
let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl);
|
|
||||||
assert!(matched2.is_some(), "带声明的 XML 应该匹配");
|
|
||||||
|
|
||||||
// 测试 3:内容不同,不应该匹配
|
|
||||||
let wrong_xml = Payload::Xml("<user><id>1002</id><name>李四</name></user>".to_string());
|
|
||||||
let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml);
|
|
||||||
assert!(matched3.is_none(), "内容不同的 XML 不应该匹配");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
use std::path::Path;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use serde_json::json;
|
|
||||||
use mock_server::models::Payload;
|
|
||||||
use mock_server::loader::MockLoader;
|
|
||||||
use mock_server::router::MockRouter;
|
|
||||||
|
|
||||||
/// 加载 v1 目录的 mock 规则
|
|
||||||
fn load_v1_mocks() -> HashMap<String, Vec<mock_server::models::MockRule>> {
|
|
||||||
let v1_path = Path::new("./mocks/v1");
|
|
||||||
MockLoader::load_all_from_dir(v1_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块一:验证所有 JSON 文件正确加载 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_load_all_mocks() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
|
|
||||||
// 验证索引键存在
|
|
||||||
assert!(index.contains_key("v1"), "应包含 'v1' 索引键");
|
|
||||||
|
|
||||||
// 验证规则总数
|
|
||||||
let total: usize = index.values().map(|v| v.len()).sum();
|
|
||||||
assert!(total >= 10, "v1 目录应有至少 10 个 mock 规则");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块二:JSON Payload 测试 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_json_login() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Content-Type".to_string(), "application/json".to_string());
|
|
||||||
headers.insert("Authorization".to_string(), "eyJhbGciOiJIUzI1NiIsInR5cCI6".to_string());
|
|
||||||
headers.insert("host".to_string(), "127.0.0.1:8080".to_string());
|
|
||||||
|
|
||||||
let payload = Payload::Json(json!({
|
|
||||||
"username": "user001",
|
|
||||||
"password": "password123"
|
|
||||||
}));
|
|
||||||
|
|
||||||
let matched = router.match_rule("POST", "/v1/auth/login", &HashMap::new(), &headers, &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "JSON 登录应匹配成功");
|
|
||||||
let rule = matched.unwrap();
|
|
||||||
assert_eq!(rule.name, "user_login_001");
|
|
||||||
assert_eq!(rule.response.status, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_json_register() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Content-Type".to_string(), "application/json".to_string());
|
|
||||||
|
|
||||||
let payload = Payload::Json(json!({
|
|
||||||
"username": "newuser",
|
|
||||||
"password": "newpass123",
|
|
||||||
"email": "newuser@example.com"
|
|
||||||
}));
|
|
||||||
|
|
||||||
let matched = router.match_rule("POST", "/v1/auth/register", &HashMap::new(), &headers, &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "JSON 注册应匹配成功");
|
|
||||||
let rule = matched.unwrap();
|
|
||||||
assert_eq!(rule.name, "user_register");
|
|
||||||
assert_eq!(rule.response.status, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块三:Form Payload 测试 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_form_login() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Content-Type".to_string(), "application/x-www-form-urlencoded".to_string());
|
|
||||||
|
|
||||||
let mut form_data = HashMap::new();
|
|
||||||
form_data.insert("username".to_string(), "formuser".to_string());
|
|
||||||
form_data.insert("password".to_string(), "formpass".to_string());
|
|
||||||
let payload = Payload::Form(form_data);
|
|
||||||
|
|
||||||
let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "Form 登录应匹配成功");
|
|
||||||
assert_eq!(matched.unwrap().name, "_user_login_form");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块四:Text Payload 测试 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_text_echo() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Content-Type".to_string(), "text/plain".to_string());
|
|
||||||
|
|
||||||
let payload = Payload::Text("Hello V1 Mock Server".to_string());
|
|
||||||
|
|
||||||
let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "Text 回显应匹配成功");
|
|
||||||
let rule = matched.unwrap();
|
|
||||||
assert_eq!(rule.name, "user_echo");
|
|
||||||
assert!(rule.response.body.contains("Echo from V1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块五:XML Payload 测试 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_xml_export() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Content-Type".to_string(), "application/xml".to_string());
|
|
||||||
|
|
||||||
let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?><request><userId>10001</userId><format>xml</format></request>"#;
|
|
||||||
let payload = Payload::Xml(xml_body.to_string());
|
|
||||||
|
|
||||||
let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "XML 导出应匹配成功");
|
|
||||||
assert_eq!(matched.unwrap().name, "data_export");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块六:Multipart Payload 测试 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_multipart_upload() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Content-Type".to_string(), "multipart/form-data".to_string());
|
|
||||||
|
|
||||||
let mut multipart_data = HashMap::new();
|
|
||||||
multipart_data.insert("avatar1".to_string(), "file_content".to_string());
|
|
||||||
multipart_data.insert("description1".to_string(), "user avatar".to_string());
|
|
||||||
let payload = Payload::Multipart(multipart_data);
|
|
||||||
|
|
||||||
let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "Multipart 上传应匹配成功");
|
|
||||||
assert_eq!(matched.unwrap().name, "user_upload_avatar_001");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块七:None Payload 测试 (GET 无 Body) ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_health_check() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let payload = Payload::None;
|
|
||||||
|
|
||||||
let matched = router.match_rule("GET", "/v1/health", &HashMap::new(), &HashMap::new(), &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "健康检查应匹配成功");
|
|
||||||
let rule = matched.unwrap();
|
|
||||||
assert_eq!(rule.name, "health_check");
|
|
||||||
assert_eq!(rule.response.status, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_get_profile() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
|
|
||||||
|
|
||||||
let payload = Payload::None;
|
|
||||||
|
|
||||||
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "获取用户信息应匹配成功");
|
|
||||||
assert_eq!(matched.unwrap().name, "user_profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块八:Query Params 测试 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_query_params() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
let mut query = HashMap::new();
|
|
||||||
query.insert("format".to_string(), "json".to_string());
|
|
||||||
|
|
||||||
let payload = Payload::None;
|
|
||||||
|
|
||||||
let matched = router.match_rule("GET", "/v1/user/download", &query, &HashMap::new(), &payload);
|
|
||||||
|
|
||||||
assert!(matched.is_some(), "带 query params 的下载应匹配成功");
|
|
||||||
let rule = matched.unwrap();
|
|
||||||
assert_eq!(rule.name, "user_download");
|
|
||||||
assert!(rule.response.body.starts_with("file://"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 模块九:Header 匹配测试 ==========
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v1_header_required() {
|
|
||||||
let index = load_v1_mocks();
|
|
||||||
let router = MockRouter::new(index);
|
|
||||||
|
|
||||||
// 无 Authorization header 不应匹配
|
|
||||||
let payload = Payload::None;
|
|
||||||
let no_match = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &HashMap::new(), &payload);
|
|
||||||
assert!(no_match.is_none(), "无 Authorization 不应匹配");
|
|
||||||
|
|
||||||
// 有正确的 Authorization header 应匹配
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
|
|
||||||
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
|
|
||||||
assert!(matched.is_some(), "有 Authorization 应匹配");
|
|
||||||
}
|
|
||||||