Compare commits
6 Commits
5f3269bad5
...
feature-ms
| Author | SHA1 | Date | |
|---|---|---|---|
| 78fb8951ab | |||
| 9c1d0e16b4 | |||
| b579a835de | |||
| e218ab04fe | |||
| 18e0ccad58 | |||
| 3097a16465 |
76
.claude/CLAUDE.md
Normal 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)
|
||||||
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)
|
||||||
89
.claude/rules/commit-spec.md
Normal 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): 简化响应处理逻辑
|
||||||
|
|
||||||
|
将文件响应和内联响应的处理逻辑分离到独立函数中。
|
||||||
|
```
|
||||||
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` |
|
||||||
89
.claude/rules/mock-spec.md
Normal 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`
|
||||||
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(cargo search:*)",
|
||||||
|
"WebSearch"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
117
Cargo.lock
generated
@@ -60,6 +60,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
@@ -109,6 +115,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -271,12 +286,52 @@ dependencies = [
|
|||||||
"hashbrown",
|
"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]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.16"
|
version = "1.0.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
|
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]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -335,6 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -345,6 +401,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"notify",
|
||||||
|
"notify-debouncer-mini",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
@@ -354,8 +412,45 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -442,7 +537,7 @@ version = "0.5.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -451,7 +546,7 @@ version = "1.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -678,6 +773,15 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.5.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -905,6 +1009,15 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winres"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
|
||||||
|
dependencies = [
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ tracing-subscriber = "0.3.22"
|
|||||||
# 性能优化:快速哈希(可选,用于路由匹配)
|
# 性能优化:快速哈希(可选,用于路由匹配)
|
||||||
#dashmap = "7.0.0-rc2"
|
#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]
|
[dev-dependencies]
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.24.0"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
winres = "0.1"
|
||||||
75
README.md
@@ -1,11 +1,76 @@
|
|||||||
# mock-server
|
# 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
@@ -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
|
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 |
@@ -25,4 +25,4 @@ response:
|
|||||||
"msg": "success"
|
"msg": "success"
|
||||||
}
|
}
|
||||||
settings:
|
settings:
|
||||||
delay_ms: 200 # 模拟真实网络延迟
|
delay_ms: 2000 # 模拟真实网络延迟
|
||||||
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
|
||||||
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 检查
|
||||||
@@ -5,31 +5,29 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, RwLock}; // 必须引入 RwLock
|
||||||
use tokio_util::io::ReaderStream; // 需在 Cargo.toml 确认有 tokio-util
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
use crate::router::MockRouter;
|
use crate::router::MockRouter;
|
||||||
|
|
||||||
/// 共享的应用状态
|
/// 共享的应用状态,router 现在由 RwLock 保护以支持热重载
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub router: MockRouter,
|
pub router: RwLock<MockRouter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全局统一请求处理函数
|
/// 全局统一请求处理函数
|
||||||
pub async fn mock_handler(
|
pub async fn mock_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>, // State 必须是第一个或靠前的参数
|
||||||
method: Method,
|
method: Method,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Query(params): Query<HashMap<String, String>>,
|
||||||
req: Request<Body>,
|
req: Request<Body>, // Request<Body> 必须是最后一个参数
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// let path = req.uri().path();
|
// 1. 提取 path 和 method
|
||||||
// 1. 【关键】将需要的数据克隆出来,断开与 req 的借用关系
|
|
||||||
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();
|
||||||
// 1. 将 Axum HeaderMap 转换为简单的 HashMap 供 Router 使用
|
|
||||||
|
|
||||||
// 2. 现在可以安全地消耗 req 了,因为上面没有指向 req 内部的引用了
|
// 2. 读取请求 body(用于 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(_) => {
|
||||||
@@ -39,43 +37,43 @@ pub async fn mock_handler(
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let incoming_json: Option<serde_json::Value> = serde_json::from_slice(&body_bytes).ok();
|
let incoming_json: Option<serde_json::Value> = serde_json::from_slice(&body_bytes).ok();
|
||||||
|
|
||||||
|
// 3. 将 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() {
|
||||||
req_headers.insert(name.as_str().to_string(), v.to_string());
|
req_headers.insert(name.as_str().to_string(), v.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. 执行匹配逻辑
|
|
||||||
if let Some(rule) =
|
// 4. 执行匹配逻辑:先获取读锁 (Read Lock)
|
||||||
state
|
let maybe_rule = {
|
||||||
.router
|
let router = state.router.read().expect("Failed to acquire read lock");
|
||||||
.match_rule(&method_str, &path, ¶ms, &req_headers, &incoming_json)
|
router.match_rule(&method_str, &path, ¶ms, &req_headers, &incoming_json).cloned()
|
||||||
{
|
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
|
||||||
// 3. 处理模拟延迟
|
};
|
||||||
|
|
||||||
|
if let Some(rule) = maybe_rule {
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 构建响应基础信息
|
// 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);
|
||||||
|
|
||||||
// 注入 YAML 定义的 Header
|
|
||||||
if let Some(ref h) = rule.response.headers {
|
if let Some(ref h) = rule.response.headers {
|
||||||
for (k, v) in h {
|
for (k, v) in h {
|
||||||
// println!("{}:{}",k.clone(), v.clone());
|
|
||||||
response_builder = response_builder.header(k, v);
|
response_builder = response_builder.header(k, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 执行 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() {
|
||||||
// A. 文件模式:异步打开文件并转换为流,实现低内存占用
|
|
||||||
match tokio::fs::File::open(file_path).await {
|
match tokio::fs::File::open(file_path).await {
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
let stream = ReaderStream::new(file);
|
let stream = ReaderStream::new(file);
|
||||||
@@ -91,13 +89,12 @@ pub async fn mock_handler(
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// B. 内联模式:直接返回字符串内容
|
// 内联模式:直接返回字符串内容
|
||||||
response_builder
|
response_builder
|
||||||
.body(Body::from(rule.response.body.clone()))
|
.body(Body::from(rule.response.body.clone()))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("请求头{:?}", req_headers.clone());
|
|
||||||
// 匹配失败返回 404
|
// 匹配失败返回 404
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
|
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
|
||||||
pub mod config;
|
pub mod model;
|
||||||
pub mod loader;
|
pub mod loader;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path};
|
||||||
use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::config::{MockRule, MockSource}; // 假设 config.rs 中定义了这两个类型
|
use crate::model::{MockRule, MockSource}; // 假设 model 中定义了这两个类型
|
||||||
|
|
||||||
pub struct MockLoader;
|
pub struct MockLoader;
|
||||||
|
|
||||||
|
|||||||
64
src/main.rs
@@ -1,43 +1,79 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::Duration;
|
||||||
use axum::{routing::any, Router};
|
use axum::{routing::any, Router};
|
||||||
|
use notify_debouncer_mini::{new_debouncer, notify::*};
|
||||||
|
|
||||||
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};
|
||||||
|
|
||||||
|
/// 打印启动 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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// 1. 初始化日志(建议添加,方便观察加载情况)
|
print_banner();
|
||||||
// 需要在 Cargo.toml 添加 tracing-subscriber = "0.3"
|
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
// 2. 递归加载所有的 YAML 配置文件
|
|
||||||
let mocks_dir = Path::new("./mocks");
|
let mocks_dir = Path::new("./mocks");
|
||||||
if !mocks_dir.exists() {
|
if !mocks_dir.exists() {
|
||||||
println!("Warning: 'mocks/' directory not found. Creating it...");
|
|
||||||
std::fs::create_dir_all(mocks_dir).unwrap();
|
std::fs::create_dir_all(mocks_dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. 初始加载
|
||||||
println!("Scanning mocks directory...");
|
println!("Scanning mocks directory...");
|
||||||
let index = MockLoader::load_all_from_dir(mocks_dir);
|
let index = MockLoader::load_all_from_dir(mocks_dir);
|
||||||
|
|
||||||
// 3. 构建路由引擎并包装为共享状态
|
|
||||||
let router_engine = MockRouter::new(index);
|
|
||||||
let shared_state = Arc::new(AppState {
|
let shared_state = Arc::new(AppState {
|
||||||
router: router_engine,
|
router: RwLock::new(MockRouter::new(index)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 配置 Axum 路由
|
// 2. 设置热加载监听器
|
||||||
// 使用 any(mock_handler) 意味着它会接管所有 HTTP 方法和所有路径的请求
|
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()
|
let app = Router::new()
|
||||||
.fallback(any(mock_handler))
|
.fallback(any(mock_handler))
|
||||||
.with_state(shared_state);
|
.with_state(shared_state);
|
||||||
|
|
||||||
// 5. 启动服务
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
|
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
|
||||||
println!("🚀 Rust Mock Server is running on http://{}", addr);
|
println!("🚀 Server running at http://{}", addr);
|
||||||
println!("Ready to handle requests based on your YAML definitions.");
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
|||||||
@@ -52,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 {
|
||||||
@@ -74,3 +67,10 @@ impl MockResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 模拟器行为设置
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
|
pub struct MockSettings {
|
||||||
|
/// 模拟网络延迟(毫秒)
|
||||||
|
pub delay_ms: Option<u64>,
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::config::MockRule;
|
use crate::model::MockRule;
|
||||||
|
|
||||||
pub struct MockRouter {
|
pub struct MockRouter {
|
||||||
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
|
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::io::Write;
|
|||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
// 确保 Cargo.toml 中有 serde_json
|
// 确保 Cargo.toml 中有 serde_json
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use mock_server::config::{MockRule, MockSource};
|
use mock_server::model::{MockRule, MockSource};
|
||||||
use mock_server::loader::MockLoader;
|
use mock_server::loader::MockLoader;
|
||||||
use mock_server::router::MockRouter;
|
use mock_server::router::MockRouter;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::fs::{self, File};
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
// 假设你的项目名在 Cargo.toml 中叫 mock_server
|
// 假设你的项目名在 Cargo.toml 中叫 mock_server
|
||||||
use mock_server::config::MockSource;
|
use mock_server::model::MockSource;
|
||||||
use mock_server::loader::MockLoader;
|
use mock_server::loader::MockLoader;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||