1use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
7use serde::de::DeserializeOwned;
8use serde_json::Value;
9use std::env;
10use std::time::Duration;
11use thiserror::Error;
12
13#[derive(Debug, Clone)]
15pub struct HttpErrorDetail {
16 pub status: u16,
17 pub url: String,
18 pub message: String,
19 pub body_snippet: Option<String>,
20}
21
22impl std::fmt::Display for HttpErrorDetail {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 write!(f, "HTTP {} for {}: {}", self.status, self.url, self.message)?;
25 if let Some(ref snippet) = self.body_snippet {
26 let truncated: String = snippet.chars().take(200).collect();
27 write!(f, " | body[0:200]={}", truncated)?;
28 }
29 Ok(())
30 }
31}
32
33#[derive(Debug, Error)]
35pub enum HttpError {
36 #[error("request failed: {0}")]
37 Request(#[from] reqwest::Error),
38
39 #[error("{0}")]
40 Response(HttpErrorDetail),
41
42 #[error("invalid url: {0}")]
43 InvalidUrl(String),
44
45 #[error("json parse error: {0}")]
46 JsonParse(String),
47}
48
49impl HttpError {
50 pub fn from_response(status: u16, url: &str, body: Option<&str>) -> Self {
52 let body_snippet = body.map(|s| s.chars().take(200).collect());
53 HttpError::Response(HttpErrorDetail {
54 status,
55 url: url.to_string(),
56 message: "request_failed".to_string(),
57 body_snippet,
58 })
59 }
60
61 pub fn status(&self) -> Option<u16> {
63 match self {
64 HttpError::Response(detail) => Some(detail.status),
65 HttpError::Request(e) => e.status().map(|s| s.as_u16()),
66 _ => None,
67 }
68 }
69}
70
71#[derive(Clone)]
82pub struct HttpClient {
83 client: reqwest::Client,
84 base_url: String,
85 #[allow(dead_code)]
86 api_key: String,
87}
88
89impl HttpClient {
90 pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self, HttpError> {
104 let mut headers = HeaderMap::new();
105
106 let auth_value = format!("Bearer {}", api_key);
108 headers.insert(
109 AUTHORIZATION,
110 HeaderValue::from_str(&auth_value).map_err(|_| {
111 HttpError::InvalidUrl("invalid api key characters".to_string())
112 })?,
113 );
114
115 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
117
118 if let Some(user_id) = env::var("SYNTH_USER_ID")
120 .ok()
121 .or_else(|| env::var("X_USER_ID").ok())
122 {
123 if let Ok(val) = HeaderValue::from_str(&user_id) {
124 headers.insert("X-User-ID", val);
125 }
126 }
127
128 if let Some(org_id) = env::var("SYNTH_ORG_ID")
129 .ok()
130 .or_else(|| env::var("X_ORG_ID").ok())
131 {
132 if let Ok(val) = HeaderValue::from_str(&org_id) {
133 headers.insert("X-Org-ID", val);
134 }
135 }
136
137 let client = reqwest::Client::builder()
138 .default_headers(headers)
139 .timeout(Duration::from_secs(timeout_secs))
140 .build()
141 .map_err(HttpError::Request)?;
142
143 Ok(Self {
144 client,
145 base_url: base_url.trim_end_matches('/').to_string(),
146 api_key: api_key.to_string(),
147 })
148 }
149
150 fn abs_url(&self, path: &str) -> String {
152 if path.starts_with("http://") || path.starts_with("https://") {
153 return path.to_string();
154 }
155
156 let path = path.trim_start_matches('/');
157
158 if self.base_url.ends_with("/api") && path.starts_with("api/") {
160 return format!("{}/{}", self.base_url, &path[4..]);
161 }
162
163 format!("{}/{}", self.base_url, path)
164 }
165
166 pub async fn get<T: DeserializeOwned>(
173 &self,
174 path: &str,
175 params: Option<&[(&str, &str)]>,
176 ) -> Result<T, HttpError> {
177 let url = self.abs_url(path);
178 let mut req = self.client.get(&url);
179
180 if let Some(p) = params {
181 req = req.query(p);
182 }
183
184 let resp = req.send().await?;
185 self.handle_response(resp, &url).await
186 }
187
188 pub async fn get_json(
190 &self,
191 path: &str,
192 params: Option<&[(&str, &str)]>,
193 ) -> Result<Value, HttpError> {
194 self.get(path, params).await
195 }
196
197 pub async fn post_json<T: DeserializeOwned>(
204 &self,
205 path: &str,
206 body: &Value,
207 ) -> Result<T, HttpError> {
208 let url = self.abs_url(path);
209 let resp = self.client.post(&url).json(body).send().await?;
210 self.handle_response(resp, &url).await
211 }
212
213 pub async fn delete(&self, path: &str) -> Result<(), HttpError> {
219 let url = self.abs_url(path);
220 let resp = self.client.delete(&url).send().await?;
221
222 let status = resp.status().as_u16();
223 if (200..300).contains(&status) {
224 return Ok(());
225 }
226
227 let body = resp.text().await.ok();
228 Err(HttpError::from_response(status, &url, body.as_deref()))
229 }
230
231 async fn handle_response<T: DeserializeOwned>(
233 &self,
234 resp: reqwest::Response,
235 url: &str,
236 ) -> Result<T, HttpError> {
237 let status = resp.status().as_u16();
238 let text = resp.text().await.unwrap_or_default();
239
240 if (200..300).contains(&status) {
241 serde_json::from_str(&text)
242 .map_err(|e| HttpError::JsonParse(format!("{}: {}", e, &text[..text.len().min(100)])))
243 } else {
244 Err(HttpError::from_response(status, url, Some(&text)))
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_abs_url_relative() {
255 let client = HttpClient::new("https://api.usesynth.ai", "test_key", 30).unwrap();
256 assert_eq!(
257 client.abs_url("/api/v1/jobs"),
258 "https://api.usesynth.ai/api/v1/jobs"
259 );
260 assert_eq!(
261 client.abs_url("api/v1/jobs"),
262 "https://api.usesynth.ai/api/v1/jobs"
263 );
264 }
265
266 #[test]
267 fn test_abs_url_absolute() {
268 let client = HttpClient::new("https://api.usesynth.ai", "test_key", 30).unwrap();
269 assert_eq!(
270 client.abs_url("https://other.com/path"),
271 "https://other.com/path"
272 );
273 }
274
275 #[test]
276 fn test_abs_url_api_prefix_dedup() {
277 let client = HttpClient::new("https://api.usesynth.ai/api", "test_key", 30).unwrap();
278 assert_eq!(
279 client.abs_url("api/v1/jobs"),
280 "https://api.usesynth.ai/api/v1/jobs"
281 );
282 }
283
284 #[test]
285 fn test_http_error_display() {
286 let err = HttpError::from_response(404, "https://api.example.com/test", Some("not found"));
287 let msg = format!("{}", err);
288 assert!(msg.contains("404"));
289 assert!(msg.contains("api.example.com"));
290 }
291}