Skip to main content

torii_lib/util/
http.rs

1//! Shared HTTP helpers for the platform clients (`pr`, `issue`,
2//! `release`, `pipeline`, `package`).
3//!
4//! Before 0.7.14, each surface had its own boilerplate around `reqwest`:
5//! build a client, set user-agent, send, parse status, extract a JSON
6//! message field for errors. That repeated across ~15 client structs
7//! (GitHub × GitLab × Gitea × 5 surfaces).
8//!
9//! This module consolidates that into three primitives:
10//!
11//! - [`make_client`] — builds the `reqwest::blocking::Client` with the
12//!   gitorii user-agent. Use everywhere instead of inline `Client::builder()`.
13//! - [`send_json`] — runs a `RequestBuilder`, checks status, returns
14//!   the parsed JSON body. Folds the three-line send/status/parse
15//!   dance into one call.
16//! - [`send_empty`] — same, but for operations that don't return a body
17//!   we care about (cancel, retry, delete).
18//! - [`extract_array`] — turns a JSON value into a `&Vec<Value>` with
19//!   a consistent error message when the platform returns a non-array.
20//!
21//! The `ctx` parameter on `send_json` / `send_empty` is a free-form
22//! label that goes into the error message (e.g. `"GitHub"`,
23//! `"Gitea (cancel pipeline)"`). Callers include the URL there when it
24//! helps debugging.
25
26use std::time::Duration;
27
28use reqwest::blocking::{Client, RequestBuilder};
29use serde_json::Value;
30
31use crate::error::{Result, ToriiError};
32
33/// User-agent string sent on every platform API call.
34pub const USER_AGENT: &str = "gitorii-cli";
35
36/// Per-request hard cap. A platform API that hangs longer than this
37/// should fail and surface a clear error instead of freezing torii.
38/// 60 s is generous — most API endpoints respond in <2 s; the outlier
39/// is GitLab Pipeline list on huge projects which can take 10-15 s.
40const REQUEST_TIMEOUT_SECS: u64 = 60;
41
42/// Hard cap on the *connect* phase. If we can't reach the host at all
43/// in 10 s, no API call is going to succeed either.
44const CONNECT_TIMEOUT_SECS: u64 = 10;
45
46/// Construct the standard blocking HTTP client used by every platform
47/// client. Sets a global request timeout so a hung API can't freeze
48/// torii forever. Panics only on a build failure we don't expect at
49/// runtime (would mean `reqwest` is fundamentally broken).
50pub fn make_client() -> Client {
51    Client::builder()
52        .user_agent(USER_AGENT)
53        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
54        .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
55        .build()
56        .expect("reqwest client build failed")
57}
58
59/// Send a request, check status, parse JSON. Returns the parsed value
60/// on 2xx; on any other status, returns a `PlatformApi`
61/// error including the platform's own message field when present.
62///
63/// `ctx` is a short label for the error message ("GitHub", "Gitea
64/// retry", etc.) — the caller picks something that disambiguates.
65pub fn send_json(req: RequestBuilder, ctx: &str) -> Result<Value> {
66    let resp = req.send().map_err(|e| ToriiError::Network {
67        provider: ctx.to_string(),
68        message: e.to_string(),
69    })?;
70    let status = resp.status();
71    let body = resp.text().map_err(|e| ToriiError::Network {
72        provider: ctx.to_string(),
73        message: format!("read error: {}", e),
74    })?;
75    let json: Value =
76        serde_json::from_str(&body).unwrap_or_else(|_| serde_json::json!({ "raw_body": body }));
77    if !status.is_success() {
78        let msg = json
79            .get("message")
80            .and_then(|v| v.as_str())
81            .or_else(|| json.get("error").and_then(|v| v.as_str()))
82            .unwrap_or(if body.is_empty() {
83                "(no message)"
84            } else {
85                &body
86            });
87        return Err(ToriiError::PlatformApi {
88            provider: ctx.to_string(),
89            status: status.as_u16(),
90            message: msg.to_string(),
91        });
92    }
93    Ok(json)
94}
95
96/// Send a request and ignore the response body, only checking status.
97/// Used for cancel / retry / delete style operations.
98pub fn send_empty(req: RequestBuilder, ctx: &str) -> Result<()> {
99    let resp = req.send().map_err(|e| ToriiError::Network {
100        provider: ctx.to_string(),
101        message: e.to_string(),
102    })?;
103    if !resp.status().is_success() {
104        let s = resp.status();
105        let txt = resp.text().unwrap_or_default();
106        return Err(ToriiError::PlatformApi {
107            provider: ctx.to_string(),
108            status: s.as_u16(),
109            message: txt,
110        });
111    }
112    Ok(())
113}
114
115/// Extract the top-level array from a JSON value, or fail with a
116/// consistent diagnostic that includes the URL/context.
117pub fn extract_array<'a>(json: &'a Value, ctx: &str) -> Result<&'a Vec<Value>> {
118    json.as_array()
119        .ok_or_else(|| ToriiError::MalformedResponse {
120            provider: ctx.to_string(),
121            message: format!("expected array body, got: {}", json),
122        })
123}
124
125/// Send a request and return its body as text. For endpoints like
126/// `/jobs/{id}/trace` or `/builds/{id}/log` that return plain text
127/// instead of JSON — bypasses [`send_json`]'s `serde_json` parse step.
128///
129/// Same error shape as [`send_json`]: status check, contextual error
130/// message, single point of timeout enforcement.
131pub fn send_text(req: RequestBuilder, ctx: &str) -> Result<String> {
132    let resp = req.send().map_err(|e| ToriiError::Network {
133        provider: ctx.to_string(),
134        message: e.to_string(),
135    })?;
136    let status = resp.status();
137    let body = resp.text().map_err(|e| ToriiError::Network {
138        provider: ctx.to_string(),
139        message: format!("read error: {}", e),
140    })?;
141    if !status.is_success() {
142        return Err(ToriiError::PlatformApi {
143            provider: ctx.to_string(),
144            status: status.as_u16(),
145            message: if body.is_empty() {
146                "(empty body)".to_string()
147            } else {
148                body.lines().next().unwrap_or(&body).to_string()
149            },
150        });
151    }
152    Ok(body)
153}
154
155/// Send a request and return its body as raw bytes. For artifact
156/// downloads (zip / tarball) — the bytes go straight to disk on the
157/// caller's side.
158pub fn send_bytes(req: RequestBuilder, ctx: &str) -> Result<Vec<u8>> {
159    let resp = req.send().map_err(|e| ToriiError::Network {
160        provider: ctx.to_string(),
161        message: e.to_string(),
162    })?;
163    let status = resp.status();
164    if !status.is_success() {
165        let body = resp.text().unwrap_or_default();
166        return Err(ToriiError::PlatformApi {
167            provider: ctx.to_string(),
168            status: status.as_u16(),
169            message: if body.is_empty() {
170                "(binary response, empty)".to_string()
171            } else {
172                body
173            },
174        });
175    }
176    let bytes = resp.bytes().map_err(|e| ToriiError::Network {
177        provider: ctx.to_string(),
178        message: format!("read error: {}", e),
179    })?;
180    Ok(bytes.to_vec())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::error::ToriiError;
187    use httpmock::prelude::*;
188
189    #[test]
190    fn send_json_returns_parsed_body_on_2xx() {
191        let server = MockServer::start();
192        let m = server.mock(|when, then| {
193            when.method(GET).path("/ok");
194            then.status(200)
195                .json_body(serde_json::json!({ "id": 7, "name": "torii" }));
196        });
197        let json = send_json(make_client().get(server.url("/ok")), "Test").unwrap();
198        m.assert();
199        assert_eq!(json["id"], 7);
200        assert_eq!(json["name"], "torii");
201    }
202
203    #[test]
204    fn send_json_maps_non_2xx_to_platform_api_with_status_and_message() {
205        let server = MockServer::start();
206        server.mock(|when, then| {
207            when.method(GET).path("/missing");
208            then.status(404)
209                .json_body(serde_json::json!({ "message": "Not Found" }));
210        });
211        let err = send_json(make_client().get(server.url("/missing")), "Test").unwrap_err();
212        match err {
213            ToriiError::PlatformApi {
214                provider,
215                status,
216                message,
217            } => {
218                assert_eq!(provider, "Test");
219                assert_eq!(status, 404);
220                assert_eq!(message, "Not Found");
221            }
222            other => panic!("expected PlatformApi, got: {other:?}"),
223        }
224    }
225
226    #[test]
227    fn send_json_maps_transport_failure_to_network() {
228        // Port 1 has no listener — immediate connection refused.
229        let err = send_json(make_client().get("http://127.0.0.1:1/x"), "Test").unwrap_err();
230        assert!(
231            matches!(err, ToriiError::Network { ref provider, .. } if provider == "Test"),
232            "expected Network, got: {err:?}"
233        );
234    }
235
236    #[test]
237    fn send_empty_ok_on_2xx_platform_api_on_failure() {
238        let server = MockServer::start();
239        server.mock(|when, then| {
240            when.method(POST).path("/del");
241            then.status(204);
242        });
243        server.mock(|when, then| {
244            when.method(POST).path("/forbidden");
245            then.status(403).body("nope");
246        });
247        assert!(send_empty(make_client().post(server.url("/del")), "Test").is_ok());
248        let err = send_empty(make_client().post(server.url("/forbidden")), "Test").unwrap_err();
249        match err {
250            ToriiError::PlatformApi {
251                status, message, ..
252            } => {
253                assert_eq!(status, 403);
254                assert_eq!(message, "nope");
255            }
256            other => panic!("expected PlatformApi, got: {other:?}"),
257        }
258    }
259
260    #[test]
261    fn extract_array_rejects_non_array_as_malformed_response() {
262        let json = serde_json::json!({ "values": [] });
263        let err = extract_array(&json, "Test").unwrap_err();
264        assert!(
265            matches!(err, ToriiError::MalformedResponse { ref provider, .. } if provider == "Test"),
266            "expected MalformedResponse, got: {err:?}"
267        );
268        let arr_json = serde_json::json!([1, 2]);
269        assert_eq!(extract_array(&arr_json, "Test").unwrap().len(), 2);
270    }
271
272    #[test]
273    fn send_text_and_send_bytes_return_raw_bodies() {
274        let server = MockServer::start();
275        server.mock(|when, then| {
276            when.method(GET).path("/log");
277            then.status(200).body("line1\nline2\n");
278        });
279        let text = send_text(make_client().get(server.url("/log")), "Test").unwrap();
280        assert_eq!(text, "line1\nline2\n");
281        let bytes = send_bytes(make_client().get(server.url("/log")), "Test").unwrap();
282        assert_eq!(bytes, b"line1\nline2\n");
283    }
284}