临时提交
This commit is contained in:
375
plan2.md
Normal file
375
plan2.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Body 匹配优化:基于 Content-Type 的智能解析
|
||||
|
||||
## Context
|
||||
|
||||
**问题**: 当前只支持 JSON body 匹配,非 JSON 请求无法正确匹配。
|
||||
|
||||
**需求**: 根据请求的 Content-Type 智能解析 body,同时支持 header 匹配校验。
|
||||
|
||||
---
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 关键原则
|
||||
|
||||
1. **Body 解析**:始终以【请求的 Content-Type】为准,因为这是数据的真实格式
|
||||
2. **Header 匹配**:Content-Type 当作普通 header 处理,写了就匹配,没写跳过
|
||||
3. **自动补充的 header**:Content-Length、Accept-Encoding 等不参与匹配(除非 YAML 显式配置)
|
||||
|
||||
### 处理流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 请求处理流程 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. 解析请求 body │
|
||||
│ └── 始终以【请求的 Content-Type】为准解析 │
|
||||
│ │
|
||||
│ 2. Header 匹配(包含 Content-Type) │
|
||||
│ └── YAML 写了就匹配,没写就跳过 │
|
||||
│ │
|
||||
│ 3. Body 匹配 │
|
||||
│ └── 根据匹配成功的 Content-Type 决定比较方式 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 匹配示例
|
||||
|
||||
| YAML Content-Type | 请求 Content-Type | Header 匹配 | Body 解析 | 结果 |
|
||||
|-------------------|-------------------|-------------|-----------|------|
|
||||
| `application/xml` | `application/json` | 失败 | - | 不匹配 |
|
||||
| `application/json` | `application/json` | 成功 | JSON | 比较 body |
|
||||
| 无 | `application/json` | 跳过 | JSON | 比较 body |
|
||||
| 无 | `application/xml` | 跳过 | XML | 比较 body |
|
||||
| 无 | 无 | 跳过 | 字符串 | 比较 body |
|
||||
|
||||
---
|
||||
|
||||
## 实现方案
|
||||
|
||||
### Step 1: 新增数据结构 (model.rs)
|
||||
|
||||
```rust
|
||||
/// 解析后的请求 Body
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ParsedBody {
|
||||
Json(serde_json::Value),
|
||||
Xml(String),
|
||||
Form(HashMap<String, String>),
|
||||
Multipart(Vec<String>), // 字段名列表
|
||||
Text(String),
|
||||
None,
|
||||
}
|
||||
|
||||
impl ParsedBody {
|
||||
/// 转换为字符串(用于兜底比较)
|
||||
pub fn to_compare_string(&self) -> String {
|
||||
match self {
|
||||
ParsedBody::Json(v) => v.to_string(),
|
||||
ParsedBody::Xml(s) | ParsedBody::Text(s) => s.clone(),
|
||||
ParsedBody::Form(map) => {
|
||||
let mut pairs: Vec<_> = map.iter().collect();
|
||||
pairs.sort_by_key(|(k, _)| *k);
|
||||
pairs.iter().map(|(k, v)| format!("{}={}", k, v)).join("&")
|
||||
}
|
||||
ParsedBody::Multipart(fields) => fields.join(","),
|
||||
ParsedBody::None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取对应的 Content-Type 名称
|
||||
pub fn content_type_name(&self) -> &'static str {
|
||||
match self {
|
||||
ParsedBody::Json(_) => "application/json",
|
||||
ParsedBody::Xml(_) => "application/xml",
|
||||
ParsedBody::Form(_) => "application/x-www-form-urlencoded",
|
||||
ParsedBody::Multipart(_) => "multipart/form-data",
|
||||
ParsedBody::Text(_) => "text/plain",
|
||||
ParsedBody::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Body 解析函数 (handler.rs)
|
||||
|
||||
```rust
|
||||
/// 提取请求的 Content-Type(去掉参数部分,如 boundary)
|
||||
fn extract_content_type(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(';').next().unwrap_or(s).trim().to_lowercase())
|
||||
}
|
||||
|
||||
/// 根据 Content-Type 解析 Body(始终以请求的 Content-Type 为准)
|
||||
fn parse_body(content_type: Option<&str>, bytes: &[u8]) -> ParsedBody {
|
||||
if bytes.is_empty() {
|
||||
return ParsedBody::None;
|
||||
}
|
||||
|
||||
match content_type {
|
||||
Some(ct) if ct.contains("application/json") => {
|
||||
serde_json::from_slice(bytes)
|
||||
.map(ParsedBody::Json)
|
||||
.unwrap_or_else(|_| {
|
||||
// JSON 解析失败,降级为文本
|
||||
ParsedBody::Text(String::from_utf8_lossy(bytes).to_string())
|
||||
})
|
||||
}
|
||||
Some(ct) if ct.contains("xml") => {
|
||||
ParsedBody::Xml(String::from_utf8_lossy(bytes).to_string())
|
||||
}
|
||||
Some(ct) if ct.contains("form-urlencoded") => {
|
||||
ParsedBody::Form(parse_urlencoded(bytes))
|
||||
}
|
||||
Some(ct) if ct.contains("multipart/form-data") => {
|
||||
ParsedBody::Multipart(extract_multipart_fields(bytes))
|
||||
}
|
||||
_ => {
|
||||
ParsedBody::Text(String::from_utf8_lossy(bytes).to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 urlencoded 格式
|
||||
fn parse_urlencoded(bytes: &[u8]) -> HashMap<String, String> {
|
||||
let body = String::from_utf8_lossy(bytes);
|
||||
let mut map = HashMap::new();
|
||||
for pair in body.split('&') {
|
||||
if let Some((key, value)) = pair.split_once('=') {
|
||||
// URL 解码
|
||||
let decoded_key = urlencoding_decode(key);
|
||||
let decoded_value = urlencoding_decode(value);
|
||||
map.insert(decoded_key, decoded_value);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 修改 handler.rs 主函数
|
||||
|
||||
```rust
|
||||
pub async fn mock_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
req: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
let path = req.uri().path().to_string();
|
||||
let method_str = method.as_str().to_string();
|
||||
|
||||
// 1. 提取请求的 Content-Type
|
||||
let req_content_type = extract_content_type(&headers);
|
||||
|
||||
// 2. 读取请求 body
|
||||
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("Read body error"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 根据【请求的 Content-Type】解析 body
|
||||
let parsed_body = parse_body(req_content_type.as_deref(), &body_bytes);
|
||||
|
||||
// 4. 转换 headers 为 HashMap
|
||||
let mut req_headers = HashMap::new();
|
||||
for (name, value) in headers.iter() {
|
||||
if let Ok(v) = value.to_str() {
|
||||
req_headers.insert(name.as_str().to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行匹配
|
||||
let maybe_rule = {
|
||||
let router = state.router.read().expect("Failed to acquire read lock");
|
||||
router.match_rule(&method_str, &path, ¶ms, &req_headers, &parsed_body).cloned()
|
||||
};
|
||||
|
||||
// 6. 构建响应(与现有逻辑相同)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: 修改 router.rs 匹配逻辑
|
||||
|
||||
```rust
|
||||
/// 核心匹配函数
|
||||
pub fn match_rule(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
queries: &HashMap<String, String>,
|
||||
headers: &HashMap<String, String>,
|
||||
parsed_body: &ParsedBody, // 改为 ParsedBody
|
||||
) -> Option<&MockRule> {
|
||||
let key = self.extract_first_segment(path);
|
||||
|
||||
if let Some(rules) = self.index.get(&key) {
|
||||
for rule in rules {
|
||||
if self.is_match(rule, method, path, queries, headers, parsed_body) {
|
||||
return Some(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_match(
|
||||
&self,
|
||||
rule: &MockRule,
|
||||
method: &str,
|
||||
path: &str,
|
||||
queries: &HashMap<String, String>,
|
||||
headers: &HashMap<String, String>,
|
||||
parsed_body: &ParsedBody,
|
||||
) -> bool {
|
||||
// A. Method 匹配
|
||||
if rule.request.method.to_uppercase() != method.to_uppercase() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// B. Path 匹配
|
||||
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// C. Query 匹配(子集匹配)
|
||||
if let Some(ref required_queries) = rule.request.query_params {
|
||||
for (key, val) in required_queries {
|
||||
if queries.get(key) != Some(val) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// D. Header 匹配(包含 Content-Type)
|
||||
// YAML 写了就匹配,没写就跳过
|
||||
if let Some(ref required_headers) = rule.request.headers {
|
||||
for (key, val) in required_headers {
|
||||
let matched = headers.iter().any(|(k, v)| {
|
||||
k.to_lowercase() == key.to_lowercase() && v == val
|
||||
});
|
||||
if !matched {
|
||||
return false; // Header 不匹配,包括 Content-Type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// E. Body 匹配
|
||||
// YAML 写了 body 才匹配,没写跳过
|
||||
if let Some(ref yaml_body) = rule.request.body {
|
||||
return self.match_body(yaml_body, parsed_body);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Body 匹配逻辑
|
||||
fn match_body(&self, yaml_body: &serde_json::Value, parsed_body: &ParsedBody) -> bool {
|
||||
match parsed_body {
|
||||
ParsedBody::Json(actual) => {
|
||||
// JSON 对象比较
|
||||
yaml_body == actual
|
||||
}
|
||||
ParsedBody::Xml(actual) => {
|
||||
// XML 字符串比较
|
||||
yaml_body.as_str()
|
||||
.map(|expected| expected.trim() == actual.trim())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
ParsedBody::Form(actual) => {
|
||||
// Form 键值对比较(子集匹配)
|
||||
compare_form_with_yaml(yaml_body, actual)
|
||||
}
|
||||
ParsedBody::Multipart(actual_fields) => {
|
||||
// Multipart 字段名比较
|
||||
compare_multipart_with_yaml(yaml_body, actual_fields)
|
||||
}
|
||||
ParsedBody::Text(actual) => {
|
||||
// 字符串比较
|
||||
yaml_body.as_str()
|
||||
.map(|expected| expected.trim() == actual.trim())
|
||||
.unwrap_or_else(|| yaml_body.to_string().trim() == actual.trim())
|
||||
}
|
||||
ParsedBody::None => {
|
||||
false // YAML 配置了 body,但请求没有 body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Form 比较:YAML 中的键值对必须是请求的子集
|
||||
fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
|
||||
let yaml_map = match yaml_body.as_object() {
|
||||
Some(obj) => obj,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
for (key, yaml_val) in yaml_map {
|
||||
let expected = yaml_val.as_str().unwrap_or(&yaml_val.to_string());
|
||||
if actual.get(key) != Some(&expected.to_string()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 需要修改的文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/model.rs` | 新增 `ParsedBody` 枚举和相关方法 |
|
||||
| `src/handler.rs` | 新增 `extract_content_type()`、`parse_body()`、修改 `mock_handler()` |
|
||||
| `src/router.rs` | 修改 `match_rule()` 和 `is_match()` 参数,新增 `match_body()` |
|
||||
| `Cargo.toml` | 可能需要 `urlencoding` 依赖 |
|
||||
|
||||
---
|
||||
|
||||
## YAML 配置示例
|
||||
|
||||
```yaml
|
||||
# 严格匹配:要求 Content-Type + body
|
||||
- id: login-strict
|
||||
request:
|
||||
method: POST
|
||||
path: /api/login
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
body:
|
||||
username: admin
|
||||
password: "123456"
|
||||
|
||||
# 宽松匹配:只匹配 method + path + body(不检查 Content-Type)
|
||||
- id: login-loose
|
||||
request:
|
||||
method: POST
|
||||
path: /api/login
|
||||
body:
|
||||
username: admin
|
||||
|
||||
# 最宽松:只匹配 method + path
|
||||
- id: any-body
|
||||
request:
|
||||
method: POST
|
||||
path: /api/echo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
1. `cargo test`
|
||||
2. 手动测试:
|
||||
- YAML 配置 `Content-Type: application/json`,请求发送 JSON → 匹配
|
||||
- YAML 配置 `Content-Type: application/xml`,请求发送 JSON → Header 不匹配
|
||||
- YAML 无 Content-Type,请求发送 XML → Body 字符串比较
|
||||
- YAML 无 body 配置 → 跳过 body 检查
|
||||
Reference in New Issue
Block a user