1use 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 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 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 pub fn with_auth(mut self, auth: AuthStrategy) -> Self {
94 self.auth = auth;
95 self
96 }
97
98 pub fn with_default_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
100 self.default_headers.insert(name, value);
101 self
102 }
103
104 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 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 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 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 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 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 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 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 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 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 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
348pub 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
359pub 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}