Rust 实战丨HTTPie

发布于:2024-06-17 ⋅ 阅读:(16) ⋅ 点赞:(0)

概述

之前学习过《陈天·Rust 编程第一课 - 04|get hands dirty:来写个实用的 CLI 小工具》,学的时候迷迷糊糊。后来在系统学习完 Rust 后,重新回过头来看这个实战小案例,基本上都能掌握,并且有了一些新的理解。所以我决定以一个 Rust 初学者的角度,并以最新版本的 Rust(1.7.6)和 clap(4.5.1)来重新实现这个案例,期望能对 Rust 感兴趣的初学者提供一些帮助。

本文将实现的应用叫 HTTPie,HTTPie 是一个用 Python 编写的命令行 HTTP 客户端,其目标是使 CLI 与 web 服务的交互尽可能愉快。它被设计为一个 curlwget 的替代品,提供易于使用的界面和一些用户友好的功能,如 JSON 支持、语法高亮和插件。它对于测试、调试和通常与 HTTP 服务器或 RESTful API 进行交云的开发人员来说非常有用。

HTTPie 的一些关键特性包括:

  1. JSON 支持:默认情况下,HTTPie 会自动发送 JSON,并且可以轻松地通过命令行发送 JSON 请求体。
  2. 语法高亮:它会为 HTTP 响应输出提供语法高亮显示,使得结果更加易于阅读。
  3. 插件:HTTPie 支持插件,允许扩展其核心功能。
  4. 表单和文件上传:可以很容易地通过表单上传文件。
  5. 自定义 HTTP 方法和头部:可以发送任何 HTTP 方法的请求,自定义请求头部。
  6. HTTPS、代理和身份验证支持:支持 HTTPS 请求、使用代理以及多种 HTTP 身份验证机制。
  7. 流式上传和下载:支持大文件的流式上传和下载。
  8. 会话支持:可以保存和重用常用的请求和集合。

本文我们将实现其中的 125。我们会支持发送 GET 和 POST 请求,其中 POST 支持设置请求头和 JSON 数据。

在本文中,你可以学习到:

  • 如何用 clap 解析命令行参数。
  • 如何用 tokio 进行异步编程。
  • 如何用 reqwest 发送 HTTP 请求。
  • 如何用 colored 在终端输出带颜色的内容。
  • 如何用 jsonxf 美化 json 字符串。
  • 如何用 anyhow 配合 ? 进行错误传播。
  • 如何使用 HTTPie 来进行 HTTP 接口测试。

在进行实际开发之前,推荐你先了解一下:

本文完整代码:hedon954/httpie

开发思路

HTTP 协议

回顾一下 HTTP 协议的请求体和响应体结构。

请求结构:

http request structure

响应结构:

http response structure

命令分析

在本文中,我们就实现 HTTPie cli 官方的这个示例:即允许指定请求方法、携带 headers 和 json 数据发送请求。

HTTPie 官方示例

我们来拆解一下,这个命令可以分为以下几个部分:

httpie <METHOD> <URL> [headers | params]...
  • <METHOD>: 请求方法,本案例中,我们仅支持 GET 和 POST。
  • <URL>: 请求地址。
  • <HEADERS>: 请求头,格式为 h1:v1
  • <PARAMS>: 请求参数,格式为 k1=v1,最终以 json 结构发送。

效果展示

➜  httpie git:(master) ✗ ./Httpie --help                                              
Usage: Httpie <COMMAND>

Commands:
  get   
  post  
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version

其中 post 子命令:

Usage: Httpie post <URL> <BODY>...

Arguments:
  <URL>      Specify the url you wanna request to
  <BODY>...  Set the request body. Examples: headers: header1:value1 params: key1=value1

Options:
  -h, --help  Print help

请求示例:

httpie response demo

思路梳理

httpie 开发思路梳理

第 1 步:解析命令行参数

本案例中 httpie 支持 2 个子命令:

  • get 支持 url 参数
  • post 支持 url、body 参数,因为其中 headers 和 params 是变长的,我们统一用 Vec<String> 类型的 body 来接收,然后用 := 来区分它们。

第 2 步:发送请求

  1. 使用 reqwest 创建 http client;
  2. 设置 url;
  3. 设置 method;
  4. 设置 headers;
  5. 设置 params;
  6. 发送请求;
  7. 获取响应体。

第 3 步:打印响应

  1. 打印 http version 和 status,并使用 colored 赋予蓝色;
  2. 打印 response headers,并使用 colored 赋予绿色;
  3. 确定 content-type,如果是 json,我们就用 jsonxf 美化 json 串并使用 colored 赋予蓝绿色输出,如果是其他类型,这里我们就输出原文即可。

实战过程

1. 创建项目

cargo new httpie

2. 添加依赖

[package]
name = "httpie"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.80"
clap = { version = "4.5.1", features = ["derive"] }
colored = "2.1.0"
jsonxf = "1.1.1"
mime = "0.3.17"
reqwest = { version = "0.11.24", features = ["json"] }
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] }
  • anyhow: 用于简化异常处理。
  • clap: 解析命令行参数。
  • colored: 为终端输出内容赋予颜色。
  • jsonxf: 美化 json 串。
  • mime: 提供了各种 Media Type 的类型封装。
  • reqwest: http 客户端。
  • tokio: 异步库,本案例种我们使用 reqwest 的异步功能。

3. 完整源码

// src/main.rs  为减小篇幅,省略了单元测试,读者可自行补充。
use std::collections::HashMap;
use reqwest::{Client, header, Response};
use std::str::FromStr;
use anyhow::anyhow;
use clap::{Args, Parser, Subcommand};
use colored::Colorize;
use mime::Mime;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::Url;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Httpie {
    #[command(subcommand)]
    methods: Method,
}

#[derive(Subcommand)]
enum Method {
    Get(Get),
    Post(Post)
}

#[derive(Args)]
struct Get {
    #[arg(value_parser = parse_url)]
    url: String,
}

#[derive(Args)]
struct Post {
    /// Specify the url you wanna request to.
    #[arg(value_parser = parse_url)]
    url: String,

    /// Set the request body.
    /// Examples:
    ///     headers:
    ///         header1:value1
    ///     params:
    ///         key1=value1
    #[arg(required = true, value_parser = parse_kv_pairs)]
    body: Vec<KvPair>
}

#[derive(Debug, Clone)]
struct KvPair {
    k: String,
    v: String,
    t: KvPairType,
}

#[derive(Debug,Clone)]
enum KvPairType {
    Header,
    Param,
}

impl FromStr for KvPair {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let pair_type: KvPairType;
        let split_char = if s.contains(':') {
            pair_type = KvPairType::Header;
            ':'
        } else {
            pair_type = KvPairType::Param;
            '='
        };

        let mut split = s.split(split_char);
        let err = || anyhow!(format!("failed to parse pairs {}",s));
        Ok(Self {
            k: (split.next().ok_or_else(err)?).to_string(),
            v: (split.next().ok_or_else(err)?).to_string(),
            t: pair_type,
        })
    }
}

fn parse_url(s: &str) -> anyhow::Result<String> {
    let _url: Url = s.parse()?;
    Ok(s.into())
}

fn parse_kv_pairs(s: &str) -> anyhow::Result<KvPair> {
    Ok(s.parse()?)
}

async fn get(client: Client, args: &Get) -> anyhow::Result<()> {
   let resp = client.get(&args.url).send().await?;
    Ok(print_resp(resp).await?)
}

async fn post(client: Client, args: &Post) -> anyhow::Result<()> {
    let mut body = HashMap::new();
    let mut header_map = HeaderMap::new();
    for pair in args.body.iter() {
        match pair.t {
            KvPairType::Param =>  {body.insert(&pair.k, &pair.v);}
            KvPairType::Header => {
                if let Ok(name) = HeaderName::from_str(pair.k.as_str()) {
                    if let Ok(value) = HeaderValue::from_str(pair.v.as_str()) {
                        header_map.insert(name,value);
                    } else {
                        println!("Invalid header value for key: {}", pair.v);
                    }
                } else {
                    println!("Invalid header key: {}", pair.k);
                }
            }
        }
    }
    let resp = client.post(&args.url)
        .headers(header_map)
        .json(&body).send().await?;
    Ok(print_resp(resp).await?)
}

async fn print_resp(resp: Response) -> anyhow::Result<()> {
    print_status(&resp);
    print_headers(&resp);
    let mime = get_content_type(&resp);
    let body = resp.text().await?;
    print_body(mime, &body);
    Ok(())
}

fn print_status(resp: &Response) {
    let status = format!("{:?} {}", resp.version(), resp.status()).blue();
    println!("{}\n", status);
}

fn print_headers(resp: &Response) {
    for (k,v) in resp.headers() {
        println!("{}: {:?}", k.to_string().green(), v);
    }
    print!("\n");
}

fn print_body(mime: Option<Mime>, resp: &String) {
    match mime {
        Some(v) => {
            if v == mime::APPLICATION_JSON {
                println!("{}", jsonxf::pretty_print(resp).unwrap().cyan())
            }
        }
        _ => print!("{}", resp),
    }
}

fn get_content_type(resp: &Response) -> Option<Mime> {
    resp.headers()
        .get(header::CONTENT_TYPE)
        .map(|v|v.to_str().unwrap().parse().unwrap())
}

#[tokio::main]
async fn main() -> anyhow::Result<()>{
    let httpie = Httpie::parse();
    let client = Client::new();
    let result = match httpie.methods {
        Method::Get(ref args) => get(client, args).await?,
        Method::Post(ref args) => post(client, args).await?,
    };
    Ok(result)
}

可以看到,即使算上 use 部分,总代码也不过160 行左右,Rust 的 clap 库在 CLI 开发上确实 yyds!

接下来我们来一一拆解这部分的代码,其中关于 clap 的部分我不会过多展开,刚兴趣的读者可以参阅:深入探索 Rust 的 clap 库:命令行解析的艺术

3.1 命令行解析

我们先从 main() 开始:

#[tokio::main]
async fn main() -> anyhow::Result<()>{
    let httpie = Httpie::parse();
    let client = Client::new();
    let result = match httpie.methods {
        Method::Get(ref args) => get(client, args).await?,
        Method::Post(ref args) => post(client, args).await?,
    };
    Ok(result)
}

我们希望使用 clap 的异步功能,所以使用了 async 关键字,同时加上了 tokio 提供的属性宏 #[tokio::main],用于设置异步环境。为了能够使用 ? 快速传播错误,我们设置返回值为 anyhow::Result<()>,本项目中我们不对错误进行过多处理,所以这种方式可以大大简化我们的错误处理过程。

main() 中我们使用 Httpie::parse() 解析命令行中的参数,使用 Client::new() 创建一个 http client,根据解析到的命令行参数,我们匹配子命令 methods,分别调用 get()post() 来发送 GET 和 POST 请求。

Httpie 的定义如下:

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Httpie {
    #[command(subcommand)]
    methods: Method,
}

#[derive(Parser)] 是一个过程宏(procedural macro),用于自动为结构体实现 clap::Parser trait。这使得该结构体可以用来解析命令行参数。

Httpie 中我们定义了子命令 Method

#[derive(Subcommand)]
enum Method {
    Get(Get),
    Post(Post)
}

#[derive(Subcommand)] 属性宏会自动为枚举派生一些代码,以便它可以作为子命令来解析命令行参数。目前支持 GetPost 两个子命令,它们分别接收 GetPost 参数:

#[derive(Args)]
struct Get {
    #[arg(value_parser = parse_url)]
    url: String,
}

#[derive(Args)]
struct Post {
    #[arg(value_parser = parse_url)]
    url: String,
  
    #[arg(value_parser = parse_kv_pairs)]
    body: Vec<KvPair>
}

#[derive(Args)] 属性宏表明当前 struct 是命令的参数,其中 Get 仅支持 url 参数,Post 支持 urlbody 参数。

url 参数我们使用 parse_url 函数来进行解析:

use reqwest::Url;
fn parse_url(s: &str) -> anyhow::Result<String> {
    let _url: Url = s.parse()?;
    Ok(s.into())
}

这里 reqwest::Url 已经实现了 FromStr trait,所以这里我们可以直接调用 s.parse() 来解析 url

body,因为我们期望 CLI 使用起来像:

httpie url header1:value1 param1=v1

body 就是 header1:value1 param1=v1,一对 kv 就代表着一个 header 或者 param,用 := 来区分。因为 kv 对的个数的变长的,所以我们使用 Vec<KvPair> 来接收 body 这个参数,并使用 parse_kv_pairs 来解析 kv 对。

KvPair 是我们自定义的类型:

#[derive(Debug, Clone)]
struct KvPair {
    k: String,
    v: String,
    t: KvPairType,
}

#[derive(Debug,Clone)]
enum KvPairType {
    Header,
    Param,
}

parse_kv_pairs 的实现如下:

fn parse_kv_pairs(s: &str) -> anyhow::Result<KvPair> {
    Ok(s.parse()?)
}

在这里,你可以在 parse_kv_pairs() 函数中,对 s 进行解析并返回 anyhow::Result<KvPair>。不过,更优雅,更统一的方式是什么呢?就是像 reqwest::Url 一样,为 KvPair 实现 FromStr trait,这样就可以直接调用 s.parse() 来进行解析了。

impl FromStr for KvPair {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        ...
    }
}

3.2 发送请求

参数解析完,就到了发送请求的地方了,这里使用 reqwest crate 就非常方便了,这里就不赘述了,具体可以参考:Rust reqwest 简明教程

async fn get(client: Client, args: &Get) -> anyhow::Result<()> { ... }
async fn post(client: Client, args: &Post) -> anyhow::Result<()> { ... }

3.3 打印响应

httpie response demo

响应分为 3 个部分:

  • print_status()
  • print_headers()
  • print_body()
async fn print_resp(resp: Response) -> anyhow::Result<()> {
    print_status(&resp);
    print_headers(&resp);
    let mime = get_content_type(&resp);
    let body = resp.text().await?;
    print_body(mime, &body);
    Ok(())
}

print_status() 比较简单,就是打印 HTTP 版本和响应状态码,然后我们使用 colored crate 的 blue() 使其在终端以蓝色输出。

fn print_status(resp: &Response) {
    let status = format!("{:?} {}", resp.version(), resp.status()).blue();
    println!("{}\n", status);
}

print_headers() 中,我们使用 green() 使 header_name 在终端以绿色输出。

fn print_headers(resp: &Response) {
    for (k,v) in resp.headers() {
        println!("{}: {:?}", k.to_string().green(), v);
    }
    print!("\n");
}

响应体的格式(Media Type)有很多,本案例中我们仅支持 application/json,所以在 print_body() 之前,我们需要先读取 response header 中的 content-type:

fn get_content_type(resp: &Response) -> Option<Mime> {
    resp.headers()
        .get(header::CONTENT_TYPE)
        .map(|v|v.to_str().unwrap().parse().unwrap())
}

print_resp() 中,对于 application/json,我们使用 jsonxf crate 对进行美化,并使用 cyan() 使其在终端以蓝绿色输出。对于其他类型,我们姑且照原文输出。

fn print_body(mime: Option<Mime>, resp: &String) {
    match mime {
        Some(v) => {
            if v == mime::APPLICATION_JSON {
                println!("{}", jsonxf::pretty_print(resp).unwrap().cyan())
            }
        }
        _ => print!("{}", resp),
    }
}

总结

在本文中,我们深入探讨了如何使用 Rust 语言来实现一个类似于 HTTPie 的命令行工具。这个过程包括了对 HTTP 协议的理解、命令行参数的解析、HTTP 客户端的创建和请求发送,以及对响应的处理和展示。通过本文,读者不仅能够获得一个实用的命令行工具,还能够学习到如何使用 Rust 的库来构建实际的应用程序,包括 clapreqwesttokiocolored 等。此外,文章也说明了在 Rust 中进行异步编程和错误处理的一些常见模式。尽管示例代码的错误处理较为简单,但它提供了一个良好的起点,开发者可以在此基础上进行扩展和改进,以适应更复杂的应用场景。