Rust 实战丨HTTPie

06-18 1505阅读

概述

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

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

HTTPie 的一些关键特性包括:

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

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

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

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

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

    • Rust reqwest 简明教程
    • Rust anyhow 简明教程
    • 深入探索 Rust 的 clap 库:命令行解析的艺术

      本文完整代码:hedon954/httpie

      开发思路

      HTTP 协议

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

      请求结构:

      Rust 实战丨HTTPie

      响应结构:

      Rust 实战丨HTTPie

      命令分析

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

      Rust 实战丨HTTPie

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

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

        效果展示

        ➜  httpie git:(master) ✗ ./Httpie --help                                              
        Usage: Httpie 
        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  ...
        Arguments:
                Specify the url you wanna request to
          ...  Set the request body. Examples: headers: header1:value1 params: key1=value1
        Options:
          -h, --help  Print help
        

        请求示例:

        Rust 实战丨HTTPie

        思路梳理

        Rust 实战丨HTTPie

        第 1 步:解析命令行参数

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

        • get 支持 url 参数
        • post 支持 url、body 参数,因为其中 headers 和 params 是变长的,我们统一用 Vec 类型的 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
            }
            #[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 {
                    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 {
                let _url: Url = s.parse()?;
                Ok(s.into())
            }
            fn parse_kv_pairs(s: &str) -> anyhow::Result {
                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, 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 {
                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)] 属性宏会自动为枚举派生一些代码,以便它可以作为子命令来解析命令行参数。目前支持 Get 和 Post 两个子命令,它们分别接收 Get 和 Post 参数:

            #[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
            }
            

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

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

            use reqwest::Url;
            fn parse_url(s: &str) -> anyhow::Result {
                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 来接收 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 {
                Ok(s.parse()?)
            }
            

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

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

            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 打印响应

            Rust 实战丨HTTPie

            响应分为 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 {
                  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, 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 的库来构建实际的应用程序,包括 clap、reqwest、tokio 和 colored 等。此外,文章也说明了在 Rust 中进行异步编程和错误处理的一些常见模式。尽管示例代码的错误处理较为简单,但它提供了一个良好的起点,开发者可以在此基础上进行扩展和改进,以适应更复杂的应用场景。

VPS购买请点击我

文章版权声明:除非注明,否则均为主机测评原创文章,转载或复制请以超链接形式并注明出处。

目录[+]