首页
/ Hoppscotch Relay:为 Hoppscotch 桌面端构建的 Rust HTTP 请求中继层详解

Hoppscotch Relay:为 Hoppscotch 桌面端构建的 Rust HTTP 请求中继层详解

2026-09-04 19:55:43作者:田桥桑Industrious

本篇指南围绕 Hoppscotch 仓库中的 hoppscotch-relay 包(packages/hoppscotch-relay/README.md)展开:它是一个高性能的 HTTP 请求-响应中继库,为 Hoppscotch Desktop 与 Hoppscotch Agent 提供浏览器无法实现的底层网络能力——CORS 覆盖、自定义请求头、客户端证书认证、自定义根证书、代理与本地系统集成。读完本文,你将掌握该库的完整 API 用法(基本请求、各类请求体、证书、代理、请求取消),并能从源码层面理解其基于 curl-rust fork 与静态 OpenSSL 的实现原理。

1. 它解决什么问题:浏览器网络栈的能力边界

在 Web 端,HTTP 请求受限于浏览器的 fetch/XHR 安全模型:不能随意修改 Host 头、不能上传客户端证书、无法信任自签 CA、无法走本地代理。Hoppscotch 桌面端(Tauri 应用)为了让桌面版与 Web 版体验一致,把“真正发请求”这一步下沉到了 Rust 原生层——也就是 hoppscotch-relay 这个 crate。

Cargo.toml 可以看到其核心依赖选择,这也是 README 中“consistent SSL/TLS behavior across different platforms(跨平台一致的 SSL/TLS 行为)”说法的实现依据:

[dependencies]
# 基于 curl-rust 的定制 fork,启用 NTLM 特性以支持 NTLM 代理认证
curl = { git = "https://github.com/CuriousCorrelation/curl-rust.git", features = ["ntlm"] }
tokio-util = "0.7.12"
# vendored 特性:静态编译 OpenSSL,避免依赖系统 OpenSSL 版本差异
openssl = { version = "0.10.66", features = ["vendored"] }
# NOTE: This crate follows `openssl-sys` from https://github.com/CuriousCorrelation/curl-rust.git
# to avoid issues from version mismatch when compiling from source.
openssl-sys = { version = "0.9.64", features = ["vendored"] }

两个值得注意的工程决策:

  1. 使用定制 curl-rust fork(而非官方 curl crate),以启用 NTLM 代理认证等特性;
  2. opensslopenssl-sys 都开启 vendored 特性,即从源码静态编译 OpenSSL。Cargo.toml 中的注释明确说明:这样做是为了跟随 curl-rust fork 所依赖的 openssl-sys 版本,避免从源码编译时出现版本不匹配问题。其效果是:无论目标机器上装了什么版本的系统 OpenSSL,relay 的行为都一致。

对外 API 在 src/lib.rs 中统一导出,非常收敛:

pub use error::{RelayError, RelayResult};
pub use interop::{RequestWithMetadata, ResponseWithMetadata};
pub use relay::run_request_task;

即整个库的公开面就是“请求描述结构体 + 响应描述结构体 + 一个执行函数 + 一套错误类型”,其余模块(errorinteroprelayutil)均为 pub(crate) 内部实现。

2. 核心数据模型:RequestWithMetadata 与 ResponseWithMetadata

所有请求配置集中在 src/interop.rsRequestWithMetadata 中,它同时也是 serde 可序列化/可反序列化的结构(Serialize/Deserialize),这意味着前端(WebView)可以通过 JSON 把完整请求配置跨进程传给 Rust 层:

pub struct RequestWithMetadata {
    pub req_id: usize,                    // 请求 ID,用于与前端侧的请求追踪对应
    pub method: String,                    // HTTP 方法,任意值(custom_request)
    pub endpoint: String,                  // 目标 URL
    pub headers: Vec<KeyValuePair>,       // 请求头列表
    pub body: Option<BodyDef>,            // 请求体(可选)
    pub validate_certs: bool,              // 是否校验 TLS 证书(peer + host)
    pub root_cert_bundle_files: Vec<Vec<u8>>, // 自定义根证书包(PEM 字节,可多个)
    pub client_cert: Option<ClientCertDef>,   // 客户端证书(PEM 或 PFX)
    pub proxy: Option<ProxyConfig>,         // 代理配置
}

2.1 请求体类型 BodyDef

pub enum BodyDef {
    Text(String),                       // 原始文本/JSON
    URLEncoded(Vec<KeyValuePair>),      // 表单键值对
    FormData(Vec<FormDataEntry>),       // multipart 表单
}

pub enum FormDataValue {
    Text(String),
    File { filename: String, data: Vec<u8>, mime: String },  // 文件上传
}

2.2 客户端证书 ClientCertDef

支持两种格式(见 src/interop.rs L76-L86):

pub enum ClientCertDef {
    PEMCert { certificate_pem: Vec<u8>, key_pem: Vec<u8> },   // PEM 证书 + 私钥
    PFXCert { certificate_pfx: Vec<u8>, password: String },   // PKCS#12/PFX + 口令
}

2.3 响应结构 ResponseWithMetadata

pub struct ResponseWithMetadata {
    pub status: u16,        // 状态码
    pub status_text: String,// 状态文本(如 "OK")
    pub headers: Vec<KeyValuePair>,
    pub data: Vec<u8>,     // 响应体原始字节
    pub time_start_ms: u128,// 传输开始时间戳(ms,自 UNIX_EPOCH)
    pub time_end_ms: u128,  // 传输结束时间戳
}

这里的 time_start_ms/time_end_ms 是围绕 transfer.perform() 前后用 SystemTime::now() 取到的绝对时间戳(src/relay.rs L286-L311),因此 time_end_ms - time_start_ms 即为本次请求的总耗时,README 示例中的用法即源于此。状态文本则由 src/util.rsget_status_text 通过 http::StatusCode::canonical_reason() 得到,未知状态码返回 "Unknown Status"

3. 安装与基本用法

3.1 安装

按照 README 说明,将 crate 加入你的 Cargo.toml

[dependencies]
hoppscotch-relay = "0.1.1"

当前仓库中该 crate 的版本号(Cargo.toml L3)即为 0.1.1,与文档一致。

3.2 基本 GET 请求

use hoppscotch_relay::{RequestWithMetadata, KeyValuePair};
use tokio_util::sync::CancellationToken;

// Create a basic GET request
let request = RequestWithMetadata::new(
    1,                    // Request ID
    "GET".to_string(),    // Method
    "https://api.example.com/data".to_string(), // Endpoint
    vec![                 // Headers
        KeyValuePair {
            key: "Accept".to_string(),
            value: "application/json".to_string(),
        }
    ],
    None,                 // Body
    true,                 // Validate certificates
    vec![],               // Root certificate bundles
    None,                 // Client certificate
    None,                 // Proxy configuration
);

// Execute the request with cancellation support
let cancel_token = CancellationToken::new();
let response = hoppscotch_relay::run_request_task(&request, cancel_token)?;

println!("Status: {} {}", response.status, response.status_text);
println!("Response time: {}ms", response.time_end_ms - response.time_start_ms);

注意 RequestWithMetadata::new(...) 的参数顺序与结构体字段顺序一致:req_id, method, endpoint, headers, body, validate_certs, root_cert_bundle_files, client_cert, proxy。执行函数 run_request_task 的签名是(src/relay.rs L16-L19):

pub fn run_request_task(
    req: &RequestWithMetadata,
    cancel_token: CancellationToken,
) -> Result<ResponseWithMetadata, RelayError>

从源码结构看,run_request_task 本身是一个同步阻塞函数:它直接操作 curl 的 Easy 句柄并调用 transfer.perform()。README 特性列表中提到的“Async design”应理解为:调用方可以把它放进 tokio::spawn 等异步任务中隔离执行(后文取消示例正是这种用法),并通过 CancellationToken 实现跨任务取消。

4. 请求体实战:JSON、URL 编码与文件上传

4.1 POST JSON Body

let mut request = RequestWithMetadata::new(
    2,
    "POST".to_string(),
    "https://api.example.com/users".to_string(),
    vec![KeyValuePair {
        key: "Content-Type".to_string(),
        value: "application/json".to_string(),
    }],
    Some(BodyDef::Text(r#"{"name": "John Doe"}"#.to_string())),
    true,
    vec![],
    None,
    None,
);

let response = hoppscotch_relay::run_request_task(&request, CancellationToken::new())?;

对应实现位于 src/relay.rsapply_body_to_curl_handle(L371-L446):BodyDef::Textcurl_handle.post_fields_copy(text.as_bytes()),即把原始字节作为 POST fields 发送,不做任何转义——所以 JSON、XML 等任意 raw body 都可以直接放进来。

4.2 URL 编码表单

BodyDef::URLEncoded(Vec<KeyValuePair>) 在实现中会先用 url_escape::encode_www_form_urlencoded 对 key 和 value 分别做 RFC 3986 表单编码,再用 & 连接后发送:

Some(BodyDef::URLEncoded(entries)) => {
    let data = entries.iter().map(|KeyValuePair { key, value }| {
        format!(
            "{}={}",
            &url_escape::encode_www_form_urlencoded(key),
            &url_escape::encode_www_form_urlencoded(value),
        )
    }).collect::<Vec<String>>().join("&");
    curl_handle.post_fields_copy(data.as_bytes())...
}

也就是说,调用方只需要传原始键值对,编码细节由库内部完成。

4.3 multipart 文件上传

let form_data = vec![
    FormDataEntry {
        key: "file".to_string(),
        value: FormDataValue::File {
            filename: "document.pdf".to_string(),
            data: std::fs::read("document.pdf")?,
            mime: "application/pdf".to_string(),
        },
    },
    FormDataEntry {
        key: "description".to_string(),
        value: FormDataValue::Text("Important document".to_string()),
    },
];

let mut request = RequestWithMetadata::new(
    3,
    "POST".to_string(),
    "https://api.example.com/upload".to_string(),
    vec![],
    Some(BodyDef::FormData(form_data)),
    true,
    vec![],
    None,
    None,
);

实现上,每个 FormDataEntry 被映射为 curl 的 Form part:文本值走 part.contents(...),文件值走 part.buffer(filename, data).content_type(mime) 以内存字节(而非文件路径)作为上传源,最后 curl_handle.httppost(form) 组装完成。这意味着文件内容以 Vec<u8> 形式随请求描述一起从前端传入,天然契合 Tauri 命令序列化模型。

5. TLS 能力:证书校验、客户端证书与自定义根证书包

5.1 证书校验开关

validate_certs: bool 会同时作用于 peer 与 host 两项校验(src/relay.rs L96-L124):

curl_handle.ssl_verify_peer(req.validate_certs)?;
curl_handle.ssl_verify_host(req.validate_certs)?;

即传入 false 时完全跳过 TLS 校验(常用于访问内网自签 CA 的 API),传入 true 时恢复正常校验——此时若你用的是私有 CA,就需要配合下一条的自定义根证书包。

5.2 自定义根证书包(root_cert_bundle_files)

root_cert_bundle_files 是一个 Vec<Vec<u8>>,每个元素是一份 PEM 编码证书包的原始字节。源码中的处理路径是(L152-L219):

  1. 通过 curl 的 ssl_ctx_function 回调拿到 libcurl 创建的 SSL_CTX 裸指针;
  2. get_x509_certs_from_root_cert_bundle_safe(L558-L573)用 openssl::x509::X509::stack_from_pem 逐个解析每个 bundle,解析失败的包只告警不中断;
  3. 将解析出的每张 X509 证书 add_certSSL_CTX 的 cert store;
  4. 最后执行 std::mem::forget(ssl_ctx_builder)

第 4 步是这段实现中最精妙的细节:SslContextBuilder 只是 openssl_sys::SSL_CTX 指针的安全包装,真正的 SSL_CTX 内存由 curl 拥有并在连接清理时释放。如果让 Rust 的 Drop 再释放一次就会发生 double free。源码注释引用了 libcurl 文档中“libcurl does not guarantee the lifetime of the passed in object once this callback function has returned”来解释为何故意“泄漏”这个薄包装(L195-L209)。

5.3 客户端证书认证

let client_cert = ClientCertDef::PEMCert {
    certificate_pem: std::fs::read("client.crt")?,
    key_pem: std::fs::read("client.key")?,
};

let mut request = RequestWithMetadata::new(
    4,
    "GET".to_string(),
    "https://secure-api.example.com".to_string(),
    vec![],
    None,
    true,
    vec![],
    Some(client_cert),
    None,
);

src/relay.rsapply_client_cert_to_curl_handle(L448-L556)看,两种格式的处理路径不同:

  • PEMCert:直接以 blob 形式设置 ssl_cert_type("PEM") + ssl_cert_blobssl_key_type("PEM") + ssl_key_blob,不落盘、不依赖系统证书目录;
  • PFXCert:先用 openssl::pkcs12::Pkcs12::from_der 解析 PFX 二进制,再用 parse2(password) 以口令解出证书与私钥,随后统一转换成 PEM 格式(证书 to_pem(),私钥 private_key_to_pem_pkcs8())走与 PEM 相同的路径下发给 curl。若 PFX 中缺少证书或私钥任一,返回明确的 RequestRunError

这种“PFX 转 PEM 再下发”的设计,让 Windows 用户习惯的 .p12/.pfx 单文件证书与 Linux/macOS 习惯的 .crt+.key 文件在 API 层保持一致体验。

6. 代理配置与实现细节

README 用法示例:

let proxy_config = ProxyConfig {
    url: "http://proxy.example.com:8080".to_string(),
};

let mut request = RequestWithMetadata::new(
    5,
    "GET".to_string(),
    "https://api.example.com".to_string(),
    vec![],
    None,
    true,
    vec![],
    None,
    Some(proxy_config),
);

实现见 src/relay.rsapply_proxy_config_to_curl_handle(L575-L595),共两步:

handle.proxy_auth(curl::easy::Auth::new().auto(true))?; // 自动协商代理认证方式
handle.proxy(&proxy_config.url)?;                       // 设置代理地址

这里有两点需要与 README 的特性描述对照理解:

  • ProxyConfig 结构体只有 url 一个字段(src/interop.rs L71-L74)。所谓“Authentication support”来自 Auth::new().auto(true):启用后 curl 会自动在 Basic/Digest/NTLM/Negotiate 等方式间协商,凭据通常内嵌在代理 URL 中(如 http://user:pass@host:port);
  • NTLM 支持并非默认 curl 能力,而是 Cargo.toml 中 curl-rust fork 显式开启的 features = ["ntlm"]

7. 请求取消:CancellationToken 与 progress 回调的配合

README 中的取消示例:

use tokio_util::sync::CancellationToken;

let cancel_token = CancellationToken::new();
let cancel_token_clone = cancel_token.clone();

// Spawn the request in a separate task
let request_handle = tokio::spawn(async move {
    hoppscotch_relay::run_request_task(&request, cancel_token_clone)
});

// Cancel the request after 5 seconds
tokio::time::sleep(Duration::from_secs(5)).await;
cancel_token.cancel();

其底层机制在 src/relay.rs L221-L247:由于 run_request_task 是阻塞式 transfer.perform(),取消无法用 async/await 的 select! 实现,而是挂在 curl 的 progress 回调上——每次传输进度更新时检查 cancel_token.is_cancelled(),一旦为真就返回 false,curl 随即中止传输;回调同时会打印已下载/上传的字节数日志。这与 README 中“Async design / Request cancellation support / Progress logs”三条特性一一对应:

  • Progress logs:未取消时以 debug 级别输出 Download: dlnow/dltotal, Upload: ulnow/ultotal
  • Cancellation:取消后回调返回 false 中止请求,并记录取消时刻的传输进度。

同时 progress(true) 在任务开头被强制启用(L32-L42),启用失败会直接返回错误——进度回调是整个取消机制的载体,所以它是硬性依赖而非可选项。

8. 错误处理与可观测性

8.1 错误模型

src/error.rs 定义了全部对外错误:

#[derive(Debug, Error, Serialize)]
pub enum RelayError {
    #[error("Invalid method")]
    InvalidMethod,        // custom_request(&method) 失败
    #[error("Invalid URL")]
    InvalidUrl,          // url(&endpoint) 失败
    #[error("Invalid headers")]
    InvalidHeaders,      // http_headers(...) 失败
    #[error("Request run error: {0}")]
    RequestRunError(String), // 其余运行期错误(body/证书/代理/传输等)
    ...
}
pub type RelayResult<T> = std::result::Result<T, RelayError>;

RelayError 派生了 Serialize,因此错误也能序列化回前端展示。执行函数内每一步 curl 调用(方法、URL、headers、body、SSL 校验、客户端证书、代理)都有独立分支,配置类错误映射为对应语义变体,其余统一收敛为 RequestRunError

8.2 日志

整个 run_request_task 全程埋点 log::info!/log::debug!/log::warn!:请求启动参数、证书包中每张证书的 Subject/有效期、响应头逐行接收、每个 body chunk 的累计字节数、最终状态码与耗时汇总(L338-L347)都在日志中可见。Cargo.toml 同时依赖 logenv_logger,宿主程序(如 Tauri 应用)负责初始化日志后端并控制级别,relay 自身只产生日志事件。

9. 从源码构建

README 建议从独立仓库克隆构建。在当前 monorepo 中,hoppscotch-relay 是工作区子包,直接在该目录执行即可:

# 方式一:按 README,克隆独立仓库
# git clone https://github.com/hoppscotch/hoppscotch-relay
# cd hoppscotch-relay
cargo build --release

# 方式二:在 Hoppscotch 仓库内直接构建该子包(当前仓库实际结构)
cd packages/hoppscotch-relay
cargo build --release

构建注意事项:由于 openssl/openssl-sys 均为 vendored,首次构建会从源码编译 OpenSSL,需要目标平台具备 C 编译器工具链;这也是“跨平台 SSL/TLS 行为一致”的来源——不依赖系统 OpenSSL 版本。

10. 小结与适用边界

hoppscotch-relay 用极小的 API 面(4 个导出项)把桌面端 API 开发最刚需的底层网络能力封装到位:任意方法与 body 类型、PEM/PFX 客户端证书、自定义根证书包、代理(含 NTLM)、请求取消与完整响应度量。几个使用边界值得留意:

  • run_request_task 是阻塞函数,生产集成时应放入独立任务/线程,避免阻塞 async runtime 主线程;
  • 响应体 data 是完整缓冲的 Vec<u8>,适合 API 调试场景,不适合作为超大文件下载的通用方案;
  • 代理凭据不单独建模,依赖 URL 内嵌 + curl auto-auth 协商,这是当前 ProxyConfig 结构决定的(从源码结构看,后续如需独立 username/password 字段,只需扩展该结构并在 apply_proxy_config_to_curl_handle 中补 proxy_userpass 调用)。

关键源码索引:请求执行主流程 src/relay.rs、数据模型 src/interop.rs、导出面 src/lib.rs、错误定义 src/error.rs、依赖与 OpenSSL 策略 Cargo.toml

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
docsdocs
暂无描述
Markdown
889
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341