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}