如何用 Rust 写一个命令行 API 测试工具

  • King
  • 发布于 10小时前
  • 阅读 30

为什么想要自己写一个做后端开发有个很常见的场景:改了一个接口,得先本地跑起来,然后找个东西测一下,看看返回对不对。Postman当然是个好工具,但它有几个问题:第一,配置太臃肿。导个collection出来,几百行JSON,结构复杂得要命。想看看某个请求的配置?你得一层层点开嵌套的

为什么想要自己写一个

做后端开发有个很常见的场景:改了一个接口,得先本地跑起来,然后找个东西测一下,看看返回对不对。

Postman 当然是个好工具,但它有几个问题:

第一,配置太臃肿。导个 collection 出来,几百行 JSON,结构复杂得要命。想看看某个请求的配置?你得一层层点开嵌套的对象和数组。想分享给别人?对方收到文件还得导入 Postman 才能看。

第二,自动化不友好。Newman 是有的,但配起来挺麻烦的。环境变量、前置脚本、后置脚本,各种坑。CI 里跑挂了,报错信息一大段,定位问题要翻半天日志。

第三,版本管理不方便。每次改配置都要手动同步 collection 文件。两个人改了同一个 request,Merge conflict 能折腾你半小时。

第四,登录流程每次都重复。Bearer Token 的话,登录后复制 token 粘贴到 header,创建数据后复制 ID 放到下一个请求的路径里...这些操作要是能用配置文件自动搞定就好了。

于是想:能不能写个简单的东西,把核心功能保留住,其他都简化掉?

核心需求其实就五个:

  1. 配置文件写得人读得懂
  2. 支持登录认证
  3. 请求之间能传数据(token、ID 这些)
  4. 能验证响应
  5. 能进 CI 自动跑

目标明确了,那就动手写吧。


核心设计

先想清楚要做什么:

  1. 用 YAML 写请求 - 比 JSON 易读,谁都能看懂
  2. 支持环境变量 - 密码、token 不用写死
  3. 请求之间能传数据 - 比如登录后拿 token,创建后拿 ID
  4. 能验证响应 - 状态码、返回字段、数据结构
  5. 能进 CI 自动跑 - 输出标准格式的报告

就这么五句话,足够覆盖大部分场景了。


目录结构怎么设计

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 只发请求。每层职责单一,后面加功能不会打架。


几个核心功能的实现思路

1. YAML 配置怎么解析

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 单独放一堆验证规则。

2. 变量替换怎么做

支持 {{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 就继续往下找。

3. 请求链怎么处理

这个有点意思。如果有 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 集合标记正在访问的路径,如果发现某个节点已经在路径里,说明有环。

4. 从响应里提取数据

用 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"  # 提取不到就用这个

5. 验证响应怎么写

几种常见的验证需求:

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 做判断。没什么特别的,耐心写就行。

6. 认证怎么支持

最常见的是 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 " 前缀

实现分两步:

  1. 执行登录请求,拿到响应
  2. 从响应里提取 token,存起来
  3. 后续请求自动加上 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 而已。


CLI 怎么定义

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,不用额外工作。


怎么在 CI 里用

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 都能认。


还有什么可以改进

这是我目前觉得还能优化的地方:

  1. 更好的错误提示 - 现在失败了只显示 "Validation failed",应该告诉你是哪个字段没对上
  2. 支持 mock - 有时候不想真的调外部 API,希望能本地 mock 响应
  3. 并发执行 - 独立的不相关请求可以并行跑,速度更快
  4. watch 模式 - 文件变了自动重新跑,开发时很方便
  5. 插件机制 - 比如自定义加密签名、自定义断言逻辑

如果你也感兴趣,可以从这几个方向入手。


总结

写这类工具的核心思路:

  1. 分层 - CLI 管命令,App 管流程,Runner 管执行,HTTP 管发送
  2. 用结构化数据 - YAML + Serde,配置文件就是数据结构
  3. 别过度设计 - 先解决最痛的点,别的慢慢加

整个过程没有高深的技术,就是把常见的需求一个个实现出来。

希望对你有用。有什么问题欢迎交流。👋

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发