Files
mock-server/plan2.md
2026-03-26 19:20:22 +08:00

376 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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