Skip to main content

HTTP 请求工具实战

这一章把前面的 GPUI 基础落到 HiPoster 的核心业务:一个可发送 HTTP 请求的桌面工具。目标不是复刻 Postman 的全部功能,而是搭出一个结构清楚、可扩展的最小产品。

功能拆分

一个 HTTP 请求工具至少包含:

  • 请求方法:GETPOSTPUTDELETEPATCHHEADOPTIONS
  • URL 与查询参数。
  • Header 列表。
  • Body:None、Raw JSON/Text/XML/HTML、Form Data、Urlencoded。
  • Auth:None、Bearer Token、Basic Auth。
  • Response:状态码、状态文本、耗时、大小、Headers、Body。
  • 历史记录:保存最近请求并支持重新打开。

这些功能正好对应三个层次:

  • 模型层:HttpRequestHttpResponseHttpBodyAuthData
  • 服务层:execute_request(&HttpRequest)
  • 视图层:ApiTabHiposter

请求模型

先定义不会依赖 UI 的模型:

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum HttpBody {
None,
Raw(String),
FormData(Vec<FormDataItem>),
UrlEncoded(Vec<Header>),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpRequest {
pub method: HttpMethod,
pub url: String,
pub headers: Vec<Header>,
pub params: Vec<Header>,
pub body: HttpBody,
pub content_type: String,
pub auth: AuthData,
}

Body 类型不要只靠 content_type: String 推断,否则 UI 会很快陷入“字符串状态机”。content_type 负责 HTTP Header,HttpBody 负责程序内部结构。

从 UI 收集请求

ApiTab 里有许多输入框实体,发送前需要把它们收集成 HttpRequest

fn update_request_state(&mut self, cx: &mut Context<Self>) {
self.request.url = self.url_input.read(cx).value().to_string();

self.request.headers = self.headers.iter().filter_map(|row| {
let key = row.key.read(cx).value().trim().to_string();
let value = row.value.read(cx).value().to_string();
(!key.is_empty()).then_some(Header { key, value })
}).collect();

self.request.auth.token = self.auth_token_input.read(cx).value().to_string();
self.request.auth.username = self.auth_username_input.read(cx).value().to_string();
self.request.auth.password = self.auth_password_input.read(cx).value().to_string();
}

这里有两个细节:

  • 空 key 的 Header、Param、Form 行应该被过滤,避免发送无效数据。
  • 从 UI 读出的数据应该一次性收集,之后异步任务使用 request.clone(),不要在后台任务里读输入框。

URL 与 Params 同步

用户会在 URL 中直接写查询参数,也会在 Params 表格中编辑。推荐提供双向同步,但要避免循环。

解析 URL:

pub fn parse_url_params(url: &str) -> (String, Vec<(String, String)>) {
if let Some((base, query)) = url.split_once('?') {
let params = query
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
(key.to_string(), value.to_string())
})
.collect();

(base.to_string(), params)
} else {
(url.to_string(), Vec::new())
}
}

生产项目建议用 url crate 来处理编码、空值、重复 key 和 Unicode;HiPoster 当前实现是轻量手写版本,适合教学,但遇到复杂 URL 时应替换成标准解析器。

请求发送

网络层使用 reqwest

pub async fn execute_request(request: &HttpRequest) -> anyhow::Result<HttpResponse> {
let client = get_client();
let method = match request.method {
HttpMethod::GET => reqwest::Method::GET,
HttpMethod::POST => reqwest::Method::POST,
HttpMethod::PUT => reqwest::Method::PUT,
HttpMethod::DELETE => reqwest::Method::DELETE,
HttpMethod::PATCH => reqwest::Method::PATCH,
HttpMethod::HEAD => reqwest::Method::HEAD,
HttpMethod::OPTIONS => reqwest::Method::OPTIONS,
};

let mut builder = client.request(method, &request.url);

for header in &request.headers {
if !header.key.trim().is_empty() {
builder = builder.header(&header.key, &header.value);
}
}

let started = std::time::Instant::now();
let response = builder.send().await?;
let elapsed_ms = started.elapsed().as_millis();

let status = response.status();
let bytes = response.bytes().await?;

Ok(HttpResponse {
status_code: status.as_u16(),
status_text: status.canonical_reason().unwrap_or("").to_string(),
headers: Vec::new(),
body: String::from_utf8_lossy(&bytes).to_string(),
size: bytes.len(),
elapsed_ms,
})
}

真实实现还要加入认证和 Body:

match &request.auth.auth_type {
AuthType::Bearer if !request.auth.token.is_empty() => {
builder = builder.bearer_auth(&request.auth.token);
}
AuthType::Basic => {
builder = builder.basic_auth(&request.auth.username, Some(&request.auth.password));
}
_ => {}
}

Body 处理:

if !matches!(request.method, HttpMethod::GET | HttpMethod::HEAD) {
match &request.body {
HttpBody::Raw(raw) if !raw.is_empty() => {
builder = builder
.header("Content-Type", &request.content_type)
.body(raw.clone());
}
HttpBody::UrlEncoded(form) => {
let fields: Vec<_> = form.iter()
.filter(|field| !field.key.is_empty())
.map(|field| (field.key.clone(), field.value.clone()))
.collect();
builder = builder.form(&fields);
}
HttpBody::FormData(_) => {
// 逐项构造 reqwest::multipart::Form
}
HttpBody::None => {}
}
}

响应展示

响应区通常分成两个 Tab:

  • Body:Pretty / Raw。
  • Headers:键值列表。

Pretty JSON 不要每次 render 都格式化。收到响应后只标记数据脏:

pub fn set_response(&mut self, response: HttpResponse, cx: &mut Context<Self>) {
self.response = Some(response);
self.dirty_response = true;
cx.notify();
}

真正更新展示输入框时再做格式化:

pub fn update_response_display(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(response) = &self.response {
let display_content = if self.active_response_view == ResponseView::Pretty {
serde_json::from_str::<serde_json::Value>(&response.body)
.ok()
.map(|value| format_json(&value))
.unwrap_or_else(|| response.body.clone())
} else {
response.body.clone()
};

self.response_body_input.update(cx, |input, cx| {
input.set_value(display_content, window, cx);
});

self.dirty_response = false;
}
}

历史记录

历史记录放在根视图中更合理,因为它跨 Tab 存在:

pub struct HistoryItem {
pub request: HttpRequest,
pub timestamp: u64,
}

pub fn add_history(&mut self, request: HttpRequest) {
self.history.insert(0, HistoryItem {
request,
timestamp: current_timestamp(),
});
self.history.truncate(50);
self.save_history();
}

点击历史项时创建新 Tab:

.on_click(cx.listener(move |this, _, window, cx| {
this.add_tab(request.clone(), window, cx);
}))

这里不要复用旧 Tab 的 UI 状态,历史项应该只保存可序列化请求模型。

下一步可拓展功能

  • 请求集合与文件夹。
  • 环境变量和变量插值。
  • Cookie 管理。
  • 响应搜索。
  • 请求取消和超时配置。
  • 导入导出 OpenAPI、curl、Postman Collection。
  • 二进制响应保存到文件。

这些功能都可以沿用本章结构:先扩展纯模型,再扩展服务层,最后更新视图。