临时提交

This commit is contained in:
2026-03-26 19:20:22 +08:00
parent 9c1d0e16b4
commit 78fb8951ab
68 changed files with 908 additions and 19 deletions

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