Rust 实战丨HTTPie

概述

之前学习过《陈天·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 官方示例

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

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

效果展示

1
2
3
4
5
6
7
8
9
10
11
➜  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 子命令:

1
2
3
4
5
6
7
8
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. 创建项目

1
cargo new httpie

2. 添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
[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. 完整源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// 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() 开始:

1
2
3
4
5
6
7
8
9
10
#[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 的定义如下:

1
2
3
4
5
6
#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Httpie {
#[command(subcommand)]
methods: Method,
}

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

Httpie 中我们定义了子命令 Method

1
2
3
4
5
#[derive(Subcommand)]
enum Method {
Get(Get),
Post(Post)
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[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 函数来进行解析:

1
2
3
4
5
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 使用起来像:

1
httpie url header1:value1 param1=v1

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

KvPair 是我们自定义的类型:

1
2
3
4
5
6
7
8
9
10
11
12
#[derive(Debug, Clone)]
struct KvPair {
k: String,
v: String,
t: KvPairType,
}

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

parse_kv_pairs 的实现如下:

1
2
3
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() 来进行解析了。

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

3.2 发送请求

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

1
2
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()
1
2
3
4
5
6
7
8
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() 使其在终端以蓝色输出。

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

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

1
2
3
4
5
6
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:

1
2
3
4
5
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() 使其在终端以蓝绿色输出。对于其他类型,我们姑且照原文输出。

1
2
3
4
5
6
7
8
9
10
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 中进行异步编程和错误处理的一些常见模式。尽管示例代码的错误处理较为简单,但它提供了一个良好的起点,开发者可以在此基础上进行扩展和改进,以适应更复杂的应用场景。


Rust 实战丨HTTPie
https://hedon.top/2024/03/06/rust-action-httpie/
Author
Hedon Wang
Posted on
2024-03-06
Licensed under