2 Commits

Author SHA1 Message Date
cfcebbe300 feat(mcp): 实现HTTP统一方案并添加MCP文档
- 将MCP服务和Mock API合并到单个HTTP服务器(8080端口)
- 添加POST /mcp端点,使用无状态StreamableHttpService
- 新增docs/mcp-implementation.md文档
2026-03-29 22:30:29 +08:00
d364307131 feat: mock配置迁移至JSON格式并修复body匹配
- 将mock配置从YAML格式迁移到JSON格式
- 修复JSON字符串格式body匹配失败问题
- 添加MCP功能模块
- 更新mock-spec.md规范文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 09:43:11 +08:00
43 changed files with 2512 additions and 895 deletions

View File

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

905
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "mock_server" name = "mock_server"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
[dependencies] [dependencies]
# 核心 Web 框架 # 核心 Web 框架
@@ -12,24 +12,25 @@ tokio={version = "1.48.0",features = ["full"]}
tokio-util = {version = "0.7.17",features = ["io"]} tokio-util = {version = "0.7.17",features = ["io"]}
futures-util = "0.3.31" futures-util = "0.3.31"
# 序列化与 YAML 解析 # 序列化
serde = {version = "1.0.228",features = ["derive"]} serde = {version = "1.0.228",features = ["derive"]}
serde_yaml = "0.9.34+deprecated"
serde_json = "1.0.147" serde_json = "1.0.147"
# 物理目录递归扫描工具 # 物理目录递归扫描工具
walkdir = "2.5.0" walkdir = "2.5.0"
tracing="0.1.44" # 日志系统
tracing-subscriber = "0.3.22" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["fmt", "env-filter"] }
tracing-appender = "0.2"
# 性能优化:快速哈希(可选,用于路由匹配)
#dashmap = "7.0.0-rc2"
# 热加载支持(扩展功能) # 热加载支持(扩展功能)
notify = "8.2.0" notify = "8.2.0"
notify-debouncer-mini = "0.6.0" notify-debouncer-mini = "0.6.0"
# 路径处理
#pathdiff = "0.2.3" # MCP Server 支持
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
schemars = "1.0"
[dev-dependencies] [dev-dependencies]
tempfile = "3.24.0" tempfile = "3.24.0"

427
docs/mcp-implementation.md Normal file
View File

@@ -0,0 +1,427 @@
# rmcp 0.11 MCP Server 实现指南
本文档详细介绍了如何使用 rmcp 0.11 实现 MCP Server包括核心概念、关键代码模式和常见陷阱。
## 目录
- [核心概念](#核心概念)
- [Tool 注册三要素](#tool-注册三要素)
- [完整代码示例](#完整代码示例)
- [常见错误和解决方案](#常见错误和解决方案)
- [HTTP 传输配置](#http-传输配置)
- [客户端配置](#客户端配置)
---
## 核心概念
### rmcp 简介
rmcp 是 Rust 官方的 MCP SDK提供了实现 MCP (Model Context Protocol) 服务器和客户端的完整工具链。
### MCP 协议
MCP (Model Context Protocol) 是一个开放协议,用于连接 AI 助手与外部系统。它定义了一套标准化的接口,允许 AI 模型:
- **Tools**: 调用外部工具/函数
- **Resources**: 访问外部资源
- **Prompts**: 使用预定义的提示模板
### 依赖配置
```toml
# Cargo.toml
[dependencies]
rmcp = { version = "0.11", features = ["server", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
schemars = "1.0"
tokio-util = { version = "0.7", features = ["io"] }
```
---
## Tool 注册三要素
要使 MCP tools 在 rmcp 0.11 中正常工作,**必须**同时具备以下三个要素:
### 1. `#[tool_router]` 宏
放在包含 tool 方法的 impl 块上:
```rust
#[tool_router]
impl MockMcpServer {
// tool 方法...
}
```
### 2. `tool_router: ToolRouter<Self>` 字段
在结构体中必须包含此字段:
```rust
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>, // 必需字段
}
```
### 3. `#[tool_handler]` 宏
放在 `ServerHandler` trait 实现块上:
```rust
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
// ...
}
}
```
> **重要**: 缺少 `#[tool_handler]` 会导致 `tools/list` 返回空数组 `{"tools": []}`
---
## 完整代码示例
### 结构体定义
```rust
use std::sync::Arc;
use rmcp::{
handler::server::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::*,
tool, tool_handler, tool_router,
ErrorData as McpError, ServerHandler,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// MCP Server for Mock Server management
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>, // 必需字段
}
```
### 请求参数结构体
```rust
/// 使用 schemars 和 serde 自动生成 JSON Schema
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListRulesRequest {
#[schemars(description = "Optional group name to filter by")]
pub group: Option<String>,
}
```
### Tool 方法实现
```rust
#[tool_router]
impl MockMcpServer {
pub fn new(manager: Arc<MockManager>) -> Self {
Self {
manager,
tool_router: Self::tool_router(), // 初始化 tool_router
}
}
/// 无参数 tool
#[tool(description = "List all groups (directories)")]
async fn list_groups(&self) -> Result<CallToolResult, McpError> {
let groups = self.manager.list_groups();
let result = serde_json::to_string_pretty(&groups)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
/// 带参数 tool - 使用 Parameters<T> 包装器
#[tool(description = "Get a specific mock rule by group and name")]
async fn get_mock_rule(
&self,
params: Parameters<GetRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.get(&params.0.group, &params.0.name) {
Some(rule) => {
let result = serde_json::to_string_pretty(&rule)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
None => Ok(CallToolResult::error(vec![Content::text(
format!("Rule not found: {}/{}", params.0.group, params.0.name),
)])),
}
}
/// 可选参数
#[tool(description = "List all mock rules, optionally filtered by group")]
async fn list_mock_rules(
&self,
params: Parameters<ListRulesRequest>,
) -> Result<CallToolResult, McpError> {
let rules = self.manager.list(params.0.group.as_deref());
let result = serde_json::to_string_pretty(&rules)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
}
```
### ServerHandler 实现
```rust
#[tool_handler] // 关键宏!
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools() // 必须启用 tools 能力
.build(),
instructions: Some("Mock Server MCP - Manage mock API rules.".to_string()),
..Default::default()
}
}
}
```
---
## 常见错误和解决方案
### 1. Missing `#[tool_handler]`
**症状**: `tools/list` 返回空数组 `{"tools": []}`
**原因**: 没有在 `ServerHandler` impl 块上添加 `#[tool_handler]`
**解决方案**:
```rust
// 错误 - 缺少宏
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo { ... }
}
// 正确
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo { ... }
}
```
### 2. Missing `enable_tools()`
**症状**: Tools 不被广播,客户端无法发现 tools
**原因**: `ServerCapabilities` 没有启用 tools
**解决方案**:
```rust
// 错误
ServerInfo {
capabilities: ServerCapabilities::default (),
...
}
// 正确
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
...
}
```
### 3. 使用 `#[tool(aggr)]`
**症状**: 编译错误
**原因**: rmcp 0.11 不支持 `aggr` 参数
**解决方案**:
```rust
// 错误 - rmcp 0.11 不支持
#[tool(aggr)]
async fn my_tool(&self, params: MyRequest) -> Result<...>
// 正确 - 使用 Parameters<T> 包装器
#[tool(description = "...")]
async fn my_tool(&self, params: Parameters<MyRequest>) -> Result<...>
```
### 4. 使用 `#[tool(tool_box)]`
**症状**: 编译错误
**原因**: rmcp 0.11 使用不同的宏名称
**解决方案**:
```rust
// 错误
#[tool(tool_box)]
impl MockMcpServer { ... }
// 正确
#[tool_router]
impl MockMcpServer { ... }
```
---
## HTTP 传输配置
### StreamableHttpService 无状态模式
适用于简单的 HTTP 集成,无需维护会话状态:
```rust
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::never::NeverSessionManager,
};
use tokio_util::sync::CancellationToken;
/// 创建无状态 MCP HTTP 服务
pub fn create_mcp_http_service(
manager: Arc<MockManager>,
) -> StreamableHttpService<MockMcpServer, NeverSessionManager> {
StreamableHttpService::new(
// 每次请求创建新的 server 实例
move || Ok(MockMcpServer::new(manager.clone())),
// 无状态会话管理器
Arc::new(NeverSessionManager::default()),
StreamableHttpServerConfig {
sse_keep_alive: None, // SSE 保活配置(可选)
stateful_mode: false, // 无状态模式
cancellation_token: CancellationToken::new(),
},
)
}
```
### 与 Axum 集成
```rust
use axum::{
routing::post,
Router,
body::Body,
http::Request,
};
let mcp_service = create_mcp_http_service(manager);
let app = Router::new()
.route("/mcp", post({
let service = mcp_service.clone();
move | req: Request < Body > | {
let service = service.clone();
async move { service.handle(req).await }
}
}));
```
### 配置选项说明
| 选项 | 类型 | 说明 |
|----------------------|---------------------|------------|
| `sse_keep_alive` | `Option<Duration>` | SSE 连接保活间隔 |
| `stateful_mode` | `bool` | 是否维护会话状态 |
| `cancellation_token` | `CancellationToken` | 用于优雅关闭 |
---
## 客户端配置
### Claude Code 配置
在 Claude Code 的 `settings.json` 中添加:
```json
{
"mcpServers": {
"mock-server": {
"type": "http",
"url": "http://127.0.0.1:8080/mcp"
}
}
}
```
### Claude Desktop 配置
在 Claude Desktop 的配置文件中添加:
```json
{
"mcpServers": {
"mock-server": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}
```
---
## 架构说明
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP Server (Axum) │
│ Port 8080 │
├─────────────────────────────────────────────────────────────┤
│ │
│ POST /mcp │ /* (fallback) │
│ ├─ tools/list │ Mock API endpoints │
│ ├─ tools/call │ │
│ └─ other MCP methods │ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ StreamableHttpService<MockMcpServer> │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ MockMcpServer │ │ NeverSessionManager │ │
│ │ │ │ (无状态) │ │
│ │ - tool_router │ │ │ │
│ │ - manager │ │ │ │
│ └─────────────────┘ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ MockManager (Arc) │
│ │
│ 共享于 HTTP MCP 端点和 Mock API handler │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 参考资源
- [rmcp GitHub Repository](https://github.com/anthropics/rmcp)
- [MCP Specification](https://spec.modelcontextprotocol.io/)
- [schemars Documentation](https://docs.rs/schemars/)

View File

@@ -0,0 +1,26 @@
{
"name": "user_login_001",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user001",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10001,\"username\":\"user001\",\"role\":\"administrator\"}}"
},
"settings": {
"delay_ms": 2000
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "user_login_002",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user002",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10002,\"username\":\"user002\",\"role\":\"administrator\"}}"
},
"settings": {
"delay_ms": 2000
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "user_login_003",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user003",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10003,\"username\":\"user003\",\"role\":\"administrator\"}}"
},
"settings": {
"delay_ms": 2000
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "user_login_004",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user004",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10004,\"username\":\"user004\",\"role\":\"administrator\"}}"
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "user_login_005",
"request": {
"path": "/v1/auth/login",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"host": "127.0.0.1:8080"
},
"body": {
"username": "user005",
"password": "password123"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"登录成功\",\"data\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6\",\"userId\":10005,\"username\":\"user005\",\"role\":\"administrator\"}}"
}
}

View File

@@ -1,118 +0,0 @@
# 用户登录 - JSON 格式
- name: "user_login_002"
request:
path: "/v1/auth/login"
method: "POST"
headers:
Content-Type: "application/json"
Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
host: "127.0.0.1:8080"
body: >
{
"username": "user002",
"password": "password123"
}
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"userId": 10002,
"username": "user002",
"role": "administrator"
}
}
settings:
delay_ms: 2000 # 模拟真实网络延迟
- name: "user_login_003"
request:
path: "/v1/auth/login"
method: "POST"
headers:
Content-Type: "application/json"
Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
host: "127.0.0.1:8080"
body: |
{
"username": "user003",
"password": "password123"
}
response:
status: 200
headers:
Content-Type: "application/json"
body: >
{
"code": 0,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"userId": 10003,
"username": "user003",
"role": "administrator"
}
}
settings:
delay_ms: 2000 # 模拟真实网络延迟
- name: "user_login_004"
request:
path: "/v1/auth/login"
method: "POST"
headers:
Content-Type: "application/json"
Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
host: "127.0.0.1:8080"
body: |
{
"username": "user004",
"password": "password123"
}
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"userId": 10004,
"username": "user004",
"role": "administrator"
}
}
- name: "user_login_005"
request:
path: "/v1/auth/login"
method: "POST"
headers:
Content-Type: "application/json"
Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
host: "127.0.0.1:8080"
body:
username: "user005"
password: "password123"
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"userId": 10005,
"username": "user005",
"role": "administrator"
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "user_register",
"request": {
"method": "POST",
"path": "/v1/auth/register",
"headers": {
"Content-Type": "application/json"
},
"body": {
"username": "newuser",
"password": "newpass123",
"email": "newuser@example.com"
}
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"注册成功\",\"data\":{\"userId\":10002,\"username\":\"newuser\",\"email\":\"newuser@example.com\",\"createdAt\":\"2026-03-27T10:00:00Z\"}}"
}
}

View File

@@ -1,26 +0,0 @@
# 用户注册 - JSON 格式
name: "user_register"
request:
method: "POST"
path: "/v1/auth/register"
headers:
Content-Type: "application/json"
body:
username: "newuser"
password: "newpass123"
email: "newuser@example.com"
response:
status: 201
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "注册成功",
"data": {
"userId": 10002,
"username": "newuser",
"email": "newuser@example.com",
"createdAt": "2026-03-27T10:00:00Z"
}
}

View File

@@ -1,29 +0,0 @@
# 用户登录 - JSON 格式
name: "user_login_001"
request:
path: "/v1/auth/login"
method: "POST"
headers:
Content-Type: "application/json"
Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
host: "127.0.0.1:8080"
body:
username: "user001"
password: "password123"
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
"userId": 10001,
"username": "user001",
"role": "administrator"
}
}
settings:
delay_ms: 2000 # 模拟真实网络延迟

18
mocks/v1/data/export.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "data_export",
"request": {
"method": "POST",
"path": "/v1/data/export",
"headers": {
"Content-Type": "application/xml"
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><request><userId>10001</userId><format>xml</format></request>"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/xml"
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><response><code>0</code><message>导出成功</message><data><user><id>10001</id><name>管理员</name><email>admin@example.com</email></user></data></response>"
}
}

View File

@@ -1,30 +0,0 @@
# XML 数据导出 - XML 格式
name: "data_export"
request:
method: "POST"
path: "/v1/data/export"
headers:
Content-Type: "application/xml"
body: |
<?xml version="1.0" encoding="UTF-8"?>
<request>
<userId>10001</userId>
<format>xml</format>
</request>
response:
status: 200
headers:
Content-Type: "application/xml"
body: |
<?xml version="1.0" encoding="UTF-8"?>
<response>
<code>0</code>
<message>导出成功</message>
<data>
<user>
<id>10001</id>
<name>管理员</name>
<email>admin@example.com</email>
</user>
</data>
</response>

14
mocks/v1/health.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "health_check",
"request": {
"method": "GET",
"path": "/v1/health"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"status\":\"healthy\",\"version\":\"2.0.0\",\"timestamp\":\"2026-03-27T10:00:00Z\"}"
}
}

View File

@@ -1,15 +0,0 @@
# 健康检查 - GET 无 Body
name: "health_check"
request:
method: "GET"
path: "/v1/health"
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"status": "healthy",
"version": "2.0.0",
"timestamp": "2026-03-27T10:00:00Z"
}

View File

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

View File

@@ -0,0 +1,16 @@
{
"name": "prod_export_pdf",
"request": {
"method": "GET",
"path": "/v1/products/report",
"body": "{\"username\":\"user001\",\"password\":\"password123\"}"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename=report.pdf"
},
"body": "file://./storage/v1/hello.pdf"
}
}

View File

@@ -1,73 +0,0 @@
# 上传头像 - Multipart 格式(数组形式,只匹配字段名)
- name: "user_upload_avatar_001"
request:
method: "POST"
path: "/v1/user/avatar"
headers:
Content-Type: "multipart/form-data"
body:
- "avatar1"
- "description1"
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "头像上传成功",
"data": {
"url": "https://cdn.example.com/v1/avatars/10001.jpg",
"size": 204800,
"filename": "avatar.jpg"
}
}
- name: "user_upload_avatar_002"
request:
method: "POST"
path: "/v1/user/avatar"
headers:
Content-Type: "multipart/form-data"
body:
avatar2: "avatar"
description2: "description"
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "头像上传成功",
"data": {
"url": "https://cdn.example.com/v1/avatars/10002.jpg",
"size": 204800,
"filename": "avatar.jpg"
}
}
- name: "user_upload_avatar_003"
request:
method: "POST"
path: "/v1/user/avatar"
headers:
Content-Type: "multipart/form-data"
body: >
{
"avatar3": "avatar"
"description3": "description"
}
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "头像上传成功",
"data": {
"url": "https://cdn.example.com/v1/avatars/10003.jpg",
"size": 204800,
"filename": "avatar.jpg"
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "user_upload_avatar_001",
"request": {
"method": "POST",
"path": "/v1/user/avatar",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": ["avatar1", "description1"]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10001.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "user_upload_avatar_002",
"request": {
"method": "POST",
"path": "/v1/user/avatar",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": {
"avatar2": "avatar",
"description2": "description"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10002.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "user_upload_avatar_003",
"request": {
"method": "POST",
"path": "/v1/user/avatar",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": {
"avatar3": "avatar",
"description3": "description"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"头像上传成功\",\"data\":{\"url\":\"https://cdn.example.com/v1/avatars/10003.jpg\",\"size\":204800,\"filename\":\"avatar.jpg\"}}"
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "user_download",
"request": {
"method": "GET",
"path": "/v1/user/download",
"query_params": {
"format": "json"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/octet-stream",
"Content-Disposition": "attachment; filename=user_data.json"
},
"body": "file://./storage/v1/user_data.json"
}
}

View File

@@ -1,13 +0,0 @@
# 下载用户数据文件 - file:// 协议
name: "user_download"
request:
method: "GET"
path: "/v1/user/download"
query_params:
format: "json"
response:
status: 200
headers:
Content-Type: "application/octet-stream"
Content-Disposition: "attachment; filename=user_data.json"
body: "file://./storage/v1/user_data.json"

18
mocks/v1/user/echo.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "user_echo",
"request": {
"method": "POST",
"path": "/v1/user/echo",
"headers": {
"Content-Type": "text/plain"
},
"body": "Hello V1 Mock Server"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "text/plain"
},
"body": "Echo from V1: Hello V1 Mock Server"
}
}

View File

@@ -1,13 +0,0 @@
# 文本回显 - Text 格式
name: "user_echo"
request:
method: "POST"
path: "/v1/user/echo"
headers:
Content-Type: "text/plain"
body: "Hello V1 Mock Server"
response:
status: 200
headers:
Content-Type: "text/plain"
body: "Echo from V1: Hello V1 Mock Server"

View File

@@ -0,0 +1,21 @@
{
"name": "_user_login_form",
"request": {
"method": "POST",
"path": "/v1/user/login/form",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"body": {
"username": "formuser",
"password": "formpass"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"表单登录成功\",\"data\":{\"token\":\"v2_form_token_xyz\",\"userId\":20001,\"username\":\"formuser\"}}"
}
}

View File

@@ -1,24 +0,0 @@
# 表单登录 - Form 格式
name: "_user_login_form"
request:
method: "POST"
path: "/v1/user/login/form"
headers:
Content-Type: "application/x-www-form-urlencoded"
body:
username: "formuser"
password: "formpass"
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "表单登录成功",
"data": {
"token": "v2_form_token_xyz",
"userId": 20001,
"username": "formuser"
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "user_profile",
"request": {
"method": "GET",
"path": "/v1/user/profile",
"headers": {
"Authorization": "Bearer v1_test_token"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"code\":0,\"message\":\"获取成功\",\"data\":{\"userId\":10001,\"username\":\"admin\",\"email\":\"admin@example.com\",\"nickname\":\"管理员\",\"avatar\":\"https://example.com/avatars/admin.jpg\",\"createdAt\":\"2025-01-01T00:00:00Z\"}}"
}
}

View File

@@ -1,24 +0,0 @@
# 获取用户信息 - GET 无 Body需要 Authorization Header
name: "user_profile"
request:
method: "GET"
path: "/v1/user/profile"
headers:
Authorization: "Bearer v1_test_token"
response:
status: 200
headers:
Content-Type: "application/json"
body: |
{
"code": 0,
"message": "获取成功",
"data": {
"userId": 10001,
"username": "admin",
"email": "admin@example.com",
"nickname": "管理员",
"avatar": "https://example.com/avatars/admin.jpg",
"createdAt": "2025-01-01T00:00:00Z"
}
}

View File

@@ -5,7 +5,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; // 必须引入 RwLock use std::sync::{Arc, RwLock};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::models::Payload; use crate::models::Payload;

View File

@@ -2,4 +2,7 @@
pub mod models; pub mod models;
pub mod loader; pub mod loader;
pub mod router; pub mod router;
pub mod handler; pub mod handler;
pub mod manager;
pub mod logging;
pub mod mcp;

View File

@@ -1,31 +1,34 @@
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::models::{MockRule, MockSource}; use crate::models::MockRule;
pub struct MockLoader; pub struct MockLoader;
impl MockLoader { impl MockLoader {
/// 递归扫描指定目录并构建索引表 /// 递归扫描指定目录并构建索引表
/// 目录结构mocks/{group}/{rule}.json
pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> { pub fn load_all_from_dir(dir: &Path) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new(); let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
// 1. 使用 walkdir 递归遍历目录,不限层级 if !dir.exists() {
return index;
}
// 1. 使用 walkdir 递归遍历目录,寻找 JSON 文件
for entry in WalkDir::new(dir) for entry in WalkDir::new(dir)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "yaml" || ext == "yml")) .filter(|e| e.path().extension().map_or(false, |ext| ext == "json"))
{ {
if let Some(rules) = Self::parse_yaml_file(entry.path()) { if let Some(rule) = Self::parse_json_file(entry.path()) {
for rule in rules { // 2. 提取路径首段作为索引 Key
// 2. 提取路径首段作为索引 Key let key = Self::extract_first_segment(&rule.request.path);
let key = Self::extract_first_segment(&rule.request.path);
// 3. 将规则插入到对应的索引桶中 // 3. 将规则插入到对应的索引桶中
index.entry(key).or_insert_with(Vec::new).push(rule); index.entry(key).or_insert_with(Vec::new).push(rule);
}
} }
} }
@@ -33,15 +36,20 @@ impl MockLoader {
index index
} }
/// 解析单个 YAML 文件,支持单接口和多接口模式 /// 解析单个 JSON 文件
fn parse_yaml_file(path: &Path) -> Option<Vec<MockRule>> { fn parse_json_file(path: &Path) -> Option<MockRule> {
let content = fs::read_to_string(path).ok()?; match fs::read_to_string(path) {
Ok(content) => {
// 利用 serde_yaml 的反序列化能力处理 MockSource 枚举 match serde_json::from_str::<MockRule>(&content) {
match serde_yaml::from_str::<MockSource>(&content) { Ok(rule) => Some(rule),
Ok(source) => Some(source.flatten()), // 统一打平为 Vec<MockRule> Err(e) => {
eprintln!("Failed to parse JSON at {:?}: {}", path, e);
None
}
}
}
Err(e) => { Err(e) => {
eprintln!("Failed to parse YAML at {:?}: {}", path, e); eprintln!("Failed to read file {:?}: {}", path, e);
None None
} }
} }
@@ -55,4 +63,4 @@ impl MockLoader {
.unwrap_or("root") // 如果是根路径 "/",则归类到 "root" .unwrap_or("root") // 如果是根路径 "/",则归类到 "root"
.to_string() .to_string()
} }
} }

View File

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

208
src/manager.rs Normal file
View File

@@ -0,0 +1,208 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::RwLock;
use crate::models::MockRule;
use crate::loader::MockLoader;
use crate::router::MockRouter;
/// Mock 规则管理器
/// 提供统一的 CRUD 操作,同时管理内存和文件持久化
pub struct MockManager {
/// mocks 目录的基础路径
base_path: PathBuf,
/// 内存中的规则分组group -> Vec<MockRule>
groups: RwLock<HashMap<String, Vec<MockRule>>>,
/// 路由索引
router: RwLock<MockRouter>,
}
impl MockManager {
/// 创建新的 MockManager
pub fn new(base_path: PathBuf) -> Self {
let groups = MockLoader::load_all_from_dir(&base_path);
let index = Self::build_index(&groups);
let router = MockRouter::new(index);
Self {
base_path,
groups: RwLock::new(groups),
router: RwLock::new(router),
}
}
/// 从分组构建路由索引
fn build_index(groups: &HashMap<String, Vec<MockRule>>) -> HashMap<String, Vec<MockRule>> {
let mut index: HashMap<String, Vec<MockRule>> = HashMap::new();
for rules in groups.values() {
for rule in rules {
let key = Self::extract_first_segment(&rule.request.path);
index.entry(key).or_default().push(rule.clone());
}
}
index
}
/// 提取路径首段
fn extract_first_segment(path: &str) -> String {
path.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("root")
.to_string()
}
/// 列出所有规则(可按分组过滤)
pub fn list(&self, group: Option<&str>) -> Vec<(String, MockRule)> {
let groups = self.groups.read().unwrap();
match group {
Some(g) => groups
.get(g)
.map(|rules| rules.iter().map(|r| (g.to_string(), r.clone())).collect())
.unwrap_or_default(),
None => groups
.iter()
.flat_map(|(g, rules)| rules.iter().map(|r| (g.clone(), r.clone())))
.collect(),
}
}
/// 获取单个规则
pub fn get(&self, group: &str, name: &str) -> Option<MockRule> {
let groups = self.groups.read().unwrap();
groups
.get(group)?
.iter()
.find(|r| r.name == name)
.cloned()
}
/// 创建规则
pub fn create(&self, group: &str, rule: MockRule) -> Result<(), String> {
// 1. 写入文件
let dir = self.base_path.join(group);
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?;
let file_path = dir.join(format!("{}.json", rule.name));
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
groups
.entry(group.to_string())
.or_default()
.push(rule.clone());
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 更新规则
pub fn update(&self, group: &str, name: &str, rule: MockRule) -> Result<(), String> {
// 1. 更新文件
let file_path = self.base_path.join(group).join(format!("{}.json", name));
// 如果 name 变化,需要删除旧文件
if name != rule.name {
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除旧文件失败: {}", e))?;
}
let new_path = self.base_path.join(group).join(format!("{}.json", rule.name));
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&new_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
} else {
let content =
serde_json::to_string_pretty(&rule).map_err(|e| format!("序列化失败: {}", e))?;
fs::write(&file_path, content).map_err(|e| format!("写入文件失败: {}", e))?;
}
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
if let Some(rules) = groups.get_mut(group) {
if let Some(pos) = rules.iter().position(|r| r.name == name) {
rules[pos] = rule.clone();
}
}
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 删除规则
pub fn delete(&self, group: &str, name: &str) -> Result<(), String> {
// 1. 删除文件
let file_path = self.base_path.join(group).join(format!("{}.json", name));
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
}
// 2. 更新内存
{
let mut groups = self.groups.write().unwrap();
if let Some(rules) = groups.get_mut(group) {
rules.retain(|r| r.name != name);
if rules.is_empty() {
groups.remove(group);
// 尝试删除空目录
let _ = fs::remove_dir(self.base_path.join(group));
}
}
}
// 3. 重建 router
self.rebuild_router();
Ok(())
}
/// 重载所有规则(从磁盘重新加载)
pub fn reload(&self) {
let new_groups = MockLoader::load_all_from_dir(&self.base_path);
let new_index = Self::build_index(&new_groups);
let new_router = MockRouter::new(new_index);
{
let mut groups = self.groups.write().unwrap();
*groups = new_groups;
}
{
let mut router = self.router.write().unwrap();
*router = new_router;
}
}
/// 重建 router 索引
fn rebuild_router(&self) {
let groups = self.groups.read().unwrap();
let index = Self::build_index(&groups);
let new_router = MockRouter::new(index);
let mut router = self.router.write().unwrap();
*router = new_router;
}
/// 获取 router 的读锁引用(用于 handler
pub fn get_router(&self) -> &RwLock<MockRouter> {
&self.router
}
/// 列出所有分组
pub fn list_groups(&self) -> Vec<String> {
let groups = self.groups.read().unwrap();
groups.keys().cloned().collect()
}
}

3
src/mcp/mod.rs Normal file
View File

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

199
src/mcp/server.rs Normal file
View File

@@ -0,0 +1,199 @@
use std::sync::Arc;
use rmcp::{
handler::server::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::*,
tool, tool_handler, tool_router,
ErrorData as McpError, ServerHandler,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::manager::MockManager;
use crate::models::MockRule;
/// MCP Server for Mock Server management
#[derive(Clone)]
pub struct MockMcpServer {
manager: Arc<MockManager>,
tool_router: ToolRouter<Self>,
}
/// Request for getting a specific mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
/// Request for creating a new mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CreateRuleRequest {
#[schemars(description = "Group name (directory name)")]
pub group: String,
#[schemars(description = "Mock rule definition (JSON object)")]
pub rule_json: String,
}
/// Request for updating a mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateRuleRequest {
#[schemars(description = "Group name")]
pub group: String,
#[schemars(description = "Current rule name")]
pub name: String,
#[schemars(description = "Updated rule definition (JSON object)")]
pub rule_json: String,
}
/// Request for deleting a mock rule
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteRuleRequest {
#[schemars(description = "Group name")]
pub group: String,
#[schemars(description = "Rule name")]
pub name: String,
}
/// Request for listing mock rules with optional filter
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListRulesRequest {
#[schemars(description = "Optional group name to filter by")]
pub group: Option<String>,
}
#[tool_router]
impl MockMcpServer {
pub fn new(manager: Arc<MockManager>) -> Self {
Self {
manager,
tool_router: Self::tool_router(),
}
}
#[tool(description = "List all mock rules, optionally filtered by group")]
async fn list_mock_rules(
&self,
params: Parameters<ListRulesRequest>,
) -> Result<CallToolResult, McpError> {
let rules = self.manager.list(params.0.group.as_deref());
let result = serde_json::to_string_pretty(&rules)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
#[tool(description = "Get a specific mock rule by group and name")]
async fn get_mock_rule(
&self,
params: Parameters<GetRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.get(&params.0.group, &params.0.name) {
Some(rule) => {
let result = serde_json::to_string_pretty(&rule)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
None => Ok(CallToolResult::error(vec![Content::text(
format!("Rule not found: {}/{}", params.0.group, params.0.name),
)])),
}
}
#[tool(description = "Create a new mock rule")]
async fn create_mock_rule(
&self,
params: Parameters<CreateRuleRequest>,
) -> Result<CallToolResult, McpError> {
let rule: MockRule = serde_json::from_str(&params.0.rule_json)
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
match self.manager.create(&params.0.group, rule) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Created rule in group '{}'", params.0.group),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Update an existing mock rule")]
async fn update_mock_rule(
&self,
params: Parameters<UpdateRuleRequest>,
) -> Result<CallToolResult, McpError> {
let rule: MockRule = serde_json::from_str(&params.0.rule_json)
.map_err(|e| McpError::invalid_params(format!("Invalid rule JSON: {}", e), None))?;
match self.manager.update(&params.0.group, &params.0.name, rule) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Updated rule {}/{}", params.0.group, params.0.name),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Delete a mock rule")]
async fn delete_mock_rule(
&self,
params: Parameters<DeleteRuleRequest>,
) -> Result<CallToolResult, McpError> {
match self.manager.delete(&params.0.group, &params.0.name) {
Ok(_) => Ok(CallToolResult::success(vec![Content::text(
format!("Deleted rule {}/{}", params.0.group, params.0.name),
)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
}
}
#[tool(description = "Reload all mock rules from disk")]
async fn reload_mock_rules(&self) -> Result<CallToolResult, McpError> {
self.manager.reload();
Ok(CallToolResult::success(vec![Content::text(
"Reloaded all rules from disk".to_string(),
)]))
}
#[tool(description = "List all groups (directories)")]
async fn list_groups(&self) -> Result<CallToolResult, McpError> {
let groups = self.manager.list_groups();
let result = serde_json::to_string_pretty(&groups)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
}
#[tool_handler]
impl ServerHandler for MockMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
instructions: Some("Mock Server MCP - Manage mock API rules for development.\n\nUse list_mock_rules to see all rules, create_mock_rule to add new ones, and delete_mock_rule to remove them.".to_string()),
..Default::default()
}
}
}
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::never::NeverSessionManager,
};
use tokio_util::sync::CancellationToken;
/// Create stateless MCP HTTP service for integration with Axum
pub fn create_mcp_http_service(
manager: Arc<MockManager>,
) -> StreamableHttpService<MockMcpServer, NeverSessionManager> {
StreamableHttpService::new(
move || Ok(MockMcpServer::new(manager.clone())),
Arc::new(NeverSessionManager::default()),
StreamableHttpServerConfig {
sse_keep_alive: None,
stateful_mode: false,
cancellation_token: CancellationToken::new(),
},
)
}

View File

@@ -46,26 +46,6 @@ impl Payload {
} }
} }
/// 顶层包装:支持单对象或数组,自动打平
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum MockSource {
/// 对应"一个接口一个文件"模式
Single(MockRule),
/// 对应"一个文件多个接口"模式
Multiple(Vec<MockRule>),
}
impl MockSource {
/// 将不同的解析模式统一转化为列表,供 Loader 构建索引
pub fn flatten(self) -> Vec<MockRule> {
match self {
Self::Single(rule) => vec![rule],
Self::Multiple(rules) => rules,
}
}
}
/// 核心 Mock 规则定义 /// 核心 Mock 规则定义
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule { pub struct MockRule {

View File

@@ -88,45 +88,49 @@ impl MockRouter {
} }
// D. Body 匹配(根据 Payload 类型智能比较) // D. Body 匹配(根据 Payload 类型智能比较)
if let Some(ref yaml_body) = rule.request.body { if let Some(ref rule_body) = rule.request.body {
return self.match_body(yaml_body, payload); return self.match_body(rule_body, payload);
} }
true true
} }
/// Body 匹配逻辑 /// Body 匹配逻辑
fn match_body(&self, yaml_body: &serde_json::Value, payload: &Payload) -> bool { fn match_body(&self, rule_body: &serde_json::Value, payload: &Payload) -> bool {
// 将 YAML body 规范化:如果是字符串,尝试解析为 JSON
let normalized_body = normalize_yaml_body(yaml_body);
match payload { match payload {
Payload::Json(actual) => { Payload::Json(actual) => {
// JSON 对象比较 // 如果 rule_body 是字符串,尝试解析为 JSON 比较
&normalized_body == actual if let Some(rule_str) = rule_body.as_str() {
// 尝试将字符串解析为 JSON
if let Ok(parsed_rule) = serde_json::from_str::<serde_json::Value>(rule_str) {
return &parsed_rule == actual;
}
}
// 直接比较
rule_body == actual
} }
Payload::Xml(actual) => { Payload::Xml(actual) => {
// XML 字符串比较(规范化后比较) // XML 字符串比较(规范化后比较)
normalized_body.as_str() rule_body.as_str()
.map(|expected| normalize_xml(expected) == normalize_xml(actual)) .map(|expected| normalize_xml(expected) == normalize_xml(actual))
.unwrap_or(false) .unwrap_or(false)
} }
Payload::Form(actual) => { Payload::Form(actual) => {
// Form 键值对比较(子集匹配) // Form 键值对比较(子集匹配)
compare_form_with_yaml(&normalized_body, actual) compare_form_with_json(rule_body, actual)
} }
Payload::Multipart(actual_data) => { Payload::Multipart(actual_data) => {
// Multipart 匹配:支持键值对或字段名列表 // Multipart 匹配:支持键值对或字段名列表
compare_multipart_with_yaml(&normalized_body, actual_data) compare_multipart_with_json(rule_body, actual_data)
} }
Payload::Text(actual) => { Payload::Text(actual) => {
// 字符串比较(去掉首尾空白) // 字符串比较(去掉首尾空白)
normalized_body.as_str() rule_body.as_str()
.map(|expected| expected.trim() == actual.trim()) .map(|expected| expected.trim() == actual.trim())
.unwrap_or_else(|| normalized_body.to_string().trim() == actual.trim()) .unwrap_or_else(|| rule_body.to_string().trim() == actual.trim())
} }
Payload::None => { Payload::None => {
false // YAML 配置了 body但请求没有 body false // 配置了 body但请求没有 body
} }
} }
} }
@@ -141,18 +145,6 @@ impl MockRouter {
} }
} }
/// 规范化 YAML body如果是字符串尝试解析为 JSON
fn normalize_yaml_body(yaml_body: &serde_json::Value) -> serde_json::Value {
if let Some(s) = yaml_body.as_str() {
// 先 trim 去掉前导/尾随空格,再尝试解析为 JSON
let trimmed = s.trim();
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
return parsed;
}
}
yaml_body.clone()
}
/// 规范化 XML 字符串:去掉声明、多余空白、格式化为紧凑形式 /// 规范化 XML 字符串:去掉声明、多余空白、格式化为紧凑形式
fn normalize_xml(xml: &str) -> String { fn normalize_xml(xml: &str) -> String {
let mut result = xml.to_string(); let mut result = xml.to_string();
@@ -182,15 +174,15 @@ fn normalize_xml(xml: &str) -> String {
result result
} }
/// Form 比较:YAML 中的键值对必须是请求的子集 /// Form 比较:JSON 中的键值对必须是请求的子集
fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool { fn compare_form_with_json(rule_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
let yaml_map = match yaml_body.as_object() { let rule_map = match rule_body.as_object() {
Some(obj) => obj, Some(obj) => obj,
None => return false, None => return false,
}; };
for (key, yaml_val) in yaml_map { for (key, rule_val) in rule_map {
let expected = yaml_val.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_val.to_string()); let expected = rule_val.as_str().map(|s| s.to_string()).unwrap_or_else(|| rule_val.to_string());
if actual.get(key) != Some(&expected) { if actual.get(key) != Some(&expected) {
return false; return false;
} }
@@ -199,10 +191,10 @@ fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String
} }
/// Multipart 比较:对象和数组形式都只匹配字段名是否存在 /// Multipart 比较:对象和数组形式都只匹配字段名是否存在
fn compare_multipart_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool { fn compare_multipart_with_json(rule_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
// 方式 1对象形式 - 只匹配键名是否存在(忽略值) // 方式 1对象形式 - 只匹配键名是否存在(忽略值)
if let Some(yaml_map) = yaml_body.as_object() { if let Some(rule_map) = rule_body.as_object() {
for key in yaml_map.keys() { for key in rule_map.keys() {
if !actual.contains_key(key) { if !actual.contains_key(key) {
return false; return false;
} }
@@ -211,9 +203,9 @@ fn compare_multipart_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<S
} }
// 方式 2数组形式 - 只匹配字段名是否存在 // 方式 2数组形式 - 只匹配字段名是否存在
if let Some(yaml_array) = yaml_body.as_array() { if let Some(rule_array) = rule_body.as_array() {
for yaml_field in yaml_array { for rule_field in rule_array {
let field_name = yaml_field.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_field.to_string()); let field_name = rule_field.as_str().map(|s| s.to_string()).unwrap_or_else(|| rule_field.to_string());
if !actual.contains_key(&field_name) { if !actual.contains_key(&field_name) {
return false; return false;
} }

View File

@@ -1,35 +1,25 @@
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::Write; use std::io::Write;
use tempfile::tempdir; use tempfile::tempdir;
use mock_server::models::MockSource; use mock_server::models::MockRule;
use mock_server::loader::MockLoader; use mock_server::loader::MockLoader;
#[test] #[test]
fn test_config_deserialization() { fn test_config_deserialization() {
// 测试 1验证单接口 YAML 解析 // 测试 1验证 JSON 解析
let single_yaml = r#" let single_json = r#"{
name: "auth_v1" "name": "auth_v1",
request: "request": {
method: "POST" "method": "POST",
path: "/api/v1/login" "path": "/api/v1/login"
response: },
status: 200 "response": {
body: "inline_content" "status": 200,
"#; "body": "inline_content"
let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口"); }
assert_eq!(res.flatten().len(), 1); }"#;
let res: MockRule = serde_json::from_str(single_json).expect("应该成功解析 JSON");
// 测试 2验证多接口 YAML 数组解析 assert_eq!(res.name, "auth_v1");
let multi_yaml = r#"
- name: "api_1"
request: { method: "GET", path: "/1" }
response: { status: 200, body: "b1" }
- name: "api_2"
request: { method: "GET", path: "/2" }
response: { status: 200, body: "b2" }
"#;
let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
assert_eq!(res_multi.flatten().len(), 2);
} }
#[test] #[test]
@@ -42,34 +32,39 @@ fn test_recursive_loading_logic() {
let user_dir = root_path.join("v1/user"); let user_dir = root_path.join("v1/user");
fs::create_dir_all(&user_dir).unwrap(); fs::create_dir_all(&user_dir).unwrap();
// 在深层目录创建单接口文件 // 在深层目录创建单接口 JSON 文件
let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap(); let mut file1 = File::create(user_dir.join("get_profile.json")).unwrap();
writeln!( writeln!(
file1, file1,
r#" r#"{{
name: "user_profile" "name": "user_profile",
request: "request": {{
method: "GET" "method": "GET",
path: "/api/v1/user/profile" "path": "/api/v1/user/profile"
response: }},
status: 200 "response": {{
body: "profile_data" "status": 200,
"# "body": "profile_data"
}}
}}"#
) )
.unwrap(); .unwrap();
// 在根目录创建多接口文件 // 在根目录创建另一个 JSON 文件
let mut file2 = File::create(root_path.join("system.yaml")).unwrap(); let mut file2 = File::create(root_path.join("health.json")).unwrap();
writeln!( writeln!(
file2, file2,
r#" r#"{{
- name: "sys_health" "name": "sys_health",
request: {{ method: "GET", path: "/health" }} "request": {{
response: {{ status: 200, body: "ok" }} "method": "GET",
- name: "sys_version" "path": "/health"
request: {{ method: "GET", path: "/version" }} }},
response: {{ status: 200, body: "1.0.0" }} "response": {{
"# "status": 200,
"body": "ok"
}}
}}"#
) )
.unwrap(); .unwrap();
@@ -80,11 +75,10 @@ fn test_recursive_loading_logic() {
// 1. 检查 Key 是否根据路径首段正确提取 // 1. 检查 Key 是否根据路径首段正确提取
assert!(index.contains_key("api"), "索引应包含 'api' 键"); assert!(index.contains_key("api"), "索引应包含 'api' 键");
assert!(index.contains_key("health"), "索引应包含 'health' 键"); assert!(index.contains_key("health"), "索引应包含 'health' 键");
assert!(index.contains_key("version"), "索引应包含 'version' 键");
// 2. 检查规则总数 // 2. 检查规则总数
let total_rules: usize = index.values().map(|v| v.len()).sum(); let total_rules: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total_rules, 3, "总规则数应为 3"); assert_eq!(total_rules, 2, "总规则数应为 2");
// 3. 检查深层文件是否被正确读取 // 3. 检查深层文件是否被正确读取
let api_rules = &index["api"]; let api_rules = &index["api"];

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;
use serde_json::json; use serde_json::json;
use mock_server::models::{MockRule, MockSource, Payload}; use mock_server::models::{MockRule, Payload};
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;
@@ -11,42 +11,27 @@ use std::collections::HashMap;
#[test] #[test]
fn test_config_parsing_scenarios() { fn test_config_parsing_scenarios() {
// 场景 A: 验证单接口配置 (增加 body 结构化校验) // 场景 A: 验证单接口配置 (增加 body 结构化校验)
let yaml_single = r#" let json_single = r#"{
name: "auth_v1" "name": "auth_v1",
request: "request": {
method: "POST" "method": "POST",
path: "/api/v1/login" "path": "/api/v1/login",
body: { "user": "admin" } "body": { "user": "admin" }
response: { status: 200, body: "welcome" } },
"#; "response": { "status": 200, "body": "welcome" }
let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败"); }"#;
let rules = source_s.flatten(); let rule: MockRule = serde_json::from_str(json_single).expect("解析单接口失败");
assert_eq!(rules.len(), 1); assert!(rule.request.body.is_some());
// 验证 body 是否被成功解析为 Value::Object assert_eq!(rule.request.body.as_ref().unwrap()["user"], "admin");
assert!(rules[0].request.body.is_some());
assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin");
// 场景 B: 验证多接口配置 // 场景 B: 验证 Smart Body 的 file:// 协议字符串解析
let yaml_multi = r#" let json_file = r#"{
- name: "api_1" "name": "export_api",
request: { method: "GET", path: "/health" } "request": { "method": "GET", "path": "/download" },
response: { status: 200, body: "ok" } "response": { "status": 200, "body": "file://./storage/data.zip" }
- name: "api_2" }"#;
request: { method: "GET", path: "/version" } let rule_file: MockRule = serde_json::from_str(json_file).unwrap();
response: { status: 200, body: "1.0" } assert!(rule_file.response.body.starts_with("file://"));
"#;
let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败");
assert_eq!(source_m.flatten().len(), 2);
// 场景 C: 验证 Smart Body 的 file:// 协议字符串解析
let yaml_file = r#"
name: "export_api"
request: { method: "GET", path: "/download" }
response: { status: 200, body: "file://./storage/data.zip" }
"#;
let source_f: MockSource = serde_yaml::from_str(yaml_file).unwrap();
let rule = &source_f.flatten()[0];
assert!(rule.response.body.starts_with("file://"));
} }
/// 模块二:验证 Loader 递归扫描与索引构建 /// 模块二:验证 Loader 递归扫描与索引构建
@@ -58,11 +43,11 @@ fn test_loader_recursive_indexing() {
let auth_path = root_path.join("v1/auth"); let auth_path = root_path.join("v1/auth");
fs::create_dir_all(&auth_path).unwrap(); fs::create_dir_all(&auth_path).unwrap();
let mut f1 = File::create(auth_path.join("single_login.yaml")).unwrap(); let mut f1 = File::create(auth_path.join("login.json")).unwrap();
writeln!(f1, "name: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap(); writeln!(f1, r#"{{"name":"l1","request":{{"method":"POST","path":"/api/v1/login"}},"response":{{"status":200,"body":"ok"}}}}"#).unwrap();
let mut f2 = File::create(root_path.join("sys.yaml")).unwrap(); let mut f2 = File::create(root_path.join("health.json")).unwrap();
writeln!(f2, "- name: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap(); writeln!(f2, r#"{{"name":"s1","request":{{"method":"GET","path":"/health"}},"response":{{"status":200,"body":"up"}}}}"#).unwrap();
let index = MockLoader::load_all_from_dir(root_path); let index = MockLoader::load_all_from_dir(root_path);
@@ -78,21 +63,19 @@ fn test_router_matching_logic() {
let mut index = HashMap::new(); let mut index = HashMap::new();
// 1. 准备带有 Body 的规则 // 1. 准备带有 Body 的规则
let rule_auth = serde_yaml::from_str::<MockSource>( let rule_json = r#"{
r#" "name": "auth_v1",
name: "auth_v1" "request": {
request: "method": "POST",
method: "POST" "path": "/api/v1/login",
path: "/api/v1/login" "headers": { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json" } "body": { "code": 123 }
body: { "code": 123 } },
response: { status: 200, body: "token_123" } "response": { "status": 200, "body": "token_123" }
"#, }"#;
) let rule_auth: MockRule = serde_json::from_str(rule_json).unwrap();
.unwrap()
.flatten();
index.insert("api".to_string(), vec![rule_auth[0].clone()]); index.insert("api".to_string(), vec![rule_auth.clone()]);
let router = MockRouter::new(index); let router = MockRouter::new(index);
// 2. 测试场景 AJSON 完全匹配 // 2. 测试场景 AJSON 完全匹配
@@ -139,48 +122,39 @@ fn test_payload_type_matching() {
let mut index = HashMap::new(); let mut index = HashMap::new();
// XML 规则 // XML 规则
let xml_rule = serde_yaml::from_str::<MockSource>( let xml_rule: MockRule = serde_json::from_str(r#"{
r#" "name": "xml_api",
name: "xml_api" "request": {
request: "method": "POST",
method: "POST" "path": "/api/xml",
path: "/api/xml" "body": "<root><name>test</name></root>"
body: "<root><name>test</name></root>" },
response: { status: 200, body: "ok" } "response": { "status": 200, "body": "ok" }
"#, }"#).unwrap();
)
.unwrap()
.flatten();
// Form 规则 // Form 规则
let form_rule = serde_yaml::from_str::<MockSource>( let form_rule: MockRule = serde_json::from_str(r#"{
r#" "name": "form_api",
name: "form_api" "request": {
request: "method": "POST",
method: "POST" "path": "/api/form",
path: "/api/form" "body": { "username": "admin", "password": "123456" }
body: { "username": "admin", "password": "123456" } },
response: { status: 200, body: "login_ok" } "response": { "status": 200, "body": "login_ok" }
"#, }"#).unwrap();
)
.unwrap()
.flatten();
// Text 规则 // Text 规则
let text_rule = serde_yaml::from_str::<MockSource>( let text_rule: MockRule = serde_json::from_str(r#"{
r#" "name": "text_api",
name: "text_api" "request": {
request: "method": "POST",
method: "POST" "path": "/api/text",
path: "/api/text" "body": "plain text content"
body: "plain text content" },
response: { status: 200, body: "text_ok" } "response": { "status": 200, "body": "text_ok" }
"#, }"#).unwrap();
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![xml_rule[0].clone(), form_rule[0].clone(), text_rule[0].clone()]); index.insert("api".to_string(), vec![xml_rule, form_rule, text_rule]);
let router = MockRouter::new(index); let router = MockRouter::new(index);
// 测试 XML 匹配 // 测试 XML 匹配
@@ -252,34 +226,28 @@ fn test_multipart_matching() {
let mut index = HashMap::new(); let mut index = HashMap::new();
// 数组形式:只匹配字段名 // 数组形式:只匹配字段名
let array_rule = serde_yaml::from_str::<MockSource>( let array_rule: MockRule = serde_json::from_str(r#"{
r#" "name": "upload_array",
name: "upload_array" "request": {
request: "method": "POST",
method: "POST" "path": "/api/upload/array",
path: "/api/upload/array" "body": ["file", "description"]
body: ["file", "description"] },
response: { status: 200, body: "ok" } "response": { "status": 200, "body": "ok" }
"#, }"#).unwrap();
)
.unwrap()
.flatten();
// 对象形式:匹配键值对 // 对象形式:匹配键
let object_rule = serde_yaml::from_str::<MockSource>( let object_rule: MockRule = serde_json::from_str(r#"{
r#" "name": "upload_object",
name: "upload_object" "request": {
request: "method": "POST",
method: "POST" "path": "/api/upload/object",
path: "/api/upload/object" "body": { "username": "admin", "role": "user" }
body: { "username": "admin", "role": "user" } },
response: { status: 200, body: "ok" } "response": { "status": 200, "body": "ok" }
"#, }"#).unwrap();
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![array_rule[0].clone(), object_rule[0].clone()]); index.insert("api".to_string(), vec![array_rule, object_rule]);
let router = MockRouter::new(index); let router = MockRouter::new(index);
// 测试数组形式:匹配字段名 // 测试数组形式:匹配字段名
@@ -299,7 +267,7 @@ response: { status: 200, body: "ok" }
// 测试对象形式:键名匹配(值被忽略) // 测试对象形式:键名匹配(值被忽略)
let mut correct_data = HashMap::new(); let mut correct_data = HashMap::new();
correct_data.insert("username".to_string(), "any_value".to_string()); // 值不重要 correct_data.insert("username".to_string(), "any_value".to_string());
correct_data.insert("role".to_string(), "any_role".to_string()); correct_data.insert("role".to_string(), "any_role".to_string());
let correct_payload = Payload::Multipart(correct_data); let correct_payload = Payload::Multipart(correct_data);
let object_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &correct_payload); let object_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &correct_payload);
@@ -308,118 +276,43 @@ response: { status: 200, body: "ok" }
// 测试对象形式:键名不匹配 // 测试对象形式:键名不匹配
let mut wrong_data = HashMap::new(); let mut wrong_data = HashMap::new();
wrong_data.insert("username".to_string(), "admin".to_string()); wrong_data.insert("username".to_string(), "admin".to_string());
wrong_data.insert("other_field".to_string(), "value".to_string()); // 缺少 role 字段 wrong_data.insert("other_field".to_string(), "value".to_string());
let wrong_payload = Payload::Multipart(wrong_data); let wrong_payload = Payload::Multipart(wrong_data);
let wrong_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &wrong_payload); let wrong_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &wrong_payload);
assert!(wrong_matched.is_none(), "缺少键名不应该成功"); assert!(wrong_matched.is_none(), "缺少键名不应该成功");
} }
/// 模块七:验证 YAML 块语法(折叠 `>` 和字面量 `|` /// 模块七:验证 XML 格式化匹配
#[test]
fn test_yaml_block_syntax() {
let mut index = HashMap::new();
// 使用折叠语法 `>` 的规则
let folded_rule = serde_yaml::from_str::<MockSource>(
r#"
name: "folded_api"
request:
method: "POST"
path: "/api/folded"
body: >
{"username":"admin","password":"123456"}
response:
status: 200
body: "ok"
"#,
)
.unwrap()
.flatten();
// 使用字面量语法 `|` 的规则
let literal_rule = serde_yaml::from_str::<MockSource>(
r#"
name: "literal_api"
request:
method: "POST"
path: "/api/literal"
body: |
{"username":"test","password":"abcdef"}
response:
status: 200
body: "ok"
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![folded_rule[0].clone(), literal_rule[0].clone()]);
let router = MockRouter::new(index);
// 测试折叠语法YAML 中 `>` 会把内容合并成一行,但仍是字符串
// 程序应该能将字符串解析为 JSON 后匹配
let folded_payload = Payload::Json(json!({"username":"admin","password":"123456"}));
let folded_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &folded_payload);
assert!(folded_matched.is_some(), "折叠语法应该匹配 JSON 请求");
assert_eq!(folded_matched.unwrap().name, "folded_api");
// 测试字面量语法YAML 中 `|` 保留换行
let literal_payload = Payload::Json(json!({"username":"test","password":"abcdef"}));
let literal_matched = router.match_rule("POST", "/api/literal", &HashMap::new(), &HashMap::new(), &literal_payload);
assert!(literal_matched.is_some(), "字面量语法应该匹配 JSON 请求");
assert_eq!(literal_matched.unwrap().name, "literal_api");
// 测试不匹配的情况
let wrong_payload = Payload::Json(json!({"username":"wrong","password":"wrong"}));
let wrong_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &wrong_payload);
assert!(wrong_matched.is_none(), "错误的 body 不应该匹配");
}
/// 模块八:验证 XML 格式化匹配
#[test] #[test]
fn test_xml_normalized_matching() { fn test_xml_normalized_matching() {
let mut index = HashMap::new(); let mut index = HashMap::new();
// YAML 中的 XML格式 // JSON 中的 XML紧凑格式)
let xml_rule = serde_yaml::from_str::<MockSource>( let xml_rule: MockRule = serde_json::from_str(r#"{
r#" "name": "xml_api",
name: "xml_api" "request": {
request: "method": "POST",
method: "POST" "path": "/api/xml",
path: "/api/xml" "body": "<user><id>1001</id><name>张三</name></user>"
body: | },
<user> "response": { "status": 200, "body": "ok" }
<id>1001</id> }"#).unwrap();
<name>张三</name>
</user>
response:
status: 200
body: "ok"
"#,
)
.unwrap()
.flatten();
index.insert("api".to_string(), vec![xml_rule[0].clone()]); index.insert("api".to_string(), vec![xml_rule]);
let router = MockRouter::new(index); let router = MockRouter::new(index);
// 测试 1完全相同的格式 XML // 测试 1紧凑格式 XML
let formatted_xml = Payload::Xml("<user>\n <id>1001</id>\n <name>张三</name>\n</user>".to_string());
let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &formatted_xml);
assert!(matched1.is_some(), "格式化 XML 应该匹配");
// 测试 2紧凑格式的 XML
let compact_xml = Payload::Xml("<user><id>1001</id><name>张三</name></user>".to_string()); let compact_xml = Payload::Xml("<user><id>1001</id><name>张三</name></user>".to_string());
let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml); let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml);
assert!(matched2.is_some(), "紧凑格式 XML 应该匹配"); assert!(matched1.is_some(), "紧凑格式 XML 应该匹配");
// 测试 3:带 XML 声明的请求 // 测试 2:带 XML 声明的请求
let xml_with_decl = Payload::Xml("<?xml version=\"1.0\" encoding=\"UTF-8\"?><user><id>1001</id><name>张三</name></user>".to_string()); let xml_with_decl = Payload::Xml("<?xml version=\"1.0\" encoding=\"UTF-8\"?><user><id>1001</id><name>张三</name></user>".to_string());
let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl); let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl);
assert!(matched3.is_some(), "带声明的 XML 应该匹配"); assert!(matched2.is_some(), "带声明的 XML 应该匹配");
// 测试 4:内容不同,不应该匹配 // 测试 3:内容不同,不应该匹配
let wrong_xml = Payload::Xml("<user><id>1002</id><name>李四</name></user>".to_string()); let wrong_xml = Payload::Xml("<user><id>1002</id><name>李四</name></user>".to_string());
let matched4 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml); let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml);
assert!(matched4.is_none(), "内容不同的 XML 不应该匹配"); assert!(matched3.is_none(), "内容不同的 XML 不应该匹配");
} }

View File

@@ -6,37 +6,39 @@ use mock_server::loader::MockLoader;
use mock_server::router::MockRouter; use mock_server::router::MockRouter;
/// 加载 v1 目录的 mock 规则 /// 加载 v1 目录的 mock 规则
fn load_v2_mocks() -> HashMap<String, Vec<mock_server::models::MockRule>> { fn load_v1_mocks() -> HashMap<String, Vec<mock_server::models::MockRule>> {
let v2_path = Path::new("../mocks/v1"); let v1_path = Path::new("./mocks/v1");
MockLoader::load_all_from_dir(v2_path) MockLoader::load_all_from_dir(v1_path)
} }
// ========== 模块一:验证所有 YAML 文件正确加载 ========== // ========== 模块一:验证所有 JSON 文件正确加载 ==========
#[test] #[test]
fn test_v2_load_all_mocks() { fn test_v1_load_all_mocks() {
let index = load_v2_mocks(); let index = load_v1_mocks();
// 验证索引键存在 // 验证索引键存在
assert!(index.contains_key("v1"), "应包含 'v2' 索引键"); assert!(index.contains_key("v1"), "应包含 'v1' 索引键");
// 验证规则总数 // 验证规则总数
let total: usize = index.values().map(|v| v.len()).sum(); let total: usize = index.values().map(|v| v.len()).sum();
assert_eq!(total, 9, "v2 目录应有 9 个 mock 规则"); assert!(total >= 10, "v1 目录应有至少 10 个 mock 规则");
} }
// ========== 模块二JSON Payload 测试 ========== // ========== 模块二JSON Payload 测试 ==========
#[test] #[test]
fn test_v2_json_login() { fn test_v1_json_login() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string()); headers.insert("Content-Type".to_string(), "application/json".to_string());
headers.insert("Authorization".to_string(), "eyJhbGciOiJIUzI1NiIsInR5cCI6".to_string());
headers.insert("host".to_string(), "127.0.0.1:8080".to_string());
let payload = Payload::Json(json!({ let payload = Payload::Json(json!({
"username": "admin", "username": "user001",
"password": "password123" "password": "password123"
})); }));
@@ -44,13 +46,13 @@ fn test_v2_json_login() {
assert!(matched.is_some(), "JSON 登录应匹配成功"); assert!(matched.is_some(), "JSON 登录应匹配成功");
let rule = matched.unwrap(); let rule = matched.unwrap();
assert_eq!(rule.name, "v2_user_login"); assert_eq!(rule.name, "user_login_001");
assert_eq!(rule.response.status, 200); assert_eq!(rule.response.status, 200);
} }
#[test] #[test]
fn test_v2_json_register() { fn test_v1_json_register() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut headers = HashMap::new(); let mut headers = HashMap::new();
@@ -66,15 +68,15 @@ fn test_v2_json_register() {
assert!(matched.is_some(), "JSON 注册应匹配成功"); assert!(matched.is_some(), "JSON 注册应匹配成功");
let rule = matched.unwrap(); let rule = matched.unwrap();
assert_eq!(rule.name, "v2_user_register"); assert_eq!(rule.name, "user_register");
assert_eq!(rule.response.status, 201); assert_eq!(rule.response.status, 201);
} }
// ========== 模块三Form Payload 测试 ========== // ========== 模块三Form Payload 测试 ==========
#[test] #[test]
fn test_v2_form_login() { fn test_v1_form_login() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut headers = HashMap::new(); let mut headers = HashMap::new();
@@ -88,74 +90,74 @@ fn test_v2_form_login() {
let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload); let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Form 登录应匹配成功"); assert!(matched.is_some(), "Form 登录应匹配成功");
assert_eq!(matched.unwrap().name, "v2_user_login_form"); assert_eq!(matched.unwrap().name, "_user_login_form");
} }
// ========== 模块四Text Payload 测试 ========== // ========== 模块四Text Payload 测试 ==========
#[test] #[test]
fn test_v2_text_echo() { fn test_v1_text_echo() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "text/plain".to_string()); headers.insert("Content-Type".to_string(), "text/plain".to_string());
let payload = Payload::Text("Hello V2 Mock Server".to_string()); let payload = Payload::Text("Hello V1 Mock Server".to_string());
let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload); let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Text 回显应匹配成功"); assert!(matched.is_some(), "Text 回显应匹配成功");
let rule = matched.unwrap(); let rule = matched.unwrap();
assert_eq!(rule.name, "v2_user_echo"); assert_eq!(rule.name, "user_echo");
assert!(rule.response.body.contains("Echo from V2")); assert!(rule.response.body.contains("Echo from V1"));
} }
// ========== 模块五XML Payload 测试 ========== // ========== 模块五XML Payload 测试 ==========
#[test] #[test]
fn test_v2_xml_export() { fn test_v1_xml_export() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/xml".to_string()); headers.insert("Content-Type".to_string(), "application/xml".to_string());
let xml_body = r#"<request><userId>10001</userId><format>xml</format></request>"#; let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?><request><userId>10001</userId><format>xml</format></request>"#;
let payload = Payload::Xml(xml_body.to_string()); let payload = Payload::Xml(xml_body.to_string());
let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload); let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "XML 导出应匹配成功"); assert!(matched.is_some(), "XML 导出应匹配成功");
assert_eq!(matched.unwrap().name, "v2_data_export"); assert_eq!(matched.unwrap().name, "data_export");
} }
// ========== 模块六Multipart Payload 测试 ========== // ========== 模块六Multipart Payload 测试 ==========
#[test] #[test]
fn test_v2_multipart_upload() { fn test_v1_multipart_upload() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "multipart/form-data".to_string()); headers.insert("Content-Type".to_string(), "multipart/form-data".to_string());
let mut multipart_data = HashMap::new(); let mut multipart_data = HashMap::new();
multipart_data.insert("avatar".to_string(), "file_content".to_string()); multipart_data.insert("avatar1".to_string(), "file_content".to_string());
multipart_data.insert("description".to_string(), "user avatar".to_string()); multipart_data.insert("description1".to_string(), "user avatar".to_string());
let payload = Payload::Multipart(multipart_data); let payload = Payload::Multipart(multipart_data);
let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload); let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "Multipart 上传应匹配成功"); assert!(matched.is_some(), "Multipart 上传应匹配成功");
assert_eq!(matched.unwrap().name, "v2_user_upload_avatar"); assert_eq!(matched.unwrap().name, "user_upload_avatar_001");
} }
// ========== 模块七None Payload 测试 (GET 无 Body) ========== // ========== 模块七None Payload 测试 (GET 无 Body) ==========
#[test] #[test]
fn test_v2_health_check() { fn test_v1_health_check() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let payload = Payload::None; let payload = Payload::None;
@@ -164,31 +166,31 @@ fn test_v2_health_check() {
assert!(matched.is_some(), "健康检查应匹配成功"); assert!(matched.is_some(), "健康检查应匹配成功");
let rule = matched.unwrap(); let rule = matched.unwrap();
assert_eq!(rule.name, "v2_health_check"); assert_eq!(rule.name, "health_check");
assert_eq!(rule.response.status, 200); assert_eq!(rule.response.status, 200);
} }
#[test] #[test]
fn test_v2_get_profile() { fn test_v1_get_profile() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v2_test_token".to_string()); headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let payload = Payload::None; let payload = Payload::None;
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload); let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "获取用户信息应匹配成功"); assert!(matched.is_some(), "获取用户信息应匹配成功");
assert_eq!(matched.unwrap().name, "v2_user_profile"); assert_eq!(matched.unwrap().name, "user_profile");
} }
// ========== 模块八Query Params 测试 ========== // ========== 模块八Query Params 测试 ==========
#[test] #[test]
fn test_v2_query_params() { fn test_v1_query_params() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
let mut query = HashMap::new(); let mut query = HashMap::new();
@@ -200,15 +202,15 @@ fn test_v2_query_params() {
assert!(matched.is_some(), "带 query params 的下载应匹配成功"); assert!(matched.is_some(), "带 query params 的下载应匹配成功");
let rule = matched.unwrap(); let rule = matched.unwrap();
assert_eq!(rule.name, "v2_user_download"); assert_eq!(rule.name, "user_download");
assert!(rule.response.body.starts_with("file://")); assert!(rule.response.body.starts_with("file://"));
} }
// ========== 模块九Header 匹配测试 ========== // ========== 模块九Header 匹配测试 ==========
#[test] #[test]
fn test_v2_header_required() { fn test_v1_header_required() {
let index = load_v2_mocks(); let index = load_v1_mocks();
let router = MockRouter::new(index); let router = MockRouter::new(index);
// 无 Authorization header 不应匹配 // 无 Authorization header 不应匹配
@@ -218,21 +220,7 @@ fn test_v2_header_required() {
// 有正确的 Authorization header 应匹配 // 有正确的 Authorization header 应匹配
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer v2_test_token".to_string()); headers.insert("Authorization".to_string(), "Bearer v1_test_token".to_string());
let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload); let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
assert!(matched.is_some(), "有 Authorization 应匹配"); assert!(matched.is_some(), "有 Authorization 应匹配");
} }
// ========== 模块十name 字段验证 ==========
#[test]
fn test_v2_all_rules_have_name() {
let index = load_v2_mocks();
for (key, rules) in &index {
for rule in rules {
assert!(!rule.name.is_empty(), "规则 name 不能为空");
assert!(rule.name.starts_with("v2_"), "v2 规则 name 应以 'v2_' 开头");
}
}
}