Skip to main content

xbp_http/
lib.rs

1//! Shared HTTP request and response helpers for XBP integrations.
2//!
3//! `RequestFactory` centralizes base-URL handling, authentication, default headers,
4//! JSON requests, multipart uploads, and binary responses. Provider-specific clients
5//! can use these primitives without duplicating response decoding and error extraction.
6
7use reqwest::header::{
8    HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT,
9};
10use reqwest::{multipart::Form, Client, Method, StatusCode, Url};
11use serde::Serialize;
12use serde_json::Value;
13use std::borrow::Cow;
14use thiserror::Error;
15
16#[derive(Debug, Clone)]
17pub enum AuthStrategy {
18    None,
19    Bearer(String),
20    Header {
21        name: HeaderName,
22        value: HeaderValue,
23    },
24}
25
26#[derive(Debug, Clone)]
27pub struct RequestFactory {
28    client: Client,
29    base_url: Url,
30    auth: AuthStrategy,
31    default_headers: HeaderMap,
32}
33
34#[derive(Debug, Clone)]
35pub struct ResponseBytes {
36    pub content_type: Option<String>,
37    pub body: Vec<u8>,
38}
39
40#[derive(Debug, Error)]
41pub enum HttpError {
42    #[error("{message}")]
43    Request {
44        message: String,
45        status: Option<StatusCode>,
46        body: Option<String>,
47    },
48    #[error("failed to build request: {0}")]
49    Build(String),
50    #[error("failed to parse response JSON: {0}")]
51    Decode(String),
52}
53
54impl HttpError {
55    /// Creates an HTTP error with an optional status code and response body.
56    pub fn request(
57        message: impl Into<String>,
58        status: Option<StatusCode>,
59        body: Option<String>,
60    ) -> Self {
61        Self::Request {
62            message: message.into(),
63            status,
64            body,
65        }
66    }
67}
68
69impl RequestFactory {
70    /// Creates a request factory rooted at `base_url`.
71    ///
72    /// The factory starts without authentication and sends JSON-oriented `Accept`
73    /// and XBP `User-Agent` headers on every request.
74    pub fn new(base_url: impl AsRef<str>) -> Result<Self, HttpError> {
75        let client = Client::builder()
76            .user_agent("xbp")
77            .build()
78            .map_err(|error| HttpError::Build(error.to_string()))?;
79        let base_url =
80            Url::parse(base_url.as_ref()).map_err(|error| HttpError::Build(error.to_string()))?;
81        let mut default_headers = HeaderMap::new();
82        default_headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
83        default_headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
84        Ok(Self {
85            client,
86            base_url,
87            auth: AuthStrategy::None,
88            default_headers,
89        })
90    }
91
92    /// Returns a copy of this factory configured with the selected authentication strategy.
93    pub fn with_auth(mut self, auth: AuthStrategy) -> Self {
94        self.auth = auth;
95        self
96    }
97
98    /// Returns a copy of this factory with an additional default request header.
99    pub fn with_default_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
100        self.default_headers.insert(name, value);
101        self
102    }
103
104    /// Sends a GET request and deserializes its JSON response into `T`.
105    pub async fn get_json<T, Q>(&self, path: &str, query: Option<&Q>) -> Result<T, HttpError>
106    where
107        T: serde::de::DeserializeOwned,
108        Q: Serialize + ?Sized,
109    {
110        self.send_json(Method::GET, path, query, Option::<&Value>::None)
111            .await
112    }
113
114    /// Sends a DELETE request and deserializes its JSON response into `T`.
115    pub async fn delete_json<T, Q>(&self, path: &str, query: Option<&Q>) -> Result<T, HttpError>
116    where
117        T: serde::de::DeserializeOwned,
118        Q: Serialize + ?Sized,
119    {
120        self.send_json(Method::DELETE, path, query, Option::<&Value>::None)
121            .await
122    }
123
124    /// Sends a DELETE request with an optional query and JSON body.
125    pub async fn delete_json_with_body<T, Q, B>(
126        &self,
127        path: &str,
128        query: Option<&Q>,
129        body: &B,
130    ) -> Result<T, HttpError>
131    where
132        T: serde::de::DeserializeOwned,
133        Q: Serialize + ?Sized,
134        B: Serialize + ?Sized,
135    {
136        self.send_json(Method::DELETE, path, query, Some(body))
137            .await
138    }
139
140    /// Sends a POST request with a JSON body and deserializes its response.
141    pub async fn post_json<T, B>(&self, path: &str, body: &B) -> Result<T, HttpError>
142    where
143        T: serde::de::DeserializeOwned,
144        B: Serialize + ?Sized,
145    {
146        self.send_json(Method::POST, path, Option::<&Value>::None, Some(body))
147            .await
148    }
149
150    /// Sends a PUT request with a JSON body and deserializes its response.
151    pub async fn put_json<T, B>(&self, path: &str, body: &B) -> Result<T, HttpError>
152    where
153        T: serde::de::DeserializeOwned,
154        B: Serialize + ?Sized,
155    {
156        self.send_json(Method::PUT, path, Option::<&Value>::None, Some(body))
157            .await
158    }
159
160    /// Sends a multipart PUT request and deserializes its JSON response.
161    pub async fn put_multipart_json<T>(&self, path: &str, form: Form) -> Result<T, HttpError>
162    where
163        T: serde::de::DeserializeOwned,
164    {
165        let response = self
166            .request(Method::PUT, path)?
167            .multipart(form)
168            .send()
169            .await
170            .map_err(|error| HttpError::request(error.to_string(), None, None))?;
171        let status = response.status();
172        let body = response
173            .text()
174            .await
175            .map_err(|error| HttpError::request(error.to_string(), Some(status), None))?;
176        if !status.is_success() {
177            let message = extract_cloudflare_error_message(&body)
178                .or_else(|| extract_github_error_message(&body))
179                .unwrap_or_else(|| format!("HTTP {}", status));
180            return Err(HttpError::request(message, Some(status), Some(body)));
181        }
182        serde_json::from_str(&body).map_err(|error| HttpError::Decode(error.to_string()))
183    }
184
185    /// Sends a multipart POST request and deserializes its JSON response.
186    pub async fn post_multipart_json<T>(&self, path: &str, form: Form) -> Result<T, HttpError>
187    where
188        T: serde::de::DeserializeOwned,
189    {
190        let response = self
191            .request(Method::POST, path)?
192            .multipart(form)
193            .send()
194            .await
195            .map_err(|error| HttpError::request(error.to_string(), None, None))?;
196        let status = response.status();
197        let body = response
198            .text()
199            .await
200            .map_err(|error| HttpError::request(error.to_string(), Some(status), None))?;
201        if !status.is_success() {
202            let message = extract_cloudflare_error_message(&body)
203                .or_else(|| extract_github_error_message(&body))
204                .unwrap_or_else(|| format!("HTTP {}", status));
205            return Err(HttpError::request(message, Some(status), Some(body)));
206        }
207        serde_json::from_str(&body).map_err(|error| HttpError::Decode(error.to_string()))
208    }
209
210    /// Sends a PATCH request with a JSON body and deserializes its response.
211    pub async fn patch_json<T, B>(&self, path: &str, body: &B) -> Result<T, HttpError>
212    where
213        T: serde::de::DeserializeOwned,
214        B: Serialize + ?Sized,
215    {
216        self.send_json(Method::PATCH, path, Option::<&Value>::None, Some(body))
217            .await
218    }
219
220    /// Sends raw bytes in a POST request and returns the response body unchanged.
221    pub async fn post_bytes(
222        &self,
223        path: &str,
224        bytes: Vec<u8>,
225        content_type: &'static str,
226    ) -> Result<ResponseBytes, HttpError> {
227        let response = self
228            .request(Method::POST, path)?
229            .header(CONTENT_TYPE, content_type)
230            .body(bytes)
231            .send()
232            .await
233            .map_err(|error| HttpError::request(error.to_string(), None, None))?;
234        self.read_bytes_response(response).await
235    }
236
237    /// Sends a GET request and returns the response content type and raw bytes.
238    pub async fn get_bytes<Q>(
239        &self,
240        path: &str,
241        query: Option<&Q>,
242    ) -> Result<ResponseBytes, HttpError>
243    where
244        Q: Serialize + ?Sized,
245    {
246        let mut request = self.request(Method::GET, path)?;
247        if let Some(query) = query {
248            request = request.query(query);
249        }
250        let response = request
251            .send()
252            .await
253            .map_err(|error| HttpError::request(error.to_string(), None, None))?;
254        self.read_bytes_response(response).await
255    }
256
257    async fn send_json<T, Q, B>(
258        &self,
259        method: Method,
260        path: &str,
261        query: Option<&Q>,
262        body: Option<&B>,
263    ) -> Result<T, HttpError>
264    where
265        T: serde::de::DeserializeOwned,
266        Q: Serialize + ?Sized,
267        B: Serialize + ?Sized,
268    {
269        let mut request = self.request(method, path)?;
270        if let Some(query) = query {
271            request = request.query(query);
272        }
273        if let Some(body) = body {
274            request = request.json(body);
275        }
276        let response = request
277            .send()
278            .await
279            .map_err(|error| HttpError::request(error.to_string(), None, None))?;
280        let status = response.status();
281        let body = response
282            .text()
283            .await
284            .map_err(|error| HttpError::request(error.to_string(), Some(status), None))?;
285        if !status.is_success() {
286            let message = extract_cloudflare_error_message(&body)
287                .or_else(|| extract_github_error_message(&body))
288                .unwrap_or_else(|| format!("HTTP {}", status));
289            return Err(HttpError::request(message, Some(status), Some(body)));
290        }
291        serde_json::from_str(&body).map_err(|error| HttpError::Decode(error.to_string()))
292    }
293
294    fn request(&self, method: Method, path: &str) -> Result<reqwest::RequestBuilder, HttpError> {
295        // `Url::join` treats a leading slash as an origin-relative path. Preserve
296        // the configured base path so factories rooted below `/api/` behave as expected.
297        let mut url = self
298            .base_url
299            .join(path)
300            .map_err(|error| HttpError::Build(error.to_string()))?;
301        if path.starts_with('/') {
302            let joined = format!("{}{}", self.base_url.as_str().trim_end_matches('/'), path);
303            url = Url::parse(&joined).map_err(|error| HttpError::Build(error.to_string()))?;
304        }
305
306        let mut builder = self.client.request(method, url);
307        builder = builder.headers(self.default_headers.clone());
308        match &self.auth {
309            AuthStrategy::None => {}
310            AuthStrategy::Bearer(token) => {
311                builder = builder.header(AUTHORIZATION, format!("Bearer {}", token));
312            }
313            AuthStrategy::Header { name, value } => {
314                builder = builder.header(name, value);
315            }
316        }
317        Ok(builder)
318    }
319
320    async fn read_bytes_response(
321        &self,
322        response: reqwest::Response,
323    ) -> Result<ResponseBytes, HttpError> {
324        let status = response.status();
325        let content_type = response
326            .headers()
327            .get(CONTENT_TYPE)
328            .and_then(|value| value.to_str().ok())
329            .map(str::to_string);
330        let bytes = response
331            .bytes()
332            .await
333            .map_err(|error| HttpError::request(error.to_string(), Some(status), None))?;
334        if !status.is_success() {
335            let body = String::from_utf8_lossy(&bytes).to_string();
336            let message = extract_cloudflare_error_message(&body)
337                .or_else(|| extract_github_error_message(&body))
338                .unwrap_or_else(|| format!("HTTP {}", status));
339            return Err(HttpError::request(message, Some(status), Some(body)));
340        }
341        Ok(ResponseBytes {
342            content_type,
343            body: bytes.to_vec(),
344        })
345    }
346}
347
348/// Extracts a GitHub-style top-level `message` from a JSON error body.
349pub fn extract_github_error_message(body: &str) -> Option<String> {
350    let parsed = serde_json::from_str::<Value>(body.trim()).ok()?;
351    parsed
352        .get("message")
353        .and_then(Value::as_str)
354        .map(str::trim)
355        .filter(|value| !value.is_empty())
356        .map(ToOwned::to_owned)
357}
358
359/// Extracts and formats Cloudflare API errors from a JSON error body.
360pub fn extract_cloudflare_error_message(body: &str) -> Option<String> {
361    let parsed = serde_json::from_str::<Value>(body.trim()).ok()?;
362    let errors = parsed.get("errors")?.as_array()?;
363    let messages = errors
364        .iter()
365        .filter_map(|entry| {
366            let code = entry.get("code").and_then(Value::as_i64);
367            let message = entry.get("message").and_then(Value::as_str)?.trim();
368            if message.is_empty() {
369                return None;
370            }
371            Some(match code {
372                Some(code) => Cow::Owned(format!("{} ({})", message, code)),
373                None => Cow::Borrowed(message),
374            })
375        })
376        .collect::<Vec<_>>();
377    if messages.is_empty() {
378        None
379    } else {
380        Some(
381            messages
382                .into_iter()
383                .map(|value| value.into_owned())
384                .collect::<Vec<_>>()
385                .join("; "),
386        )
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::{
393        extract_cloudflare_error_message, extract_github_error_message, AuthStrategy,
394        RequestFactory,
395    };
396    use reqwest::header::{HeaderName, HeaderValue};
397
398    #[test]
399    fn extracts_github_error_message() {
400        let body = r#"{"message":"Repository not found"}"#;
401        assert_eq!(
402            extract_github_error_message(body).as_deref(),
403            Some("Repository not found")
404        );
405    }
406
407    #[test]
408    fn extracts_cloudflare_error_message() {
409        let body = r#"{"errors":[{"code":7003,"message":"No route for that URI"}]}"#;
410        assert_eq!(
411            extract_cloudflare_error_message(body).as_deref(),
412            Some("No route for that URI (7003)")
413        );
414    }
415
416    #[test]
417    fn request_factory_accepts_default_headers_and_auth() {
418        let factory = RequestFactory::new("https://api.example.com")
419            .expect("factory")
420            .with_auth(AuthStrategy::Bearer("token".to_string()))
421            .with_default_header(
422                HeaderName::from_static("x-test"),
423                HeaderValue::from_static("yes"),
424            );
425        assert_eq!(factory.base_url.as_str(), "https://api.example.com/");
426    }
427}