src/http/client.rs 5.2 K raw
1
//! Building and sending a [`SavedRequest`], and capturing the response.
2
3
use std::collections::HashMap;
4
use std::time::{Duration, Instant};
5
6
use anyhow::{Context, Result, anyhow};
7
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
8
9
use super::vars::substitute;
10
use crate::model::{Method, SavedRequest};
11
12
#[derive(Debug, Clone)]
13
pub struct HttpResponse {
14
    pub status: u16,
15
    pub reason: String,
16
    pub elapsed: Duration,
17
    pub headers: Vec<(String, String)>,
18
    pub body: String,
19
    pub size: usize,
20
}
21
22
impl HttpResponse {
23
    pub fn status_line(&self) -> String {
24
        let ms = self.elapsed.as_millis();
25
        format!(
26
            "{} {} · {}ms · {}",
27
            self.status,
28
            self.reason,
29
            ms,
30
            human_size(self.size)
31
        )
32
    }
33
}
34
35
fn human_size(n: usize) -> String {
36
    if n < 1024 {
37
        format!("{n}B")
38
    } else if n < 1024 * 1024 {
39
        format!("{:.1}kB", n as f64 / 1024.0)
40
    } else {
41
        format!("{:.1}MB", n as f64 / 1024.0 / 1024.0)
42
    }
43
}
44
45
/// Send a request against `base_url`, applying `{{variable}}` substitution and
46
/// `{pathParam}` replacement. `bearer`, when present, sets the Authorization
47
/// header unless the request already defines one.
48
pub async fn send_request(
49
    client: &reqwest::Client,
50
    base_url: &str,
51
    req: &SavedRequest,
52
    vars: &HashMap<String, String>,
53
    bearer: Option<&str>,
54
) -> Result<HttpResponse> {
55
    let url = build_url(base_url, req, vars);
56
57
    let mut headers = HeaderMap::new();
58
    let mut has_auth = false;
59
    let mut has_content_type = false;
60
    for row in req
61
        .headers
62
        .iter()
63
        .filter(|r| r.enabled && !r.key.is_empty())
64
    {
65
        let name = HeaderName::from_bytes(substitute(&row.key, vars).as_bytes())
66
            .map_err(|e| anyhow!("invalid header name {:?}: {e}", row.key))?;
67
        let value = HeaderValue::from_str(&substitute(&row.value, vars))
68
            .map_err(|e| anyhow!("invalid value for header {:?}: {e}", row.key))?;
69
        if name == AUTHORIZATION {
70
            has_auth = true;
71
        }
72
        if name == CONTENT_TYPE {
73
            has_content_type = true;
74
        }
75
        headers.insert(name, value);
76
    }
77
78
    let method = match req.method {
79
        Method::Get => reqwest::Method::GET,
80
        Method::Post => reqwest::Method::POST,
81
        Method::Put => reqwest::Method::PUT,
82
        Method::Patch => reqwest::Method::PATCH,
83
        Method::Delete => reqwest::Method::DELETE,
84
        Method::Head => reqwest::Method::HEAD,
85
        Method::Options => reqwest::Method::OPTIONS,
86
    };
87
88
    let query: Vec<(String, String)> = req
89
        .query
90
        .iter()
91
        .filter(|r| r.enabled && !r.key.is_empty())
92
        .map(|r| (substitute(&r.key, vars), substitute(&r.value, vars)))
93
        .collect();
94
95
    let mut rb = client.request(method, &url).headers(headers).query(&query);
96
97
    if let Some(token) = bearer.filter(|_| !has_auth) {
98
        rb = rb.bearer_auth(token);
99
    }
100
101
    if let Some(body) = req.body.as_ref().filter(|b| !b.trim().is_empty()) {
102
        rb = rb.body(substitute(body, vars));
103
        if !has_content_type {
104
            rb = rb.header(CONTENT_TYPE, "application/json");
105
        }
106
    }
107
108
    let start = Instant::now();
109
    let resp = rb.send().await.context("request failed")?;
110
    let elapsed = start.elapsed();
111
112
    let status = resp.status();
113
    let reason = status.canonical_reason().unwrap_or("").to_string();
114
    let is_json = resp
115
        .headers()
116
        .get(CONTENT_TYPE)
117
        .and_then(|v| v.to_str().ok())
118
        .map(|ct| ct.contains("json"))
119
        .unwrap_or(false);
120
    let resp_headers: Vec<(String, String)> = resp
121
        .headers()
122
        .iter()
123
        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
124
        .collect();
125
    let bytes = resp.bytes().await.context("reading response body")?;
126
    let size = bytes.len();
127
    let raw = String::from_utf8_lossy(&bytes).into_owned();
128
    let body = if is_json {
129
        serde_json::from_str::<serde_json::Value>(&raw)
130
            .and_then(|v| serde_json::to_string_pretty(&v))
131
            .unwrap_or(raw)
132
    } else {
133
        raw
134
    };
135
136
    Ok(HttpResponse {
137
        status: status.as_u16(),
138
        reason,
139
        elapsed,
140
        headers: resp_headers,
141
        body,
142
        size,
143
    })
144
}
145
146
/// Build the final URL: base + path with `{{vars}}` and `{pathParams}` applied.
147
pub fn build_url(base_url: &str, req: &SavedRequest, vars: &HashMap<String, String>) -> String {
148
    let mut path = substitute(&req.path, vars);
149
    for row in req.path_params.iter().filter(|r| r.enabled) {
150
        let value = encode_path_segment(&substitute(&row.value, vars));
151
        path = path.replace(&format!("{{{}}}", row.key), &value);
152
    }
153
    format!(
154
        "{}/{}",
155
        base_url.trim_end_matches('/'),
156
        path.trim_start_matches('/')
157
    )
158
}
159
160
/// Percent-encode a path parameter value (unreserved chars kept as-is).
161
fn encode_path_segment(s: &str) -> String {
162
    let mut out = String::with_capacity(s.len());
163
    for b in s.bytes() {
164
        match b {
165
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
166
                out.push(b as char)
167
            }
168
            _ => out.push_str(&format!("%{b:02X}")),
169
        }
170
    }
171
    out
172
}