9 Commits

Author SHA1 Message Date
78fb8951ab 临时提交 2026-03-26 19:20:22 +08:00
9c1d0e16b4 docs: 更新README并添加文件上传mock配置
- 移除文件上传相关文档(已从代码中移除)
   - 新增未来计划:动态参数、HTTPS、可视化管理界面
   - 添加 upload.yaml mock 配置文件
2026-03-25 17:40:34 +08:00
b579a835de docs: 重构项目文档结构
- 将 CLAUDE.md 移至 .claude/ 目录,精简为 AI 开发指南
- 扩充 README.md 为完整的用户文档
- 新增 rules/mock-spec.md: YAML 配置生成规范
- 新增 rules/commit-spec.md: Git 提交消息格式规范
- 从 .gitignore 移除 .claude/ 目录以便跟踪

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

76
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# Mock Server 开发指南
## 构建与测试命令
```bash
cargo build # 构建项目
cargo run # 启动服务 http://127.0.0.1:8080
cargo test # 运行所有测试
cargo test <pattern> # 运行匹配的测试(如 `cargo test router`
cargo clippy # 代码检查
cargo fmt # 格式化代码
```
## 架构
```
┌─────────────┐ ┌──────────────┐ ┌────────────────┐
│ loader.rs │───▶│ router.rs │───▶│ handler.rs │
│ YAML → 索引 │ │ HashMap 索引 │ │ 请求/响应处理 │
└─────────────┘ └──────────────┘ └────────────────┘
▲ │
│ ┌──────────────────┐ │
└─────────│ main.rs │◀─────────┘
热重载 │ AppState(RwLock)│
└──────────────────┘
```
**数据流:**
1. `MockLoader` 扫描 `mocks/` 目录,解析 YAML 为 `MockRule` 结构体
2. 规则按路径首段建立索引(如 `/api/users` → key: `api`)存储于 `MockRouter`
3. 请求匹配顺序method → path → query_params → headers → body
4. `AppState` 使用 `RwLock` 包装 router支持线程安全的热重载
## 核心类型 (config.rs)
- `MockRule`: 完整规则id, request, response, settings
- `RequestMatcher`: 请求匹配条件method, path, query_params, headers, body
- `MockResponse`: 响应配置status, headers, body
- `MockSource`: 枚举类型,支持单接口/多接口 YAML 格式
## 请求匹配 (router.rs)
- 路径首段 HashMap 查找O(1) 获取候选规则
- 候选集内线性扫描进行精确匹配
- Body 匹配:支持 JSON 对象和字符串两种比较方式
## 响应处理 (handler.rs)
- 内联 body直接返回
- `file://` 前缀:从磁盘流式读取文件(低内存占用)
- `settings.delay_ms`:模拟网络延迟
## 文件结构
```
src/
├── main.rs # 入口热重载监听Axum 路由配置
├── config.rs # 数据结构定义MockRule, RequestMatcher 等)
├── loader.rs # YAML 解析,目录扫描
├── router.rs # 路径首段索引,匹配逻辑
├── handler.rs # 统一请求处理器,文件流式响应
└── upload.rs # Multipart 文件上传处理
tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件
storage/ # 上传文件存储(按 YYYY-MM-DD 分目录)
```
## 开发注意事项
- **Rust Edition 2024**Axum 0.8.8
- 测试使用 `tempfile` crate 处理临时文件
- 请求体大小限制10MB (handler.rs:30)
- 热重载防抖时间200ms (main.rs:46)
- Header 匹配大小写不敏感 (router.rs:75)

View 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.

View 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

View File

@@ -0,0 +1,89 @@
---
paths:
- "**/*"
---
## Git 提交消息规范
AI 在生成 commit 消息时必须遵循以下格式。
### 格式
```
<类型>(<范围>): <描述>
[可选的详细说明]
[可选的脚注]
```
### 类型Type
| 类型 | 说明 |
|------|------|
| feat | 新功能feature |
| fix | 修复 bug |
| docs | 文档更新 |
| style | 代码格式(不影响功能的变动) |
| refactor | 代码重构(既非新功能也非 bug 修复) |
| test | 添加或修改测试 |
| chore | 构建系统或辅助工具的变动 |
| perf | 性能优化 |
### 范围Scope
描述提交影响的功能模块或组件(可选)。
### 描述Description
- 简明扼要描述变动内容
- 首字母小写
- 不超过 100 个字符
- 使用祈使句(如 "add" 而非 "added"
### 详细说明Body
- 提供更多上下文和详细说明(可选)
- 每行不超过 72 个字符
- 说明"做了什么"和"为什么"
### 脚注Footer
- 破坏性变动说明(如 `BREAKING CHANGE: ...`
- 关闭的 Issue`Closes #123`
### 示例
**新功能:**
```
feat(api): 添加用户登录端点
- 实现 JWT 认证逻辑
- 添加登录表单前端组件
- 更新用户服务处理登录请求
Closes #45
```
**Bug 修复:**
```
fix(router): 修复路径匹配大小写问题
路径匹配在某些情况下区分大小写导致无法正确路由到处理器。
```
**文档更新:**
```
docs: 更新 README 中的安装说明
```
**重构:**
```
refactor(handler): 简化响应处理逻辑
将文件响应和内联响应的处理逻辑分离到独立函数中。
```

View 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` |

View File

@@ -0,0 +1,89 @@
---
paths:
- "mocks/**/*.{yml,yaml}"
---
## YAML 配置规范
AI 在生成 Mock 规则时必须遵循以下格式。
### 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| id | string | 是 | 规则唯一标识 |
| request.method | string | 是 | HTTP 方法 (GET/POST/PUT/DELETE...) |
| request.path | string | 是 | 请求路径,精确匹配 |
| request.query_params | object | 否 | 查询参数匹配 |
| request.headers | object | 否 | 请求头匹配(大小写不敏感) |
| request.body | any | 否 | 请求体匹配,支持 JSON 对象或字符串 |
| response.status | number | 是 | HTTP 状态码 |
| response.headers | object | 否 | 响应头 |
| response.body | string | 是 | 响应体,支持 file:// 前缀 |
| settings.delay_ms | number | 否 | 延迟响应(毫秒) |
### 匹配规则
1. **路径精确匹配**(非前缀匹配)
2. **请求头匹配大小写不敏感**
3. **Body 匹配**支持 JSON 对象或字符串比较
### 配置示例
**单接口模式:**
```yaml
id: "login"
request:
method: "POST"
path: "/api/login"
body: { "username": "test" }
response:
status: 200
body: '{"token": "xxx"}'
settings:
delay_ms: 100
```
**多接口模式(数组):**
```yaml
- id: "get-users"
request: { method: "GET", path: "/api/users" }
response: { status: 200, body: '{"users": []}' }
- id: "create-user"
request: { method: "POST", path: "/api/users" }
response: { status: 201, body: '{"id": 1}' }
```
**带查询参数和请求头:**
```yaml
id: "search-users"
request:
method: "GET"
path: "/api/users"
query_params: { "role": "admin" }
headers: { "Authorization": "Bearer token" }
response:
status: 200
headers: { "X-Total-Count": "100" }
body: '{"users": []}'
```
**文件响应:**
```yaml
id: "download-pdf"
request:
method: "GET"
path: "/api/download"
response:
status: 200
body: "file://./data/large-file.pdf"
```
> `file://` 支持两种路径:
> - 相对路径:`file://./data/file.pdf`(相对于项目根目录)
> - 绝对路径:`file:///C:/path/to/file.pdf`

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(cargo search:*)",
"WebSearch"
]
}
}

307
Cargo.lock generated
View File

@@ -60,6 +60,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -84,6 +90,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -93,6 +115,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -145,6 +176,18 @@ dependencies = [
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -243,18 +286,70 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.10.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "itoa"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -295,21 +390,74 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "mock-server"
name = "mock_server"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"notify",
"notify-debouncer-mini",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"walkdir",
"winres",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.10.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-mini"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a689eb4262184d9a1727f9087cd03883ea716682ab03ed24efec57d7716dccb8"
dependencies = [
"log",
"notify",
"notify-types",
"tempfile",
]
[[package]]
name = "notify-types"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
@@ -377,13 +525,32 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.10.0",
]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -392,6 +559,15 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -477,6 +653,15 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.7"
@@ -525,6 +710,28 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tempfile"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tokio"
version = "1.48.0"
@@ -566,6 +773,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -602,9 +818,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -612,6 +840,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -626,12 +880,46 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -721,6 +1009,21 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "zmij"
version = "0.1.8"

View File

@@ -1,5 +1,5 @@
[package]
name = "mock-server"
name = "mock_server"
version = "0.1.0"
edition = "2024"
@@ -18,11 +18,21 @@ serde_yaml = "0.9.34+deprecated"
serde_json = "1.0.147"
# 物理目录递归扫描工具
#walkdir = "2.5.0"
walkdir = "2.5.0"
tracing="0.1.44"
tracing-subscriber = "0.3.22"
# 性能优化:快速哈希(可选,用于路由匹配)
#dashmap = "7.0.0-rc2"
# 热加载支持(扩展功能)
#notify = "8.2.0"
notify = "8.2.0"
notify-debouncer-mini = "0.6.0"
# 路径处理
#pathdiff = "0.2.3"
#pathdiff = "0.2.3"
[dev-dependencies]
tempfile = "3.24.0"
[build-dependencies]
winres = "0.1"

View File

@@ -1,11 +1,76 @@
# mock-server
基于Rust/Axum的配置驱动型Mock服务支持YAML配置、请求匹配、延迟响应、大文件流式返回等特性。
基于 Rust/Axum 的配置驱动型 Mock 服务,支持 YAML 配置、请求匹配、热重载、延迟响应、大文件流式返回等特性。
## 特性
- 配置驱动YAML定义API行为无需修改代码
- 高性能基于Rust异步运行时哈希索引匹配请求
- 低内存:大响应体支持磁盘文件流式读取,不占用常驻内存
- 易扩展:模块化设计,支持动态占位符、热加载(规划中)
- **配置驱动**YAML 定义 API 行为,无需修改代码
- **热重载**`mocks/*.yaml` 变更自动生效,无需重启服务
- **高性能**:基于 Rust 异步运行时,路径首段哈希索引 O(1) 匹配
- **低内存**:大响应体支持 `file://` 协议从磁盘流式读取
- **请求匹配**:支持 method、path、headers、query_params、body 多维度匹配
## 快速开始
### 1. 安装依赖
### 1. 安装依赖
确保已安装 Rust 工具链:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### 2. 构建与运行
```bash
cargo build # 构建项目
cargo run # 启动服务 http://127.0.0.1:8080
cargo test # 运行所有测试
cargo test <pattern> # 运行匹配的测试(如 cargo test router
cargo clippy # 代码检查
cargo fmt # 格式化代码
```
## 文件结构
```
src/
├── main.rs # 入口热重载监听Axum 路由配置
├── config.rs # 数据结构定义MockRule, RequestMatcher 等)
├── loader.rs # YAML 解析,目录扫描
├── router.rs # 路径首段索引,匹配逻辑
└── handler.rs # 统一请求处理器,文件流式响应
tests/ # 集成测试(每个模块一个测试文件)
mocks/ # YAML Mock 配置文件
```
## Mock 配置
详细配置规范请参考 [.claude/rules/mock-spec.md](.claude/rules/mock-spec.md)
## 未来计划
### 1. 动态参数与模板响应
支持路径参数捕获(如 `/api/users/{id}`)和响应模板,可在响应中引用请求参数:
```yaml
# 请求: GET /api/users/123
# 响应: { "id": "123", "name": "User 123" }
```
### 2. HTTPS 支持
内置 TLS/SSL 支持,提供安全的 HTTPS 服务。
### 3. 可视化管理界面
- **TUI终端界面**:基于 Ratatui 的交互式终端管理
- **GUI图形界面**Web Dashboard 或桌面应用,可视化管理 Mock 规则
> 此功能需要深入讨论,欢迎提出建议和需求
## 许可证
MIT

29
build.rs Normal file
View 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);
}
}
}

BIN
icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
icons/128x128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
icons/Square107x107Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
icons/Square142x142Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
icons/Square150x150Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
icons/Square284x284Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
icons/Square30x30Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
icons/Square310x310Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
icons/Square44x44Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
icons/Square71x71Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
icons/Square89x89Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
icons/StoreLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View 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

Binary file not shown.

BIN
icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

28
mocks/v1/auth/login.yaml Normal file
View 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 # 模拟真实网络延迟

View 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 # 模拟真实网络延迟

View 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"

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

11
mocks/v1/user/search.yaml Normal file
View 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
View 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
View 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, &params, &req_headers, &parsed_body).cloned()
};
// 6. 构建响应(与现有逻辑相同)
// ...
}
```
### Step 4: 修改 router.rs 匹配逻辑
```rust
/// 核心匹配函数
pub fn match_rule(
&self,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
parsed_body: &ParsedBody, // 改为 ParsedBody
) -> Option<&MockRule> {
let key = self.extract_first_segment(path);
if let Some(rules) = self.index.get(&key) {
for rule in rules {
if self.is_match(rule, method, path, queries, headers, parsed_body) {
return Some(rule);
}
}
}
None
}
fn is_match(
&self,
rule: &MockRule,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
parsed_body: &ParsedBody,
) -> bool {
// A. Method 匹配
if rule.request.method.to_uppercase() != method.to_uppercase() {
return false;
}
// B. Path 匹配
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
return false;
}
// C. Query 匹配(子集匹配)
if let Some(ref required_queries) = rule.request.query_params {
for (key, val) in required_queries {
if queries.get(key) != Some(val) {
return false;
}
}
}
// D. Header 匹配(包含 Content-Type
// YAML 写了就匹配,没写就跳过
if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers {
let matched = headers.iter().any(|(k, v)| {
k.to_lowercase() == key.to_lowercase() && v == val
});
if !matched {
return false; // Header 不匹配,包括 Content-Type
}
}
}
// E. Body 匹配
// YAML 写了 body 才匹配,没写跳过
if let Some(ref yaml_body) = rule.request.body {
return self.match_body(yaml_body, parsed_body);
}
true
}
/// Body 匹配逻辑
fn match_body(&self, yaml_body: &serde_json::Value, parsed_body: &ParsedBody) -> bool {
match parsed_body {
ParsedBody::Json(actual) => {
// JSON 对象比较
yaml_body == actual
}
ParsedBody::Xml(actual) => {
// XML 字符串比较
yaml_body.as_str()
.map(|expected| expected.trim() == actual.trim())
.unwrap_or(false)
}
ParsedBody::Form(actual) => {
// Form 键值对比较(子集匹配)
compare_form_with_yaml(yaml_body, actual)
}
ParsedBody::Multipart(actual_fields) => {
// Multipart 字段名比较
compare_multipart_with_yaml(yaml_body, actual_fields)
}
ParsedBody::Text(actual) => {
// 字符串比较
yaml_body.as_str()
.map(|expected| expected.trim() == actual.trim())
.unwrap_or_else(|| yaml_body.to_string().trim() == actual.trim())
}
ParsedBody::None => {
false // YAML 配置了 body但请求没有 body
}
}
}
/// Form 比较YAML 中的键值对必须是请求的子集
fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
let yaml_map = match yaml_body.as_object() {
Some(obj) => obj,
None => return false,
};
for (key, yaml_val) in yaml_map {
let expected = yaml_val.as_str().unwrap_or(&yaml_val.to_string());
if actual.get(key) != Some(&expected.to_string()) {
return false;
}
}
true
}
```
---
## 需要修改的文件
| 文件 | 改动 |
|------|------|
| `src/model.rs` | 新增 `ParsedBody` 枚举和相关方法 |
| `src/handler.rs` | 新增 `extract_content_type()``parse_body()`、修改 `mock_handler()` |
| `src/router.rs` | 修改 `match_rule()``is_match()` 参数,新增 `match_body()` |
| `Cargo.toml` | 可能需要 `urlencoding` 依赖 |
---
## YAML 配置示例
```yaml
# 严格匹配:要求 Content-Type + body
- id: login-strict
request:
method: POST
path: /api/login
headers:
Content-Type: application/json
body:
username: admin
password: "123456"
# 宽松匹配:只匹配 method + path + body不检查 Content-Type
- id: login-loose
request:
method: POST
path: /api/login
body:
username: admin
# 最宽松:只匹配 method + path
- id: any-body
request:
method: POST
path: /api/echo
```
---
## 验证
1. `cargo test`
2. 手动测试:
- YAML 配置 `Content-Type: application/json`,请求发送 JSON → 匹配
- YAML 配置 `Content-Type: application/xml`,请求发送 JSON → Header 不匹配
- YAML 无 Content-Type请求发送 XML → Body 字符串比较
- YAML 无 body 配置 → 跳过 body 检查

104
src/handler.rs Normal file
View File

@@ -0,0 +1,104 @@
use axum::{
body::Body,
extract::{Query, State},
http::{HeaderMap, Method, Request, StatusCode},
response::{IntoResponse, Response},
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock}; // 必须引入 RwLock
use tokio_util::io::ReaderStream;
use crate::router::MockRouter;
/// 共享的应用状态router 现在由 RwLock 保护以支持热重载
pub struct AppState {
pub router: RwLock<MockRouter>,
}
/// 全局统一请求处理函数
pub async fn mock_handler(
State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数
method: Method,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
req: Request<Body>, // Request<Body> 必须是最后一个参数
) -> impl IntoResponse {
// 1. 提取 path 和 method
let path = req.uri().path().to_string();
let method_str = method.as_str().to_string();
// 2. 读取请求 body用于 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();
}
};
let incoming_json: Option<serde_json::Value> = serde_json::from_slice(&body_bytes).ok();
// 3. 将 Axum HeaderMap 转换为简单的 HashMap
let mut req_headers = HashMap::new();
for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() {
req_headers.insert(name.as_str().to_string(), v.to_string());
}
}
// 4. 执行匹配逻辑:先获取读锁 (Read Lock)
let maybe_rule = {
let router = state.router.read().expect("Failed to acquire read lock");
router.match_rule(&method_str, &path, &params, &req_headers, &incoming_json).cloned()
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
};
if let Some(rule) = maybe_rule {
// 5. 处理模拟延迟
if let Some(ref settings) = rule.settings {
if let Some(delay) = settings.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
// 6. 构建响应
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
let mut response_builder = Response::builder().status(status);
if let Some(ref h) = rule.response.headers {
for (k, v) in h {
response_builder = response_builder.header(k, v);
}
}
// 7. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() {
match tokio::fs::File::open(file_path).await {
Ok(file) => {
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
response_builder.body(body).unwrap()
}
Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(format!(
"Mock Error: File not found at {}",
file_path
)))
.unwrap(),
}
} else {
// 内联模式:直接返回字符串内容
response_builder
.body(Body::from(rule.response.body.clone()))
.unwrap()
}
} else {
// 匹配失败返回 404
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("No mock rule matched this request"))
.unwrap()
}
}

5
src/lib.rs Normal file
View File

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

58
src/loader.rs Normal file
View File

@@ -0,0 +1,58 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path};
use walkdir::WalkDir;
use crate::model::{MockRule, MockSource}; // 假设 model 中定义了这两个类型
pub struct MockLoader;
impl MockLoader {
/// 递归扫描指定目录并构建索引表
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
// 1. 使用 walkdir 递归遍历目录,不限层级
for entry in WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml"))
{
if let Some(rules) = Self::parse_yaml_file(entry.path()) {
for rule in rules {
// 2. 提取路径首段作为索引 Key
let key = Self::extract_first_segment(&rule.request.path);
// 3. 将规则插入到对应的索引桶中
index.entry(key).or_insert_with(Vec::new).push(rule);
}
}
}
println!("Successfully loaded {} segments from {:?}", index.len(), dir);
index
}
/// 解析单个 YAML 文件,支持单接口和多接口模式
fn parse_yaml_file(path: &Path) -> Option<Vec<MockRule>> {
let content = fs::read_to_string(path).ok()?;
// 利用 serde_yaml 的反序列化能力处理 MockSource 枚举
match serde_yaml::from_str::<MockSource>(&content) {
Ok(source) => Some(source.flatten()), // 统一打平为 Vec<MockRule>
Err(e) => {
eprintln!("Failed to parse YAML at {:?}: {}", path, e);
None
}
}
}
/// 提取路径的第一级作为 Key例如 "/api/v1/login" -> "api"
fn extract_first_segment(path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root") // 如果是根路径 "/",则归类到 "root"
.to_string()
}
}

View File

@@ -1,3 +1,80 @@
fn main() {
println!("Hello, mock-server!");
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use axum::{routing::any, Router};
use notify_debouncer_mini::{new_debouncer, notify::*};
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use mock_server::handler::{mock_handler, AppState};
/// 打印启动 Banner
fn print_banner() {
let version = env!("CARGO_PKG_VERSION");
// 蓝色 ANSI 转义码
println!("\x1b[34m");
println!(" ███╗ ███╗ ██████╗██████╗ ██████╗ ");
println!(" ████╗ ████║██╔════╝██╔══██╗██╔═══██╗");
println!(" ██╔████╔██║██║ ██████╔╝██║ ██║");
println!(" ██║╚██╔╝██║██║ ██╔══██╗██║ ██║");
println!(" ██║ ╚═╝ ██║╚██████╗██║ ██║╚██████╔╝");
println!(" ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ");
println!("\x1b[0m"); // 重置颜色
println!(" Mock Server v{}\n", version);
}
#[tokio::main]
async fn main() {
print_banner();
tracing_subscriber::fmt::init();
let mocks_dir = Path::new("./mocks");
if !mocks_dir.exists() {
std::fs::create_dir_all(mocks_dir).unwrap();
}
// 1. 初始加载
println!("Scanning mocks directory...");
let index = MockLoader::load_all_from_dir(mocks_dir);
let shared_state = Arc::new(AppState {
router: RwLock::new(MockRouter::new(index)),
});
// 2. 设置热加载监听器
let state_for_watcher = shared_state.clone();
let watch_path = mocks_dir.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
// 200ms 防抖,防止编辑器保存文件时产生多次干扰
let mut debouncer = new_debouncer(Duration::from_millis(200), tx).unwrap();
debouncer.watcher().watch(&watch_path, RecursiveMode::Recursive).unwrap();
// 启动异步任务监听文件变动
tokio::spawn(async move {
while let Ok(res) = rx.recv() {
match res {
Ok(_) => {
println!("🔄 Detecting changes in mocks/, reloading...");
let new_index = MockLoader::load_all_from_dir(&watch_path);
// 获取写锁 (Write Lock) 更新索引
let mut writer = state_for_watcher.router.write().expect("Failed to acquire write lock");
*writer = MockRouter::new(new_index);
println!("✅ Mocks reloaded successfully.");
}
Err(e) => eprintln!("Watcher error: {:?}", e),
}
}
});
// 3. 配置 Axum 路由
let app = Router::new()
.fallback(any(mock_handler))
.with_state(shared_state);
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
println!("🚀 Server running at http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

76
src/model.rs Normal file
View File

@@ -0,0 +1,76 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// 顶层包装:支持单对象或数组,自动打平
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum MockSource {
/// 对应“一个接口一个文件”模式
Single(MockRule),
/// 对应“一个文件多个接口”模式
Multiple(Vec<MockRule>),
}
impl MockSource {
/// 将不同的解析模式统一转化为列表,供 Loader 构建索引
pub fn flatten(self) -> Vec<MockRule> {
match self {
Self::Single(rule) => vec![rule],
Self::Multiple(rules) => rules,
}
}
}
/// 核心 Mock 规则定义
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule {
pub id: String,
pub request: RequestMatcher,
pub response: MockResponse,
pub settings: Option<MockSettings>,
}
/// 请求匹配条件
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct RequestMatcher {
pub method: String,
pub path: String,
/// 选填:只有请求包含这些参数时才匹配
pub query_params: Option<HashMap<String, String>>,
/// 选填:只有请求包含这些 Header 时才匹配
pub headers: Option<HashMap<String, String>>,
// 修改点:从 String 改为 Option<serde_json::Value>
pub body: Option<serde_json::Value>,
}
/// 响应内容定义
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockResponse {
pub status: u16,
pub headers: Option<HashMap<String, String>>,
/// 统一字段:支持 Inline 文本或 file:// 前缀的路径
pub body: String,
}
impl MockResponse {
/// 辅助方法:判断是否为文件协议
pub fn is_file_protocol(&self) -> bool {
self.body.starts_with("file://")
}
/// 获取去掉协议前缀后的纯物理路径
pub fn get_file_path(&self) -> Option<&str> {
if self.is_file_protocol() {
Some(&self.body[7..]) // 截取 "file://" 之后的内容
} else {
None
}
}
}
/// 模拟器行为设置
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockSettings {
/// 模拟网络延迟(毫秒)
pub delay_ms: Option<u64>,
}

116
src/router.rs Normal file
View File

@@ -0,0 +1,116 @@
use std::collections::HashMap;
use crate::model::MockRule;
pub struct MockRouter {
// 索引表Key 是路径首段(如 "api"Value 是该段下的所有 Mock 规则
pub index: HashMap<String, Vec<MockRule>>,
}
impl MockRouter {
pub fn new(index: HashMap<String, Vec<MockRule>>) -> Self {
Self { index }
}
/// 核心匹配函数:根据请求信息寻找匹配的 Mock 规则
pub fn match_rule(
&self,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 1: 增加参数
) -> Option<&MockRule> {
// 1. 提取请求路径的首段作为索引 Key
let key = self.extract_first_segment(path);
println!("DEBUG: Request Key: '{}', Available Keys: {:?}", key, self.index.keys());
// 2. 从 HashMap 中快速定位候选规则列表 (O(k) 复杂度)
if let Some(rules) = self.index.get(&key) {
// 3. 在候选集中进行线性深度匹配
for rule in rules {
if self.is_match(rule, method, path, queries, headers,incoming_body) {
return Some(rule);
}
}
}
None
}
/// 细粒度匹配逻辑
fn is_match(
&self,
rule: &MockRule,
method: &str,
path: &str,
queries: &HashMap<String, String>,
headers: &HashMap<String, String>,
incoming_body: &Option<serde_json::Value>, // 修改 3: 增加参数
) -> bool {
// A. 基础校验Method 和 Path 必须完全一致 (忽略末尾斜杠)
if rule.request.method.to_uppercase() != method.to_uppercase() {
println!("DEBUG: [ID:{}] Method Mismatch: YAML={}, Req={}", rule.id, rule.request.method, method);
return false;
}
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
println!("DEBUG: [ID:{}] Path Mismatch: YAML='{}', Req='{}'", rule.id, rule.request.path, path);
return false;
}
println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id);
// B. Query 参数校验 (子集匹配原则)
if let Some(ref required_queries) = rule.request.query_params {
for (key, val) in required_queries {
if queries.get(key) != Some(val) {
return false;
}
}
}
// C. Header 校验 (优化版)
if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers {
println!("{}:{}",key.clone(), val.clone());
// 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值
let matched = headers.iter().any(|(k, v)| {
// k.to_lowercase() == key.to_lowercase() && v.contains(val)
k.to_lowercase() == key.to_lowercase() && v==val
});
if !matched {
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()
};
// 执行全量相等比对
if final_required != *actual_val {
println!("DEBUG: [ID:{}] Body Mismatch", rule.id);
return false;
}
}
None => return false, // YAML 要求有 Body 但请求为空
}
}
true
}
/// 与 Loader 保持一致的 Key 提取算法
fn extract_first_segment(&self, path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string()
}
}

160
tests/integration_test.rs Normal file
View File

@@ -0,0 +1,160 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
// 确保 Cargo.toml 中有 serde_json
use serde_json::json;
use mock_server::model::{MockRule, MockSource};
use mock_server::loader::MockLoader;
use mock_server::router::MockRouter;
use std::collections::HashMap;
/// 模块一:验证 Config 反序列化逻辑
#[test]
fn test_config_parsing_scenarios() {
// 场景 A: 验证单接口配置 (增加 body 结构化校验)
let yaml_single = r#"
id: "auth_v1"
request:
method: "POST"
path: "/api/v1/login"
body: { "user": "admin" }
response: { status: 200, body: "welcome" }
"#;
let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
let rules = source_s.flatten();
assert_eq!(rules.len(), 1);
// 验证 body 是否被成功解析为 Value::Object
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]
fn test_loader_recursive_indexing() {
let temp_root = tempdir().expect("无法创建临时目录");
let root_path = temp_root.path();
let auth_path = root_path.join("v1/auth");
fs::create_dir_all(&auth_path).unwrap();
let mut f1 = File::create(auth_path.join("login.yaml")).unwrap();
writeln!(f1, "id: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap();
let mut f2 = File::create(root_path.join("sys.yaml")).unwrap();
writeln!(f2, "- id: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap();
let index = MockLoader::load_all_from_dir(root_path);
assert!(index.contains_key("api"));
assert!(index.contains_key("health"));
let total: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total, 2);
}
#[test]
fn test_router_matching_logic() {
let mut index = HashMap::new();
// 1. 准备带有 Body 的规则
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. 测试场景 BBody 不匹配
let wrong_body = Some(json!({ "code": 456 }));
let matched_fail = router.match_rule(
"POST",
"/api/v1/login",
&HashMap::new(),
&headers,
&wrong_body
);
assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
// 4. 测试场景 C智能字符串转换验证 (YAML 是字符串,请求是对象)
let rule_str_body = serde_yaml::from_str::<MockSource>(
r#"
id: "str_match"
request:
method: "POST"
path: "/api/str"
body: '{"type": "json_in_string"}' # 这里 YAML 解析为 Value::String
response: { status: 200, body: "ok" }
"#).unwrap().flatten();
let mut index2 = HashMap::new();
index2.insert("api".to_string(), vec![rule_str_body[0].clone()]);
let router2 = MockRouter::new(index2);
let incoming_obj = Some(json!({ "type": "json_in_string" })); // 请求是 JSON 对象
let matched_str = router2.match_rule(
"POST",
"/api/str",
&HashMap::new(),
&HashMap::new(),
&incoming_obj
);
assert!(matched_str.is_some(), "应该支持将 YAML 字符串 body 转换为对象进行匹配");
// 5. 测试场景 D末尾斜杠兼容性测试
let matched_slash = router.match_rule(
"POST",
"/api/v1/login/",
&HashMap::new(),
&headers,
&incoming_body
);
assert!(matched_slash.is_some());
}

114
tests/loader_test.rs Normal file
View File

@@ -0,0 +1,114 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
// 假设你的项目名在 Cargo.toml 中叫 mock_server
use mock_server::model::MockSource;
use mock_server::loader::MockLoader;
#[test]
fn test_config_deserialization() {
// 测试 1验证单接口 YAML 解析
let single_yaml = r#"
id: "auth_v1"
request:
method: "POST"
path: "/api/v1/login"
response:
status: 200
body: "inline_content"
"#;
let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口");
assert_eq!(res.flatten().len(), 1);
// assert_eq!(res.flatten()[0].id, "auth_v1");
// 测试 2验证多接口 YAML 数组解析
let multi_yaml = r#"
- id: "api_1"
request: { method: "GET", path: "/1" }
response: { status: 200, body: "b1" }
- id: "api_2"
request: { method: "GET", path: "/2" }
response: { status: 200, body: "b2" }
"#;
let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
assert_eq!(res_multi.flatten().len(), 2);
}
#[test]
fn test_recursive_loading_logic() {
// 创建临时 Mock 目录结构
let root_dir = tempdir().expect("创建临时目录失败");
let root_path = root_dir.path();
// 构造物理层级mocks/v1/user/
let user_dir = root_path.join("v1/user");
fs::create_dir_all(&user_dir).unwrap();
// 在深层目录创建单接口文件
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 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);
// 断言结果:
// 1. 检查 Key 是否根据路径首段正确提取(/api/v1/... -> api, /health -> health
assert!(index.contains_key("api"), "索引应包含 'api' 键");
assert!(index.contains_key("health"), "索引应包含 'health' 键");
assert!(index.contains_key("version"), "索引应包含 'version' 键");
// 2. 检查规则总数
let total_rules: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total_rules, 3, "总规则数应为 3");
// 3. 检查深层文件是否被正确读取
let api_rules = &index["api"];
assert_eq!(api_rules[0].id, "user_profile");
}