5 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
83 changed files with 1287 additions and 2158 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"
]
}
}

6
.gitignore vendored
View File

@@ -12,9 +12,3 @@
# 日志文件 # 日志文件
logs/ logs/
*.log *.log
# 上传文件存储目录
storage/
# Claude Code 配置目录
.claude/

228
CLAUDE.md
View File

@@ -1,228 +0,0 @@
# Mock Server 项目指南
## 项目概述
基于 Rust + Axum 的配置驱动型 Mock 服务,支持 YAML 配置、请求匹配、延迟响应、大文件流式返回和文件上传等特性。
## 技术栈
- **语言**: Rust (Edition 2024)
- **Web 框架**: Axum 0.8.8 + axum-extra (multipart 支持)
- **异步运行时**: Tokio
- **序列化**: serde + serde_yaml + serde_json
- **工具库**: walkdir, uuid, chrono, tokio-util
## 项目结构
```
mock-server/
├── src/
│ ├── main.rs # 程序入口,路由配置,服务启动
│ ├── lib.rs # 库模块导出
│ ├── config.rs # 配置结构定义 (MockRule, MockResponse, MockSettings)
│ ├── loader.rs # YAML 配置加载器,递归扫描 mocks 目录
│ ├── router.rs # 路由匹配引擎,基于路径首段索引
│ ├── handler.rs # 请求处理器,统一处理所有 Mock 请求
│ └── upload.rs # 文件上传处理模块
├── tests/ # 集成测试
│ ├── config_test.rs # 配置模块测试
│ ├── handler_test.rs # 请求处理测试
│ ├── integration_test.rs # 集成测试
│ ├── loader_test.rs # 加载器测试
│ ├── router_test.rs # 路由匹配测试
│ └── upload_test.rs # 文件上传测试
├── mocks/ # YAML Mock 配置文件目录
├── storage/ # 上传文件存储目录 (按日期分类: YYYY-MM-DD)
└── Cargo.toml
```
## 核心模块说明
### config.rs - 配置结构
定义 Mock 规则的数据结构:
- `MockRule`: 完整的 Mock 规则id, request, response, settings
- `MockRequest`: 请求匹配条件method, path, query_params, headers, body
- `MockResponse`: 响应配置status, headers, body
- `MockSettings`: 额外设置delay_ms 延迟)
- `MockSource`: 支持单接口和多接口 YAML 格式
**文件协议**: body 以 `file://` 开头时,从磁盘流式读取文件
### loader.rs - 配置加载器
- `MockLoader::load_all_from_dir()`: 递归扫描目录下的 .yaml/.yml 文件
- 按路径首段建立索引(如 `/api/users` -> key: `api`
- 支持单接口和多接口两种 YAML 格式
### router.rs - 路由匹配引擎
- 基于路径首段的 HashMap 快速索引
- 线性深度匹配method -> path -> query_params -> headers -> body
- 支持大小写不敏感的方法匹配
- 支持尾部斜杠忽略
### handler.rs - 请求处理器
- 统一处理所有 HTTP 方法和路径
- 支持延迟响应settings.delay_ms
- 支持文件流式响应(低内存占用)
- 匹配失败返回 404
### upload.rs - 文件上传
- 路由: `POST /api/upload`
- 支持 multipart/form-data 格式
- 文件存储: `storage/YYYY-MM-DD/uuid.extension`
- 返回 JSON 格式的文件信息
## 常用命令
```bash
# 构建项目
cargo build
# 运行项目
cargo run
# 运行所有测试
cargo test
# 运行特定测试
cargo test test_name
# 检查代码
cargo check
# 格式化代码
cargo fmt
# 代码检查
cargo clippy
```
## YAML 配置示例
### 单接口模式
```yaml
id: "user-login"
request:
method: "POST"
path: "/api/v1/login"
query_params:
redirect: "/dashboard"
headers:
Content-Type: "application/json"
body:
username: "test"
password: "123456"
response:
status: 200
headers:
Content-Type: "application/json"
body: '{"code": 0, "message": "success", "data": {"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: "download-file"
request:
method: "GET"
path: "/api/download"
response:
status: 200
headers:
Content-Type: "application/pdf"
body: "file://./data/document.pdf"
```
## 开发规范
### 代码风格
- 使用 Rust 标准命名约定snake_case for functions/variables, PascalCase for types
- 公开函数添加文档注释 `///`
- 错误处理使用 `Result``Option`
- 异步函数使用 `async/await`
### 测试规范
- 每个模块对应一个测试文件
- 测试函数命名:`test_<模块>_<场景>`
- 使用 `tempfile` crate 处理临时文件
- 测试应该独立运行,不依赖执行顺序
### Git 提交规范
```
feat: 新功能
fix: 修复 bug
docs: 文档更新
test: 测试相关
refactor: 代码重构
chore: 构建/工具变更
```
## API 端点
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/upload` | POST | 文件上传 |
| `/*` | ANY | Mock 请求处理(由 YAML 配置定义) |
## 文件上传响应格式
```json
{
"success": true,
"files": [
{
"original_name": "document.pdf",
"stored_name": "550e8400-e29b-41d4-a716-446655440000.pdf",
"path": "storage/2026-03-19/550e8400-e29b-41d4-a716-446655440000.pdf",
"size": 1024,
"content_type": "application/pdf"
}
],
"message": "Files uploaded successfully"
}
```
## 扩展功能规划
- [ ] 热加载:配置文件变更自动重载
- [ ] 动态占位符:支持 `{{timestamp}}`, `{{uuid}}`
- [ ] 管理界面Web UI 管理 Mock 规则
- [ ] 请求录制:自动生成 Mock 配置
- [ ] 条件匹配:基于请求体的复杂匹配规则
## 注意事项
1. **文件协议**: 使用 `file://` 时确保文件路径正确
2. **延迟响应**: 仅用于测试,生产环境请移除
3. **上传目录**: 确保 `storage/` 目录有写入权限
4. **内存限制**: 请求体限制为 10MB

526
Cargo.lock generated
View File

@@ -2,33 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.8" version = "0.8.8"
@@ -81,30 +60,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [
"axum",
"axum-core",
"bytes",
"fastrand",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"multer",
"pin-project-lite",
"rustversion",
"serde_core",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -117,62 +72,18 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -195,18 +106,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -285,44 +184,16 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 5.3.0", "r-efi",
"wasip2", "wasip2",
] ]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -405,36 +276,6 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.12.1" version = "2.12.1"
@@ -442,9 +283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.1", "hashbrown",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -473,16 +312,6 @@ 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 = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]] [[package]]
name = "kqueue" name = "kqueue"
version = "1.1.1" version = "1.1.1"
@@ -509,12 +338,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.178" version = "0.2.178"
@@ -577,8 +400,6 @@ name = "mock_server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"axum-extra",
"chrono",
"futures-util", "futures-util",
"notify", "notify",
"notify-debouncer-mini", "notify-debouncer-mini",
@@ -590,25 +411,8 @@ dependencies = [
"tokio-util", "tokio-util",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
"walkdir", "walkdir",
] "winres",
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
] ]
[[package]] [[package]]
@@ -656,15 +460,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@@ -712,16 +507,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.103" version = "1.0.103"
@@ -746,12 +531,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -774,12 +553,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.21" version = "1.0.21"
@@ -801,12 +574,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -895,12 +662,6 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.7" version = "1.4.7"
@@ -932,12 +693,6 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.111" version = "2.0.111"
@@ -962,7 +717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -1018,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"
@@ -1110,42 +874,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "uuid"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -1168,95 +908,7 @@ version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [ dependencies = [
"wit-bindgen 0.46.0", "wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.10.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
] ]
[[package]] [[package]]
@@ -1268,65 +920,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"
@@ -1410,100 +1009,21 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.10.0",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "0.1.8" version = "0.1.8"

View File

@@ -6,7 +6,6 @@ edition = "2024"
[dependencies] [dependencies]
# 核心 Web 框架 # 核心 Web 框架
axum = "0.8.8" axum = "0.8.8"
axum-extra = { version = "0.10", features = ["multipart"] }
# 异步运行时 # 异步运行时
tokio={version = "1.48.0",features = ["full"]} tokio={version = "1.48.0",features = ["full"]}
# 异步文件操作与流处理工具 # 异步文件操作与流处理工具
@@ -21,12 +20,6 @@ serde_json = "1.0.147"
# 物理目录递归扫描工具 # 物理目录递归扫描工具
walkdir = "2.5.0" walkdir = "2.5.0"
# UUID 生成(用于唯一文件名)
uuid = { version = "1.0", features = ["v4", "serde"] }
# 日期时间处理
chrono = "0.4"
tracing="0.1.44" tracing="0.1.44"
tracing-subscriber = "0.3.22" tracing-subscriber = "0.3.22"
@@ -39,4 +32,7 @@ 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"

View File

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

View File

@@ -1,18 +0,0 @@
id: "user-login"
request:
method: "POST"
path: "/api/v1/login"
query_params:
redirect: "/dashboard"
headers:
Content-Type: "application/json"
body:
username: "test"
password: "123456"
response:
status: 200
headers:
Content-Type: "application/json"
body: '{"code": 0, "message": "success", "data": {"token": "mock_token_12345"}}'
settings:
delay_ms: 100

View File

@@ -25,4 +25,4 @@ response:
"msg": "success" "msg": "success"
} }
settings: settings:
delay_ms: 2000 # 模拟真实网络延迟 delay_ms: 2000 # 模拟真实网络延迟

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

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 检查

View File

@@ -5,28 +5,29 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock}; // 必须引入 RwLock
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::router::MockRouter; use crate::router::MockRouter;
/// 共享的应用状态router 由 RwLock 保护以支持热重载 /// 共享的应用状态router 现在由 RwLock 保护以支持热重载
pub struct AppState { pub struct AppState {
pub router: RwLock<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 {
// 1. 提取 path 和 method
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. 将需要的数据克隆出来,断开与 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(_) => {
@@ -36,10 +37,9 @@ 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();
// 2. 将 Axum HeaderMap 转换为简单的 HashMap // 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() {
@@ -47,22 +47,22 @@ pub async fn mock_handler(
} }
} }
// 3. 执行匹配逻辑:先获取读锁 (Read Lock) // 4. 执行匹配逻辑:先获取读锁 (Read Lock)
let maybe_rule = { let maybe_rule = {
let router = state.router.read().expect("Failed to acquire read lock"); let router = state.router.read().expect("Failed to acquire read lock");
router.match_rule(&method_str, &path, &params, &req_headers, &incoming_json).cloned() router.match_rule(&method_str, &path, &params, &req_headers, &incoming_json).cloned()
// 使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁 // 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
}; };
if let Some(rule) = maybe_rule { if let Some(rule) = maybe_rule {
// 4. 处理模拟延迟 // 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;
} }
} }
// 5. 构建响应 // 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);
@@ -72,7 +72,7 @@ pub async fn mock_handler(
} }
} }
// 6. Smart Body 逻辑 // 7. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() { if let Some(file_path) = rule.response.get_file_path() {
match tokio::fs::File::open(file_path).await { match tokio::fs::File::open(file_path).await {
Ok(file) => { Ok(file) => {
@@ -95,7 +95,6 @@ pub async fn mock_handler(
.unwrap() .unwrap()
} }
} else { } else {
println!("请求头{:?}", req_headers);
// 匹配失败返回 404 // 匹配失败返回 404
Response::builder() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)

View File

@@ -1,6 +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;
pub mod upload;

View File

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

View File

@@ -2,42 +2,46 @@ use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use axum::{routing::{any, post}, Router}; use axum::{routing::any, Router};
use notify_debouncer_mini::{new_debouncer, notify::*}; 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};
use mock_server::upload::upload_handler;
/// 打印启动 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() {
print_banner();
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// 1. 确保 mocks 目录存在
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();
} }
// 2. 确保 storage 目录存在 // 1. 初始加载
let storage_dir = Path::new("./storage");
if !storage_dir.exists() {
println!("Creating storage directory...");
std::fs::create_dir_all(storage_dir).unwrap();
}
// 3. 初始加载 Mock 配置
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);
// 4. 构建共享状态(使用 RwLock 支持热重载)
let shared_state = Arc::new(AppState { let shared_state = Arc::new(AppState {
router: RwLock::new(MockRouter::new(index)), router: RwLock::new(MockRouter::new(index)),
}); });
// 5. 设置热加载监听器 // 2. 设置热加载监听器
let state_for_watcher = shared_state.clone(); let state_for_watcher = shared_state.clone();
let watch_path = mocks_dir.to_path_buf(); let watch_path = mocks_dir.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
@@ -63,21 +67,14 @@ async fn main() {
} }
}); });
// 6. 配置 Axum 路由 // 3. 配置 Axum 路由
// 文件上传路由POST /api/upload
// 其他所有请求由 mock_handler 处理
let app = Router::new() let app = Router::new()
.route("/api/upload", post(upload_handler))
.fallback(any(mock_handler)) .fallback(any(mock_handler))
.with_state(shared_state); .with_state(shared_state);
// 7. 启动服务
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!("📁 File upload endpoint: POST http://{}/api/upload", addr);
println!("🔄 Hot reload enabled for mocks/ directory");
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();
} }

View File

@@ -22,7 +22,7 @@ impl MockSource {
} }
/// 核心 Mock 规则定义 /// 核心 Mock 规则定义
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule { pub struct MockRule {
pub id: String, pub id: String,
pub request: RequestMatcher, pub request: RequestMatcher,
@@ -31,7 +31,7 @@ pub struct MockRule {
} }
/// 请求匹配条件 /// 请求匹配条件
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct RequestMatcher { pub struct RequestMatcher {
pub method: String, pub method: String,
pub path: String, pub path: String,
@@ -44,7 +44,7 @@ pub struct RequestMatcher {
} }
/// 响应内容定义 /// 响应内容定义
#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockResponse { pub struct MockResponse {
pub status: u16, pub status: u16,
pub headers: Option<HashMap<String, String>>, pub headers: Option<HashMap<String, String>>,
@@ -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>,
}

View File

@@ -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 规则

View File

@@ -1,150 +0,0 @@
use axum::{
body::Body,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use axum_extra::extract::Multipart;
use chrono::Local;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use uuid::Uuid;
use crate::handler::AppState;
/// 文件上传结果
#[derive(Debug, serde::Serialize)]
pub struct UploadResult {
pub success: bool,
pub files: Vec<FileInfo>,
pub message: String,
}
/// 单个文件信息
#[derive(Debug, serde::Serialize)]
pub struct FileInfo {
pub original_name: String,
pub stored_name: String,
pub path: String,
pub size: usize,
pub content_type: Option<String>,
}
impl UploadResult {
pub fn success(files: Vec<FileInfo>) -> Self {
UploadResult {
success: true,
files,
message: "Files uploaded successfully".to_string(),
}
}
pub fn error(message: String) -> Self {
UploadResult {
success: false,
files: vec![],
message,
}
}
}
impl IntoResponse for UploadResult {
fn into_response(self) -> Response {
let status = if self.success {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
let body = serde_json::to_string(&self).unwrap_or_else(|_| {
json!({
"success": false,
"files": [],
"message": "Failed to serialize response"
})
.to_string()
});
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap()
}
}
/// 处理文件上传
pub async fn upload_handler(
State(_state): State<Arc<AppState>>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut uploaded_files = Vec::new();
// 确保 storage 目录存在
let storage_root = PathBuf::from("storage");
if let Err(e) = fs::create_dir_all(&storage_root).await {
return UploadResult::error(format!("Failed to create storage directory: {}", e));
}
// 创建按日期分类的子目录
let date_dir = Local::now().format("%Y-%m-%d").to_string();
let upload_dir = storage_root.join(&date_dir);
if let Err(e) = fs::create_dir_all(&upload_dir).await {
return UploadResult::error(format!("Failed to create upload directory: {}", e));
}
// 处理每个上传的字段
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
// 获取文件名
let original_name = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
// 获取内容类型
let content_type = field.content_type().map(|s| s.to_string());
// 读取文件数据
let data = match field.bytes().await {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to read file data: {}", e);
continue;
}
};
// 生成唯一文件名,保留原始扩展名
let extension = std::path::Path::new(&original_name)
.extension()
.and_then(|s| s.to_str())
.map(|s| format!(".{}", s))
.unwrap_or_default();
let stored_name = format!("{}{}", Uuid::new_v4(), extension);
let file_path = upload_dir.join(&stored_name);
// 保存文件
match fs::write(&file_path, &data).await {
Ok(_) => {
uploaded_files.push(FileInfo {
original_name: original_name.clone(),
stored_name: stored_name.clone(),
path: format!("storage/{}/{}", date_dir, stored_name),
size: data.len(),
content_type,
});
}
Err(e) => {
eprintln!("Failed to save file {}: {}", original_name, e);
}
}
}
if uploaded_files.is_empty() {
UploadResult::error("No files were uploaded".to_string())
} else {
UploadResult::success(uploaded_files)
}
}

View File

@@ -1,140 +0,0 @@
use mock_server::config::{MockResponse, MockSettings};
#[test]
fn test_mock_response_file_protocol() {
// 测试 1: 文件协议识别
let response = MockResponse {
status: 200,
headers: None,
body: "file://./data/file.txt".to_string(),
};
assert!(response.is_file_protocol());
// 测试 2: 非文件协议识别
let inline_response = MockResponse {
status: 200,
headers: None,
body: "{\"key\": \"value\"}".to_string(),
};
assert!(!inline_response.is_file_protocol());
}
#[test]
fn test_mock_response_file_path_extraction() {
// 测试 1: 提取文件路径
let response = MockResponse {
status: 200,
headers: None,
body: "file://./data/file.txt".to_string(),
};
assert_eq!(response.get_file_path(), Some("./data/file.txt"));
// 测试 2: 提取相对路径
let response2 = MockResponse {
status: 200,
headers: None,
body: "file:///absolute/path/file.pdf".to_string(),
};
assert_eq!(response2.get_file_path(), Some("/absolute/path/file.pdf"));
// 测试 3: 非文件协议返回 None
let inline_response = MockResponse {
status: 200,
headers: None,
body: "inline content".to_string(),
};
assert_eq!(inline_response.get_file_path(), None);
}
#[test]
fn test_mock_settings_delay() {
// 测试 1: 有延迟设置
let settings = MockSettings {
delay_ms: Some(1000),
};
assert_eq!(settings.delay_ms, Some(1000));
// 测试 2: 无延迟设置
let settings = MockSettings { delay_ms: None };
assert_eq!(settings.delay_ms, None);
}
#[test]
fn test_mock_response_various_body_formats() {
// 测试 1: JSON 格式
let json_response = MockResponse {
status: 200,
headers: None,
body: r#"{"code": 0, "message": "success"}"#.to_string(),
};
assert_eq!(json_response.status, 200);
assert!(!json_response.is_file_protocol());
// 测试 2: HTML 格式
let html_response = MockResponse {
status: 200,
headers: Some(vec![("Content-Type".to_string(), "text/html".to_string())]
.into_iter()
.collect()),
body: "<html><body>Hello</body></html>".to_string(),
};
assert_eq!(html_response.status, 200);
// 测试 3: 纯文本
let text_response = MockResponse {
status: 200,
headers: None,
body: "Plain text response".to_string(),
};
assert_eq!(text_response.body, "Plain text response");
}
#[test]
fn test_mock_response_with_headers() {
let mut headers = std::collections::HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
headers.insert("Authorization".to_string(), "Bearer token".to_string());
let response = MockResponse {
status: 200,
headers: Some(headers),
body: "response body".to_string(),
};
assert_eq!(response.status, 200);
assert!(response.headers.is_some());
let response_headers = response.headers.unwrap();
assert_eq!(response_headers.get("Content-Type"), Some(&"application/json".to_string()));
assert_eq!(response_headers.get("Authorization"), Some(&"Bearer token".to_string()));
}
#[test]
fn test_mock_response_edge_cases() {
// 测试 1: 空文件路径
let empty_file_response = MockResponse {
status: 200,
headers: None,
body: "file://".to_string(),
};
assert!(empty_file_response.is_file_protocol());
assert_eq!(empty_file_response.get_file_path(), Some(""));
// 测试 2: 空响应体
let empty_response = MockResponse {
status: 200,
headers: None,
body: "".to_string(),
};
assert_eq!(empty_response.body, "");
// 测试 3: 包含特殊字符的文件路径
let special_path_response = MockResponse {
status: 200,
headers: None,
body: "file://./data/file with spaces.txt".to_string(),
};
assert_eq!(
special_path_response.get_file_path(),
Some("./data/file with spaces.txt")
);
}

View File

@@ -1,441 +0,0 @@
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use axum::{
body::Body,
extract::Request,
http::{HeaderMap, HeaderValue, Method, StatusCode},
response::IntoResponse,
};
use mock_server::config::MockSource;
use mock_server::handler::{AppState, mock_handler};
use mock_server::router::MockRouter;
use tempfile::tempdir;
use std::fs::{self, File};
use std::io::Write;
use tokio::runtime::Runtime;
/// 创建带有 Mock 规则的 AppState
fn create_app_state_with_rules(rules: Vec<&str>) -> Arc<AppState> {
let temp_dir = tempdir().expect("无法创建临时目录");
let root_path = temp_dir.path();
let mut all_rules = Vec::new();
for (i, yaml_content) in rules.iter().enumerate() {
let file_path = root_path.join(format!("rule_{}.yaml", i));
let mut file = File::create(&file_path).unwrap();
writeln!(file, "{}", yaml_content).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
if let Ok(source) = serde_yaml::from_str::<MockSource>(&content) {
all_rules.extend(source.flatten());
}
}
let mut index = HashMap::new();
for rule in all_rules {
let key = rule.request.path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string();
index.entry(key).or_insert_with(Vec::new).push(rule);
}
let router = MockRouter::new(index);
Arc::new(AppState { router: RwLock::new(router) })
}
#[test]
fn test_handler_basic_request() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "basic_test"
request:
method: "GET"
path: "/api/test"
response:
status: 200
body: "test response"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/test")
.body(Body::empty())
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}
#[test]
fn test_handler_with_query_params() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "query_test"
request:
method: "GET"
path: "/api/search"
query_params:
q: "rust"
response:
status: 200
body: "search results"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/search?q=rust")
.body(Body::empty())
.unwrap();
let mut query_params = HashMap::new();
query_params.insert("q".to_string(), "rust".to_string());
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(query_params),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}
#[test]
fn test_handler_with_headers() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "header_test"
request:
method: "POST"
path: "/api/data"
headers:
Authorization: "Bearer token123"
Content-Type: "application/json"
response:
status: 200
body: "data response"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::POST)
.uri("/api/data")
.body(Body::empty())
.unwrap();
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("Bearer token123"));
headers.insert("content-type", HeaderValue::from_static("application/json"));
let response = mock_handler(
axum::extract::State(state),
Method::POST,
headers,
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}
#[test]
fn test_handler_with_body() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "body_test"
request:
method: "POST"
path: "/api/users"
body:
name: "John"
age: 30
response:
status: 201
body: "user created"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let body_json = serde_json::json!({ "name": "John", "age": 30 });
let body_bytes = serde_json::to_vec(&body_json).unwrap();
let request = Request::builder()
.method(Method::POST)
.uri("/api/users")
.header("content-type", "application/json")
.body(Body::from(body_bytes))
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::POST,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::CREATED);
});
}
#[test]
fn test_handler_no_match() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "existing_rule"
request:
method: "GET"
path: "/api/existing"
response:
status: 200
body: "exists"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/nonexistent")
.body(Body::empty())
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
});
}
#[test]
fn test_handler_response_headers() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "headers_test"
request:
method: "GET"
path: "/api/with-headers"
response:
status: 200
headers:
Content-Type: "application/json"
Cache-Control: "no-cache"
body: '{"key": "value"}'
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let request = Request::builder()
.method(Method::GET)
.uri("/api/with-headers")
.body(Body::empty())
.unwrap();
let response = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/json"
);
assert_eq!(
response.headers().get("cache-control").unwrap(),
"no-cache"
);
});
}
#[test]
fn test_handler_different_status_codes() {
let rt = Runtime::new().unwrap();
let yaml1 = r#"
id: "success"
request:
method: "GET"
path: "/api/success"
response:
status: 200
body: "ok"
"#;
let yaml2 = r#"
id: "created"
request:
method: "POST"
path: "/api/created"
response:
status: 201
body: "created"
"#;
let yaml3 = r#"
id: "error"
request:
method: "GET"
path: "/api/error"
response:
status: 500
body: "server error"
"#;
let state = create_app_state_with_rules(vec![yaml1, yaml2, yaml3]);
rt.block_on(async {
// 测试 200
let request1 = Request::builder()
.method(Method::GET)
.uri("/api/success")
.body(Body::empty())
.unwrap();
let response1 = mock_handler(
axum::extract::State(state.clone()),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request1,
)
.await
.into_response();
assert_eq!(response1.status(), StatusCode::OK);
// 测试 201
let request2 = Request::builder()
.method(Method::POST)
.uri("/api/created")
.body(Body::empty())
.unwrap();
let response2 = mock_handler(
axum::extract::State(state.clone()),
Method::POST,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request2,
)
.await
.into_response();
assert_eq!(response2.status(), StatusCode::CREATED);
// 测试 500
let request3 = Request::builder()
.method(Method::GET)
.uri("/api/error")
.body(Body::empty())
.unwrap();
let response3 = mock_handler(
axum::extract::State(state),
Method::GET,
HeaderMap::new(),
axum::extract::Query(HashMap::new()),
request3,
)
.await
.into_response();
assert_eq!(response3.status(), StatusCode::INTERNAL_SERVER_ERROR);
});
}
#[test]
fn test_handler_complex_matching() {
let rt = Runtime::new().unwrap();
let yaml = r#"
id: "complex"
request:
method: "PUT"
path: "/api/users/123"
query_params:
update: "true"
headers:
Authorization: "Bearer secret"
body:
name: "Updated Name"
response:
status: 200
body: "updated"
"#;
let state = create_app_state_with_rules(vec![yaml]);
rt.block_on(async {
let body_json = serde_json::json!({ "name": "Updated Name" });
let body_bytes = serde_json::to_vec(&body_json).unwrap();
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
let request = Request::builder()
.method(Method::PUT)
.uri("/api/users/123")
.header("content-type", "application/json")
.header("authorization", "Bearer secret")
.body(Body::from(body_bytes))
.unwrap();
let mut query_params = HashMap::new();
query_params.insert("update".to_string(), "true".to_string());
let response = mock_handler(
axum::extract::State(state),
Method::PUT,
headers,
axum::extract::Query(query_params),
request,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
});
}

View File

@@ -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::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;

View File

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

View File

@@ -1,354 +0,0 @@
use mock_server::config::MockSource;
use mock_server::router::MockRouter;
use std::collections::HashMap;
use serde_json::json;
#[test]
fn test_router_basic_method_path_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "basic_test"
request:
method: "GET"
path: "/api/test"
response: { status: 200, body: "ok" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试精确匹配
let matched = router.match_rule("GET", "/api/test", &HashMap::new(), &HashMap::new(), &None);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id, "basic_test");
// 测试方法不匹配
let not_matched = router.match_rule("POST", "/api/test", &HashMap::new(), &HashMap::new(), &None);
assert!(not_matched.is_none());
// 测试路径不匹配
let not_matched = router.match_rule("GET", "/api/other", &HashMap::new(), &HashMap::new(), &None);
assert!(not_matched.is_none());
}
#[test]
fn test_router_case_insensitive_method() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "case_test"
request:
method: "GET"
path: "/api/data"
response: { status: 200, body: "data" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试不同大小写的方法匹配
assert!(router.match_rule("get", "/api/data", &HashMap::new(), &HashMap::new(), &None).is_some());
assert!(router.match_rule("GET", "/api/data", &HashMap::new(), &HashMap::new(), &None).is_some());
assert!(router.match_rule("Get", "/api/data", &HashMap::new(), &HashMap::new(), &None).is_some());
}
#[test]
fn test_router_path_trailing_slash() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "slash_test"
request:
method: "POST"
path: "/api/users"
response: { status: 200, body: "created" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试带和不带斜杠的路径匹配
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &None).is_some());
assert!(router.match_rule("POST", "/api/users/", &HashMap::new(), &HashMap::new(), &None).is_some());
}
#[test]
fn test_router_query_params_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "query_test"
request:
method: "GET"
path: "/api/search"
query_params:
q: "rust"
limit: "10"
response: { status: 200, body: "results" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试查询参数完全匹配
let mut queries = HashMap::new();
queries.insert("q".to_string(), "rust".to_string());
queries.insert("limit".to_string(), "10".to_string());
queries.insert("extra".to_string(), "value".to_string()); // 额外参数应该忽略
assert!(router.match_rule("GET", "/api/search", &queries, &HashMap::new(), &None).is_some());
// 测试查询参数不匹配
let mut wrong_queries = HashMap::new();
wrong_queries.insert("q".to_string(), "python".to_string());
assert!(router.match_rule("GET", "/api/search", &wrong_queries, &HashMap::new(), &None).is_none());
}
#[test]
fn test_router_headers_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "header_test"
request:
method: "GET"
path: "/api/data"
headers:
Authorization: "Bearer token123"
Content-Type: "application/json"
response: { status: 200, body: "data" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试头部完全匹配(大小写不敏感)
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer token123".to_string());
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("extra-header".to_string(), "value".to_string());
assert!(router.match_rule("GET", "/api/data", &HashMap::new(), &headers, &None).is_some());
// 测试头部不匹配
let mut wrong_headers = HashMap::new();
wrong_headers.insert("authorization".to_string(), "Bearer wrong".to_string());
assert!(router.match_rule("GET", "/api/data", &HashMap::new(), &wrong_headers, &None).is_none());
}
#[test]
fn test_router_body_matching() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "body_test"
request:
method: "POST"
path: "/api/users"
body:
name: "John"
age: 30
response: { status: 200, body: "created" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试 Body 完全匹配
let body = Some(json!({ "name": "John", "age": 30 }));
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &body).is_some());
// 测试 Body 不匹配
let wrong_body = Some(json!({ "name": "Jane", "age": 25 }));
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &wrong_body).is_none());
// 测试请求无 Body
assert!(router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &None).is_none());
}
#[test]
fn test_router_body_string_to_json() {
let mut index = HashMap::new();
// YAML 中 body 是字符串,但内容是 JSON
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "str_body_test"
request:
method: "POST"
path: "/api/login"
body: '{"username": "admin", "password": "secret"}'
response: { status: 200, body: "token" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 请求中 body 是 JSON 对象,应该匹配
let body = Some(json!({ "username": "admin", "password": "secret" }));
assert!(router.match_rule("POST", "/api/login", &HashMap::new(), &HashMap::new(), &body).is_some());
}
#[test]
fn test_router_multiple_rules_same_segment() {
let mut index = HashMap::new();
let rule1 = serde_yaml::from_str::<MockSource>(
r#"
id: "rule1"
request:
method: "GET"
path: "/api/users"
response: { status: 200, body: "users" }
"#,
)
.unwrap()
.flatten();
let rule2 = serde_yaml::from_str::<MockSource>(
r#"
id: "rule2"
request:
method: "POST"
path: "/api/users"
response: { status: 201, body: "created" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule1[0].clone(), rule2[0].clone()]);
let router = MockRouter::new(index);
// 测试不同方法匹配不同规则
let get_matched = router.match_rule("GET", "/api/users", &HashMap::new(), &HashMap::new(), &None);
assert!(get_matched.is_some());
assert_eq!(get_matched.unwrap().id, "rule1");
let post_matched = router.match_rule("POST", "/api/users", &HashMap::new(), &HashMap::new(), &None);
assert!(post_matched.is_some());
assert_eq!(post_matched.unwrap().id, "rule2");
}
#[test]
fn test_router_complex_matching_scenario() {
let mut index = HashMap::new();
let complex_rule = serde_yaml::from_str::<MockSource>(
r#"
id: "complex_rule"
request:
method: "PUT"
path: "/api/users/123"
query_params:
update: "true"
headers:
Authorization: "Bearer token456"
Content-Type: "application/json"
body:
name: "Updated"
response: { status: 200, body: "updated" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![complex_rule[0].clone()]);
let router = MockRouter::new(index);
// 测试完全匹配所有条件
let mut queries = HashMap::new();
queries.insert("update".to_string(), "true".to_string());
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer token456".to_string());
headers.insert("content-type".to_string(), "application/json".to_string());
let body = Some(json!({ "name": "Updated" }));
let matched = router.match_rule("PUT", "/api/users/123", &queries, &headers, &body);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id, "complex_rule");
// 测试缺少任何条件都不匹配
let no_body_matched = router.match_rule("PUT", "/api/users/123", &queries, &headers, &None);
assert!(no_body_matched.is_none());
}
#[test]
fn test_router_root_path_handling() {
let mut index = HashMap::new();
let root_rule = serde_yaml::from_str::<MockSource>(
r#"
id: "root_rule"
request:
method: "GET"
path: "/"
response: { status: 200, body: "root" }
"#,
)
.unwrap()
.flatten();
// 根路径会被提取为空字符串 key
index.insert("".to_string(), vec![root_rule[0].clone()]);
let router = MockRouter::new(index);
// 测试根路径匹配
assert!(router.match_rule("GET", "/", &HashMap::new(), &HashMap::new(), &None).is_some());
}
#[test]
fn test_router_nonexistent_key() {
let mut index = HashMap::new();
let rule = serde_yaml::from_str::<MockSource>(
r#"
id: "test_rule"
request:
method: "GET"
path: "/api/data"
response: { status: 200, body: "data" }
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule[0].clone()]);
let router = MockRouter::new(index);
// 测试请求不同段路径,应该不匹配
assert!(router.match_rule("GET", "/health", &HashMap::new(), &HashMap::new(), &None).is_none());
assert!(router.match_rule("GET", "/v2/data", &HashMap::new(), &HashMap::new(), &None).is_none());
}

View File

@@ -1,246 +0,0 @@
use axum::{
http::StatusCode,
response::IntoResponse,
};
use mock_server::upload::{UploadResult, FileInfo};
use tempfile::TempDir;
use tokio::runtime::Runtime;
/// 创建模拟的 multipart 数据
fn create_multipart_body(boundary: &str, files: Vec<(&str, &str, &[u8])>) -> Vec<u8> {
let mut body = Vec::new();
for (field_name, filename, content) in files {
body.extend_from_slice(
format!(
"--{}\r\nContent-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n\r\n",
boundary, field_name, filename
)
.as_bytes(),
);
body.extend_from_slice(content);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
body
}
#[test]
fn test_upload_result_success() {
let file_info = FileInfo {
original_name: "test.txt".to_string(),
stored_name: "uuid-test.txt".to_string(),
path: "storage/2026-03-19/uuid-test.txt".to_string(),
size: 1024,
content_type: Some("text/plain".to_string()),
};
let result = UploadResult::success(vec![file_info]);
// 验证序列化
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("test.txt"));
assert!(json.contains("success"));
assert!(json.contains("1024"));
}
#[test]
fn test_upload_result_error() {
let result = UploadResult::error("Test error message".to_string());
assert!(!result.success);
assert_eq!(result.message, "Test error message");
assert!(result.files.is_empty());
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("Test error message"));
}
#[test]
fn test_file_info_serialization() {
let file_info = FileInfo {
original_name: "document.pdf".to_string(),
stored_name: "550e8400-e29b-41d4-a716-446655440000.pdf".to_string(),
path: "storage/2026-03-19/550e8400-e29b-41d4-a716-446655440000.pdf".to_string(),
size: 2048,
content_type: Some("application/pdf".to_string()),
};
let json = serde_json::to_string(&file_info).unwrap();
assert!(json.contains("document.pdf"));
assert!(json.contains("2048"));
assert!(json.contains("application/pdf"));
}
#[test]
fn test_upload_result_into_response() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let result = UploadResult::success(vec![]);
let response = result.into_response();
assert_eq!(response.status(), StatusCode::OK);
let result_error = UploadResult::error("Error".to_string());
let response_error = result_error.into_response();
assert_eq!(response_error.status(), StatusCode::BAD_REQUEST);
});
}
#[test]
fn test_storage_directory_creation() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let storage_path = temp_dir.path().join("storage");
// 测试目录创建
tokio::fs::create_dir_all(&storage_path).await.unwrap();
assert!(storage_path.exists());
// 测试日期目录创建
let date_dir_name = chrono::Local::now().format("%Y-%m-%d").to_string();
let date_dir = storage_path.join(&date_dir_name);
tokio::fs::create_dir_all(&date_dir).await.unwrap();
assert!(date_dir.exists());
});
}
#[test]
fn test_file_save_and_read() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file.txt");
let content = b"Test file content";
// 写入文件
tokio::fs::write(&file_path, content).await.unwrap();
assert!(file_path.exists());
// 读取文件
let read_content = tokio::fs::read(&file_path).await.unwrap();
assert_eq!(read_content, content);
});
}
#[test]
fn test_multipart_body_creation() {
let boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW";
let content = b"Hello, World!";
let body = create_multipart_body(boundary, vec![("file", "test.txt", content)]);
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains(boundary));
assert!(body_str.contains("test.txt"));
assert!(body_str.contains("Hello, World!"));
}
#[test]
fn test_multiple_files_multipart() {
let boundary = "----WebKitFormBoundary";
let files = vec![
("file1", "a.txt", b"content a" as &[u8]),
("file2", "b.txt", b"content b" as &[u8]),
];
let body = create_multipart_body(boundary, files);
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains("a.txt"));
assert!(body_str.contains("b.txt"));
assert!(body_str.contains("content a"));
assert!(body_str.contains("content b"));
}
#[test]
fn test_uuid_generation() {
use uuid::Uuid;
let uuid1 = Uuid::new_v4();
let uuid2 = Uuid::new_v4();
// UUID 应该是唯一的
assert_ne!(uuid1, uuid2);
// UUID 应该是有效的
assert!(!uuid1.is_nil());
assert!(!uuid2.is_nil());
}
#[test]
fn test_date_directory_format() {
let date_dir_name = chrono::Local::now().format("%Y-%m-%d").to_string();
// 验证日期格式
assert!(date_dir_name.contains('-'));
let parts: Vec<&str> = date_dir_name.split('-').collect();
assert_eq!(parts.len(), 3);
}
#[test]
fn test_file_extension_extraction() {
let test_cases = vec![
("test.txt", ".txt"),
("document.pdf", ".pdf"),
("image.png", ".png"),
("archive.tar.gz", ".gz"),
("noextension", ""),
];
for (filename, expected_ext) in test_cases {
let extension = std::path::Path::new(filename)
.extension()
.and_then(|s| s.to_str())
.map(|s| format!(".{}", s))
.unwrap_or_default();
assert_eq!(extension, expected_ext);
}
}
#[test]
fn test_large_file_content() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("large_file.bin");
// 创建 1MB 的数据
let large_content = vec![0u8; 1024 * 1024];
tokio::fs::write(&file_path, &large_content).await.unwrap();
let file_size = tokio::fs::metadata(&file_path).await.unwrap().len();
assert_eq!(file_size, 1024 * 1024);
});
}
#[test]
fn test_binary_file_content() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("binary.bin");
// 创建二进制数据
let binary_data: Vec<u8> = (0..=255).collect();
tokio::fs::write(&file_path, &binary_data).await.unwrap();
let read_data = tokio::fs::read(&file_path).await.unwrap();
assert_eq!(read_data.len(), 256);
assert_eq!(read_data, binary_data);
});
}