src/http/client.rs 5.9 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. `extra_headers` (e.g. an API
48
/// key) are added the same way — only for names the request didn't set itself.
49
pub async fn send_request(
50
    client: &reqwest::Client,
51
    base_url: &str,
52
    req: &SavedRequest,
53
    vars: &HashMap<String, String>,
54
    bearer: Option<&str>,
55
    extra_headers: &[(String, String)],
56
) -> Result<HttpResponse> {
57
    let url = build_url(base_url, req, vars);
58
59
    let mut headers = HeaderMap::new();
60
    let mut has_auth = false;
61
    let mut has_content_type = false;
62
    for row in req
63
        .headers
64
        .iter()
65
        .filter(|r| r.enabled && !r.key.is_empty())
66
    {
67
        let name = HeaderName::from_bytes(substitute(&row.key, vars).as_bytes())
68
            .map_err(|e| anyhow!("invalid header name {:?}: {e}", row.key))?;
69
        let value = HeaderValue::from_str(&substitute(&row.value, vars))
70
            .map_err(|e| anyhow!("invalid value for header {:?}: {e}", row.key))?;
71
        if name == AUTHORIZATION {
72
            has_auth = true;
73
        }
74
        if name == CONTENT_TYPE {
75
            has_content_type = true;
76
        }
77
        headers.insert(name, value);
78
    }
79
80
    // Auth-supplied headers defer to anything the request set explicitly.
81
    for (key, val) in extra_headers {
82
        let name = HeaderName::from_bytes(key.as_bytes())
83
            .map_err(|e| anyhow!("invalid header name {key:?}: {e}"))?;
84
        if headers.contains_key(&name) {
85
            continue;
86
        }
87
        let value = HeaderValue::from_str(val)
88
            .map_err(|e| anyhow!("invalid value for header {key:?}: {e}"))?;
89
        if name == AUTHORIZATION {
90
            has_auth = true;
91
        }
92
        headers.insert(name, value);
93
    }
94
95
    let method = match req.method {
96
        Method::Get => reqwest::Method::GET,
97
        Method::Post => reqwest::Method::POST,
98
        Method::Put => reqwest::Method::PUT,
99
        Method::Patch => reqwest::Method::PATCH,
100
        Method::Delete => reqwest::Method::DELETE,
101
        Method::Head => reqwest::Method::HEAD,
102
        Method::Options => reqwest::Method::OPTIONS,
103
    };
104
105
    let query: Vec<(String, String)> = req
106
        .query
107
        .iter()
108
        .filter(|r| r.enabled && !r.key.is_empty())
109
        .map(|r| (substitute(&r.key, vars), substitute(&r.value, vars)))
110
        .collect();
111
112
    let mut rb = client.request(method, &url).headers(headers).query(&query);
113
114
    if let Some(token) = bearer.filter(|_| !has_auth) {
115
        rb = rb.bearer_auth(token);
116
    }
117
118
    if let Some(body) = req.body.as_ref().filter(|b| !b.trim().is_empty()) {
119
        rb = rb.body(substitute(body, vars));
120
        if !has_content_type {
121
            rb = rb.header(CONTENT_TYPE, "application/json");
122
        }
123
    }
124
125
    let start = Instant::now();
126
    let resp = rb.send().await.context("request failed")?;
127
    let elapsed = start.elapsed();
128
129
    let status = resp.status();
130
    let reason = status.canonical_reason().unwrap_or("").to_string();
131
    let is_json = resp
132
        .headers()
133
        .get(CONTENT_TYPE)
134
        .and_then(|v| v.to_str().ok())
135
        .map(|ct| ct.contains("json"))
136
        .unwrap_or(false);
137
    let resp_headers: Vec<(String, String)> = resp
138
        .headers()
139
        .iter()
140
        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
141
        .collect();
142
    let bytes = resp.bytes().await.context("reading response body")?;
143
    let size = bytes.len();
144
    let raw = String::from_utf8_lossy(&bytes).into_owned();
145
    let body = if is_json {
146
        serde_json::from_str::<serde_json::Value>(&raw)
147
            .and_then(|v| serde_json::to_string_pretty(&v))
148
            .unwrap_or(raw)
149
    } else {
150
        raw
151
    };
152
153
    Ok(HttpResponse {
154
        status: status.as_u16(),
155
        reason,
156
        elapsed,
157
        headers: resp_headers,
158
        body,
159
        size,
160
    })
161
}
162
163
/// Build the final URL: base + path with `{{vars}}` and `{pathParams}` applied.
164
pub fn build_url(base_url: &str, req: &SavedRequest, vars: &HashMap<String, String>) -> String {
165
    let mut path = substitute(&req.path, vars);
166
    for row in req.path_params.iter().filter(|r| r.enabled) {
167
        let value = encode_path_segment(&substitute(&row.value, vars));
168
        path = path.replace(&format!("{{{}}}", row.key), &value);
169
    }
170
    format!(
171
        "{}/{}",
172
        base_url.trim_end_matches('/'),
173
        path.trim_start_matches('/')
174
    )
175
}
176
177
/// Percent-encode a path parameter value (unreserved chars kept as-is).
178
fn encode_path_segment(s: &str) -> String {
179
    let mut out = String::with_capacity(s.len());
180
    for b in s.bytes() {
181
        match b {
182
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
183
                out.push(b as char)
184
            }
185
            _ => out.push_str(&format!("%{b:02X}")),
186
        }
187
    }
188
    out
189
}