Skip to main content

xbp_http/
lib.rs

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