为什么想要自己写一个做后端开发有个很常见的场景:改了一个接口,得先本地跑起来,然后找个东西测一下,看看返回对不对。Postman当然是个好工具,但它有几个问题:第一,配置太臃肿。导个collection出来,几百行JSON,结构复杂得要命。想看看某个请求的配置?你得一层层点开嵌套的
做后端开发有个很常见的场景:改了一个接口,得先本地跑起来,然后找个东西测一下,看看返回对不对。
Postman 当然是个好工具,但它有几个问题:
第一,配置太臃肿。导个 collection 出来,几百行 JSON,结构复杂得要命。想看看某个请求的配置?你得一层层点开嵌套的对象和数组。想分享给别人?对方收到文件还得导入 Postman 才能看。
第二,自动化不友好。Newman 是有的,但配起来挺麻烦的。环境变量、前置脚本、后置脚本,各种坑。CI 里跑挂了,报错信息一大段,定位问题要翻半天日志。
第三,版本管理不方便。每次改配置都要手动同步 collection 文件。两个人改了同一个 request,Merge conflict 能折腾你半小时。
第四,登录流程每次都重复。Bearer Token 的话,登录后复制 token 粘贴到 header,创建数据后复制 ID 放到下一个请求的路径里...这些操作要是能用配置文件自动搞定就好了。
于是想:能不能写个简单的东西,把核心功能保留住,其他都简化掉?
核心需求其实就五个:
目标明确了,那就动手写吧。
先想清楚要做什么:
就这么五句话,足够覆盖大部分场景了。
src/
├── app/ # 应用层,处理命令和流程
│ ├── commands.rs # init/run/auth/validate/report
│ ├── executor.rs # 执行 HTTP 请求
│ ├── template.rs # {{env.XXX}} 这种变量替换
│ └── types.rs # ExecutionResult 这些类型
├── config/ # 解析配置文件
│ ├── auth.rs # auth.yaml 的结构
│ ├── project.rs # config.yaml 的结构
│ └── request.rs # requests/*.yaml 的结构
├── runner/ # 运行器,管依赖和执行顺序
│ ├── chain.rs # 根据 depends_on 排顺序
│ ├── types.rs # TestSummary, RequestChain
│ └── validator.rs # 验证响应是否符合预期
├── http/ # HTTP 客户端封装
├── extractors/ # JSONPath 提取数据
├── cli.rs # clap 定义命令
├── error.rs # 错误类型
└── report.rs # 生成文本/JSON/JUnit 报告
关键点:分层清晰。cli 只管解析命令,app 负责流程,runner 管执行顺序,http 只发请求。每层职责单一,后面加功能不会打架。
用 serde 就行。先定义结构体:
#[derive(Debug, Clone, Deserialize)]
pub struct RequestConfig {
pub name: String,
#[serde(default = "default_method")]
pub method: String,
pub endpoint: String,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub query: HashMap<String, String>,
pub body: Option<serde_json::Value>,
#[serde(default)]
pub auth: AuthRequirement,
#[serde(default)]
pub validate: ValidationConfig,
#[serde(default)]
pub extract: Vec<ExtractionConfig>,
#[serde(default)]
pub depends_on: Vec<String>,
}
然后一行代码加载:
let content = std::fs::read_to_string("request.yaml")?;
let config: RequestConfig = serde_yaml::from_str(&content)?;
搞定。YAML 文件长这样:
name: Create Post
method: POST
endpoint: /posts
auth: required
body:
title: Hello
extract:
- name: post_id
path: "data.id"
depends_on:
- Login
重点:设计好数据结构,剩下的交给 serde。字段太多怎么办?拆成多个结构体,比如 ValidationConfig 单独放一堆验证规则。
支持 {{env.USERNAME}} 这种写法,用简单的字符串替换:
pub fn render_template(input: &str, variables: &HashMap<String, String>) -> Result<String> {
let mut result = input.to_string();
for (key, value) in variables {
result = result.replace(&format!("{{{{env.{}}}}}", key), value);
}
Ok(result)
}
调用时把环境变量塞进去:
let mut vars = HashMap::new();
for (k, v) in std::env::vars() {
vars.insert(k, v);
}
// 再读取 .env 文件覆盖一部分
// ...
let endpoint = render_template("/users/{{env.USER_ID}}", &vars)?;
// 变成 "/users/123"
注意:JSON 里的值也要替换。写个递归函数遍历整个 serde_json::Value,遇到 String 就替换,Object 和 Array 就继续往下找。
这个有点意思。如果有 A 依赖 B,B 依赖 C,那执行顺序必须是 C → B → A。
用深度优先搜索:
fn dfs_visit<'a>(
&'a self,
request: &'a RequestConfig,
order: &mut Vec<&'a RequestConfig>,
visited: &mut HashSet<String>,
visiting: &mut HashSet<String>,
) {
if visited.contains(&request.name) {
return;
}
if visiting.contains(&request.name) {
// 检测到循环依赖,跳过
return;
}
visiting.insert(request.name.clone());
// 先访问所有依赖
for dep_name in &request.depends_on {
if let Some(dep) = self.requests.iter().find(|r| &r.name == dep_name) {
self.dfs_visit(dep, order, visited, visiting);
}
}
visiting.remove(&request.name);
visited.insert(request.name.clone());
order.push(request);
}
逻辑:对每个请求,先把它的依赖都访问一遍,最后才把自己加到结果里。这样得到的顺序天然满足依赖关系。
顺便提一嘴,这里得检测循环依赖,不然会栈溢出。用一个 visiting 集合标记正在访问的路径,如果发现某个节点已经在路径里,说明有环。
用 JSONPath。Rust 有个 jsonpath-rust 库能直接用:
let response_body: Value = serde_json::from_str(response_text)?;
let tokens = JsonPathExtractor::extract_value(&response_body, "data.token")?;
// tokens 就是 {"token": "abc123"}
// 如果要单个值
let token_str = jsonpath_rust::JsonPath::compile("$.data.token")?
.find(&response_body)
.and_then(|v| v.first())
.and_then(|v| v.as_str())
.ok_or("提取失败")?;
提取的值存起来,给下一个请求用:
let mut extracted_values: HashMap<String, Value> = HashMap::new();
for extraction in &config.extract {
let value = JsonPathExtractor::extract_value(&response.body, &extraction.path)?;
extracted_values.insert(extraction.name.clone(), value);
}
小技巧:支持默认值。如果提取失败但有 default 配置,就用默认的。这样写测试用例更灵活:
extract:
- name: optional_field
path: "data.optional"
default: "fallback_value" # 提取不到就用这个
几种常见的验证需求:
pub fn validate(&self, response: &HttpResponse, config: &ValidationConfig) -> Result<()> {
let mut errors = Vec::new();
// 状态码
if let Some(expected) = config.status
&& response.status != expected
{
errors.push(format!("Expected {}, got {}", expected, response.status));
}
// 响应时间
if let Some(max_time) = config.response_time_ms
&& response.duration_ms > max_time
{
errors.push(format!("Took {}ms, exceeds limit of {}ms",
response.duration_ms, max_time));
}
// 字段验证
for rule in &config.body {
if let Err(e) = self.validate_rule(&response.body, rule) {
errors.push(e.to_string());
}
}
if errors.is_empty() {
Ok(())
} else {
Err(ApitestError::ValidationFailed(errors.join("; ")))
}
}
支持的验证操作符可以有很多:
pub enum ValidationOperator {
Exists, // 字段存在
NotExists, // 字段不存在
Equals, // 等于某值
NotEquals, // 不等于
Contains, // 包含某字符串
GreaterThan, // 大于
LessThan, // 小于
Matches, // 正则匹配
}
具体实现就是用 jsonpath 取出值,然后根据 operator 做判断。没什么特别的,耐心写就行。
最常见的是 Bearer Token:
type: bearer_token
login:
endpoint: /auth/login
method: POST
body:
username: "{{env.USERNAME}}"
password: "{{env.PASSWORD}}"
token_path: "data.token" # 响应里的 token 在哪
header_name: Authorization
header_prefix: Bearer # 会自动加 "Bearer " 前缀
实现分两步:
Authorization: Bearer <token>pub async fn execute_auth(&mut self) -> Result<(), String> {
if let Some(ref auth) = self.auth_config {
let token = self.executor.execute_login(auth, &self.variables).await?;
self.token = Some(token);
Ok(())
} else {
Err("No auth configured".to_string())
}
}
扩展性:按这个模式,后面加 OAuth2、API Key 都很方便,就多几种 AuthType 而已。
用 clap:
#[derive(Parser, Debug)]
#[command(name = "apitest")]
#[command(version = "0.1.0")]
#[command(about = "A CLI API testing tool")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short, long, global = true)]
pub dir: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Init {
#[arg(short, long)]
name: Option<String>,
#[arg(short, long)]
url: Option<String>,
},
Run {
files: Vec<String>,
#[arg(long)]
skip_auth: bool,
#[arg(long)]
parallel: bool,
},
Auth {
#[arg(long)]
token_only: bool,
},
Validate,
Report {
#[arg(short, long, default_value = "text")]
format: String,
#[arg(short, long)]
output: Option<String>,
},
}
然后用 match 分发:
match cli.command {
Commands::Init { name, url } => app.init(name, url),
Commands::Run { files, skip_auth, parallel } => app.run(files, skip_auth, parallel).await,
Commands::Auth { token_only } => app.auth(token_only).await,
Commands::Validate => app.validate(),
Commands::Report { format, output } => app.report(&format, output),
}
完成。clap 会自动生成 --help,不用额外工作。
GitHub Actions 示例:
- name: Build apitest
run: cargo build --release
- name: Run tests
run: |
./target/release/apitest run \
--format junit \
--output report.xml
- name: Publish test results
uses: mikepenz/action-junit-report@v3
with:
report_paths: 'report.xml'
关键就是 --format junit 输出标准格式,任何 CI 都能认。
这是我目前觉得还能优化的地方:
如果你也感兴趣,可以从这几个方向入手。
写这类工具的核心思路:
整个过程没有高深的技术,就是把常见的需求一个个实现出来。
希望对你有用。有什么问题欢迎交流。👋
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!