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

12 KiB
Raw Permalink Blame History

Body 匹配优化:基于 Content-Type 的智能解析

Context

问题: 当前只支持 JSON body 匹配,非 JSON 请求无法正确匹配。

需求: 根据请求的 Content-Type 智能解析 body同时支持 header 匹配校验。


核心设计

关键原则

  1. Body 解析:始终以【请求的 Content-Type】为准因为这是数据的真实格式
  2. Header 匹配Content-Type 当作普通 header 处理,写了就匹配,没写跳过
  3. 自动补充的 headerContent-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)

/// 解析后的请求 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)

/// 提取请求的 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 主函数

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 匹配逻辑

/// 核心匹配函数
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 配置示例

# 严格匹配:要求 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 检查