fix: 修复 YAML 块语法 body 匹配失败问题
- normalize_yaml_body 函数在解析 JSON 前添加 trim() 处理,解决 YAML `|` 和 `>` 语法产生的前导空格问题 - 修复 multiple_login.yaml 中 response body 格式错误(YAML 对象改为 JSON 字符串) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
id: "auth_login_001"
|
||||
request:
|
||||
method: "POST"
|
||||
path: "/api/v1/auth/login"
|
||||
# 必须包含此 Header 才会匹配
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
Authorization: "111"
|
||||
host: "127.0.0.1:8080"
|
||||
body: >
|
||||
{
|
||||
"username":"user",
|
||||
"password":"123"
|
||||
}
|
||||
response:
|
||||
status: 200
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
X-Mock-Engine: "Rust-Gemini-v1.2"
|
||||
# 直接内联 JSON 字符串
|
||||
body: >
|
||||
{
|
||||
"code": 0,
|
||||
"data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6" },
|
||||
"msg": "success"
|
||||
}
|
||||
settings:
|
||||
delay_ms: 2000 # 模拟真实网络延迟
|
||||
@@ -1,50 +0,0 @@
|
||||
- id: "auth_login_out_001"
|
||||
request:
|
||||
method: "POST"
|
||||
path: "/api/v1/auth/login_out"
|
||||
# 必须包含此 Header 才会匹配
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
Authorization: "111"
|
||||
host: "127.0.0.1:8080"
|
||||
body:
|
||||
type: true
|
||||
response:
|
||||
status: 200
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
X-Mock-Engine: "Rust-Gemini-v1.2"
|
||||
# 直接内联 JSON 字符串
|
||||
body: >
|
||||
{
|
||||
"code": 0,
|
||||
"data": "退出成功",
|
||||
"msg": "success"
|
||||
}
|
||||
settings:
|
||||
delay_ms: 200 # 模拟真实网络延迟
|
||||
- id: "auth_login_out_002"
|
||||
request:
|
||||
method: "POST"
|
||||
path: "/api/v1/auth/login_out"
|
||||
# 必须包含此 Header 才会匹配
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
Authorization: "111"
|
||||
host: "127.0.0.1:8080"
|
||||
body:
|
||||
type: false
|
||||
response:
|
||||
status: 200
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
X-Mock-Engine: "Rust-Gemini-v1.2"
|
||||
# 直接内联 JSON 字符串
|
||||
body: >
|
||||
{
|
||||
"code": 1,
|
||||
"data": "退出失败",
|
||||
"msg": "success"
|
||||
}
|
||||
settings:
|
||||
delay_ms: 200 # 模拟真实网络延迟
|
||||
118
mocks/v1/auth/multiple_login.yaml
Normal file
118
mocks/v1/auth/multiple_login.yaml
Normal file
@@ -0,0 +1,118 @@
|
||||
# 用户登录 - 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"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
id: "prod_export_pdf"
|
||||
request:
|
||||
method: "GET"
|
||||
path: "/api/v1/products/report"
|
||||
body: '{"username":"user","password":"123"}'
|
||||
response:
|
||||
status: 200
|
||||
headers:
|
||||
Content-Type: "application/pdf"
|
||||
Content-Disposition: "attachment; filename=report.pdf"
|
||||
# 智能协议:引擎会自动识别前缀并异步读取磁盘文件
|
||||
body: "file://./storage/reports/annual_2024.pdf"
|
||||
@@ -1,16 +0,0 @@
|
||||
# 使用 YAML 数组语法定义多个规则
|
||||
- id: "sys_ping"
|
||||
request:
|
||||
method: "GET"
|
||||
path: "/api/v1/ping"
|
||||
response:
|
||||
status: 200
|
||||
body: "pong"
|
||||
|
||||
- id: "sys_version"
|
||||
request:
|
||||
method: "GET"
|
||||
path: "/api/v1/version"
|
||||
response:
|
||||
status: 200
|
||||
body: '{"version": "1.2.0-smart"}'
|
||||
@@ -1,23 +0,0 @@
|
||||
id: "upload_file"
|
||||
request:
|
||||
method: "POST"
|
||||
path: "/api/v1/upload"
|
||||
headers:
|
||||
Content-Type: "multipart/form-data"
|
||||
response:
|
||||
status: 200
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
body: >
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"filename": "example.txt",
|
||||
"path": "storage/2024-01-15/example.txt",
|
||||
"size": 1024,
|
||||
"url": "/storage/2024-01-15/example.txt"
|
||||
},
|
||||
"msg": "upload success"
|
||||
}
|
||||
settings:
|
||||
delay_ms: 100
|
||||
@@ -1,11 +0,0 @@
|
||||
id: "user_search_admin"
|
||||
request:
|
||||
method: "GET"
|
||||
path: "/api/v1/users"
|
||||
# 请求中必须包含 role=admin 且 status=active
|
||||
query_params:
|
||||
role: "admin"
|
||||
status: "active"
|
||||
response:
|
||||
status: 200
|
||||
body: '{"users": [{"id": 1, "name": "SuperAdmin"}]}'
|
||||
173
src/router.rs
173
src/router.rs
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::config::MockRule;
|
||||
use crate::models::{MockRule, Payload};
|
||||
|
||||
pub struct MockRouter {
|
||||
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
|
||||
@@ -18,7 +18,7 @@ impl MockRouter {
|
||||
path: &str,
|
||||
queries: &HashMap<String, String>,
|
||||
headers: &HashMap<String, String>,
|
||||
incoming_body: &Option<serde_json::Value>, // 修改 1: 增加参数
|
||||
payload: &Payload,
|
||||
) -> Option<&MockRule> {
|
||||
// 1. 提取请求路径的首段作为索引 Key
|
||||
let key = self.extract_first_segment(path);
|
||||
@@ -27,7 +27,7 @@ impl MockRouter {
|
||||
if let Some(rules) = self.index.get(&key) {
|
||||
// 3. 在候选集中进行线性深度匹配
|
||||
for rule in rules {
|
||||
if self.is_match(rule, method, path, queries, headers,incoming_body) {
|
||||
if self.is_match(rule, method, path, queries, headers, payload) {
|
||||
return Some(rule);
|
||||
}
|
||||
}
|
||||
@@ -44,18 +44,19 @@ impl MockRouter {
|
||||
path: &str,
|
||||
queries: &HashMap<String, String>,
|
||||
headers: &HashMap<String, String>,
|
||||
incoming_body: &Option<serde_json::Value>, // 修改 3: 增加参数
|
||||
payload: &Payload,
|
||||
) -> bool {
|
||||
// A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠)
|
||||
if rule.request.method.to_uppercase() != method.to_uppercase() {
|
||||
println!("DEBUG: [ID:{}] Method Mismatch: YAML={}, Req={}", rule.id, rule.request.method, method);
|
||||
println!("DEBUG: [NAME:{}] Method Mismatch: YAML={}, Req={}", rule.name, rule.request.method, method);
|
||||
return false;
|
||||
}
|
||||
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
|
||||
println!("DEBUG: [ID:{}] Path Mismatch: YAML='{}', Req='{}'", rule.id, rule.request.path, path);
|
||||
println!("DEBUG: [NAME:{}] Path Mismatch: YAML='{}', Req='{}'", rule.name, rule.request.path, path);
|
||||
return false;
|
||||
}
|
||||
println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id);
|
||||
println!("DEBUG: [NAME:{}] Method and Path matched! Checking headers...", rule.name);
|
||||
|
||||
// B. Query 参数校验 (子集匹配原则)
|
||||
if let Some(ref required_queries) = rule.request.query_params {
|
||||
for (key, val) in required_queries {
|
||||
@@ -65,46 +66,71 @@ impl MockRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// C. Header 校验 (优化版)
|
||||
// C. Header 校验 (大小写不敏感,Content-Type 使用前缀匹配)
|
||||
if let Some(ref required_headers) = rule.request.headers {
|
||||
for (key, val) in required_headers {
|
||||
println!("{}:{}",key.clone(), val.clone());
|
||||
// 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值
|
||||
let key_lower = key.to_lowercase();
|
||||
let matched = headers.iter().any(|(k, v)| {
|
||||
// k.to_lowercase() == key.to_lowercase() && v.contains(val)
|
||||
k.to_lowercase() == key.to_lowercase() && v==val
|
||||
if k.to_lowercase() != key_lower {
|
||||
return false;
|
||||
}
|
||||
// Content-Type 使用前缀匹配(因为可能包含 boundary 等参数)
|
||||
if key_lower == "content-type" {
|
||||
v.to_lowercase().starts_with(&val.to_lowercase())
|
||||
} else {
|
||||
v == val
|
||||
}
|
||||
});
|
||||
|
||||
if !matched {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// D. 智能 Body 全量比对逻辑
|
||||
if let Some(ref required_val) = rule.request.body {
|
||||
match incoming_body {
|
||||
Some(actual_val) => {
|
||||
// 实现你的想法:尝试将 YAML 中的 String 转换为 Object 再对比
|
||||
let final_required = if let Some(s) = required_val.as_str() {
|
||||
// 如果能解析成 JSON,就用解析后的对象,否则用原始字符串 Value
|
||||
serde_json::from_str::<serde_json::Value>(s).unwrap_or_else(|_| required_val.clone())
|
||||
} else {
|
||||
required_val.clone()
|
||||
};
|
||||
|
||||
// 执行全量相等比对
|
||||
if final_required != *actual_val {
|
||||
println!("DEBUG: [ID:{}] Body Mismatch", rule.id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => return false, // YAML 要求有 Body 但请求为空
|
||||
}
|
||||
// D. Body 匹配(根据 Payload 类型智能比较)
|
||||
if let Some(ref yaml_body) = rule.request.body {
|
||||
return self.match_body(yaml_body, payload);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Body 匹配逻辑
|
||||
fn match_body(&self, yaml_body: &serde_json::Value, payload: &Payload) -> bool {
|
||||
// 将 YAML body 规范化:如果是字符串,尝试解析为 JSON
|
||||
let normalized_body = normalize_yaml_body(yaml_body);
|
||||
|
||||
match payload {
|
||||
Payload::Json(actual) => {
|
||||
// JSON 对象比较
|
||||
&normalized_body == actual
|
||||
}
|
||||
Payload::Xml(actual) => {
|
||||
// XML 字符串比较(规范化后比较)
|
||||
normalized_body.as_str()
|
||||
.map(|expected| normalize_xml(expected) == normalize_xml(actual))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Payload::Form(actual) => {
|
||||
// Form 键值对比较(子集匹配)
|
||||
compare_form_with_yaml(&normalized_body, actual)
|
||||
}
|
||||
Payload::Multipart(actual_data) => {
|
||||
// Multipart 匹配:支持键值对或字段名列表
|
||||
compare_multipart_with_yaml(&normalized_body, actual_data)
|
||||
}
|
||||
Payload::Text(actual) => {
|
||||
// 字符串比较(去掉首尾空白)
|
||||
normalized_body.as_str()
|
||||
.map(|expected| expected.trim() == actual.trim())
|
||||
.unwrap_or_else(|| normalized_body.to_string().trim() == actual.trim())
|
||||
}
|
||||
Payload::None => {
|
||||
false // YAML 配置了 body,但请求没有 body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 Loader 保持一致的 Key 提取算法
|
||||
fn extract_first_segment(&self, path: &str) -> String {
|
||||
path.trim_start_matches('/')
|
||||
@@ -114,3 +140,86 @@ impl MockRouter {
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范化 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 字符串:去掉声明、多余空白、格式化为紧凑形式
|
||||
fn normalize_xml(xml: &str) -> String {
|
||||
let mut result = xml.to_string();
|
||||
|
||||
// 去掉 XML 声明
|
||||
if let Some(pos) = result.find("?>") {
|
||||
if result[..pos].contains("<?xml") {
|
||||
result = result[pos + 2..].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 去掉多余空白字符
|
||||
result = result
|
||||
.chars()
|
||||
.collect::<Vec<_>>()
|
||||
.chunks(1)
|
||||
.map(|c| c[0])
|
||||
.collect::<String>();
|
||||
|
||||
// 分割成行,去掉每行首尾空白,过滤空行
|
||||
result = result
|
||||
.lines()
|
||||
.map(|line| line.trim())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<String>();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 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().map(|s| s.to_string()).unwrap_or_else(|| yaml_val.to_string());
|
||||
if actual.get(key) != Some(&expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Multipart 比较:对象和数组形式都只匹配字段名是否存在
|
||||
fn compare_multipart_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap<String, String>) -> bool {
|
||||
// 方式 1:对象形式 - 只匹配键名是否存在(忽略值)
|
||||
if let Some(yaml_map) = yaml_body.as_object() {
|
||||
for key in yaml_map.keys() {
|
||||
if !actual.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 方式 2:数组形式 - 只匹配字段名是否存在
|
||||
if let Some(yaml_array) = yaml_body.as_array() {
|
||||
for yaml_field in yaml_array {
|
||||
let field_name = yaml_field.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_field.to_string());
|
||||
if !actual.contains_key(&field_name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user