1use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
7use reqwest::multipart::{Form, Part};
8use serde::de::DeserializeOwned;
9use serde_json::Value;
10use std::env;
11use std::time::Duration;
12use thiserror::Error;
13
14use crate::shared_client::{DEFAULT_CONNECT_TIMEOUT_SECS, DEFAULT_POOL_SIZE};
15
16#[derive(Debug, Clone)]
18pub struct HttpErrorDetail {
19 pub status: u16,
20 pub url: String,
21 pub message: String,
22 pub body_snippet: Option<String>,
23}
24
25impl std::fmt::Display for HttpErrorDetail {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 write!(f, "HTTP {} for {}: {}", self.status, self.url, self.message)?;
28 if let Some(ref snippet) = self.body_snippet {
29 let truncated: String = snippet.chars().take(200).collect();
30 write!(f, " | body[0:200]={}", truncated)?;
31 }
32 Ok(())
33 }
34}
35
36#[derive(Debug, Error)]
38pub enum HttpError {
39 #[error("request failed: {0} (is_connect={}, is_timeout={})", .0.is_connect(), .0.is_timeout())]
40 Request(#[from] reqwest::Error),
41
42 #[error("{0}")]
43 Response(HttpErrorDetail),
44
45 #[error("invalid url: {0}")]
46 InvalidUrl(String),
47
48 #[error("json parse error: {0}")]
49 JsonParse(String),
50}
51
52#[derive(Debug, Clone)]
54pub struct MultipartFile {
55 pub field: String,
56 pub filename: String,
57 pub bytes: Vec<u8>,
58 pub content_type: Option<String>,
59}
60
61impl MultipartFile {
62 pub fn new(
63 field: impl Into<String>,
64 filename: impl Into<String>,
65 bytes: Vec<u8>,
66 content_type: Option<String>,
67 ) -> Self {
68 Self {
69 field: field.into(),
70 filename: filename.into(),
71 bytes,
72 content_type,
73 }
74 }
75}
76
77impl HttpError {
78 pub fn from_response(status: u16, url: &str, body: Option<&str>) -> Self {
80 let body_snippet = body.map(|s| s.chars().take(200).collect());
81 HttpError::Response(HttpErrorDetail {
82 status,
83 url: url.to_string(),
84 message: "request_failed".to_string(),
85 body_snippet,
86 })
87 }
88
89 pub fn status(&self) -> Option<u16> {
91 match self {
92 HttpError::Response(detail) => Some(detail.status),
93 HttpError::Request(e) => e.status().map(|s| s.as_u16()),
94 _ => None,
95 }
96 }
97}
98
99#[derive(Clone)]
110pub struct HttpClient {
111 client: reqwest::Client,
112 base_url: String,
113 #[allow(dead_code)]
114 api_key: String,
115}
116
117impl HttpClient {
118 pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self, HttpError> {
132 let mut headers = HeaderMap::new();
133
134 if !api_key.is_empty() {
136 let auth_value = format!("Bearer {}", api_key);
137 headers.insert(
138 AUTHORIZATION,
139 HeaderValue::from_str(&auth_value)
140 .map_err(|_| HttpError::InvalidUrl("invalid api key characters".to_string()))?,
141 );
142 headers.insert(
143 "X-API-Key",
144 HeaderValue::from_str(api_key)
145 .map_err(|_| HttpError::InvalidUrl("invalid api key characters".to_string()))?,
146 );
147 }
148
149 if let Some(user_id) = env::var("SYNTH_USER_ID")
151 .ok()
152 .or_else(|| env::var("X_USER_ID").ok())
153 {
154 if let Ok(val) = HeaderValue::from_str(&user_id) {
155 headers.insert("X-User-ID", val);
156 }
157 }
158
159 if let Some(org_id) = env::var("SYNTH_ORG_ID")
160 .ok()
161 .or_else(|| env::var("X_ORG_ID").ok())
162 {
163 if let Ok(val) = HeaderValue::from_str(&org_id) {
164 headers.insert("X-Org-ID", val);
165 }
166 }
167
168 let client = reqwest::Client::builder()
169 .default_headers(headers)
170 .timeout(Duration::from_secs(timeout_secs))
171 .pool_max_idle_per_host(DEFAULT_POOL_SIZE)
172 .pool_idle_timeout(Duration::from_secs(90))
173 .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
174 .tcp_keepalive(Duration::from_secs(60))
175 .tcp_nodelay(true)
176 .build()
177 .map_err(HttpError::Request)?;
178
179 Ok(Self {
180 client,
181 base_url: base_url.trim_end_matches('/').to_string(),
182 api_key: api_key.to_string(),
183 })
184 }
185
186 pub(crate) fn api_key(&self) -> &str {
188 &self.api_key
189 }
190
191 fn abs_url(&self, path: &str) -> String {
193 if path.starts_with("http://") || path.starts_with("https://") {
194 return path.to_string();
195 }
196
197 let path = path.trim_start_matches('/');
198
199 if self.base_url.ends_with("/api") && path.starts_with("api/") {
201 return format!("{}/{}", self.base_url, &path[4..]);
202 }
203
204 format!("{}/{}", self.base_url, path)
205 }
206
207 pub async fn get<T: DeserializeOwned>(
214 &self,
215 path: &str,
216 params: Option<&[(&str, &str)]>,
217 ) -> Result<T, HttpError> {
218 let url = self.abs_url(path);
219 let mut req = self.client.get(&url);
220
221 if let Some(p) = params {
222 req = req.query(p);
223 }
224
225 let resp = req.send().await?;
226 self.handle_response(resp, &url).await
227 }
228
229 pub async fn get_bytes(
231 &self,
232 path: &str,
233 params: Option<&[(&str, &str)]>,
234 ) -> Result<Vec<u8>, HttpError> {
235 let url = self.abs_url(path);
236 let mut request = self.client.get(&url);
237 if let Some(params) = params {
238 request = request.query(params);
239 }
240 let resp = request.send().await?;
241 let status = resp.status();
242 if status.is_success() {
243 let bytes = resp.bytes().await?;
244 return Ok(bytes.to_vec());
245 }
246 let body = resp.text().await.unwrap_or_default();
247 Err(HttpError::from_response(
248 status.as_u16(),
249 &url,
250 if body.is_empty() { None } else { Some(&body) },
251 ))
252 }
253
254 pub async fn get_json(
256 &self,
257 path: &str,
258 params: Option<&[(&str, &str)]>,
259 ) -> Result<Value, HttpError> {
260 self.get(path, params).await
261 }
262
263 pub async fn post_json<T: DeserializeOwned>(
270 &self,
271 path: &str,
272 body: &Value,
273 ) -> Result<T, HttpError> {
274 let url = self.abs_url(path);
275 let resp = self.client.post(&url).json(body).send().await?;
276 self.handle_response(resp, &url).await
277 }
278
279 pub async fn post_json_with_headers<T: DeserializeOwned>(
281 &self,
282 path: &str,
283 body: &Value,
284 extra_headers: Option<HeaderMap>,
285 ) -> Result<T, HttpError> {
286 let url = self.abs_url(path);
287 let mut request = self.client.post(&url).json(body);
288 if let Some(headers) = extra_headers {
289 request = request.headers(headers);
290 }
291 let resp = request.send().await?;
292 self.handle_response(resp, &url).await
293 }
294
295 pub async fn post_multipart<T: DeserializeOwned>(
303 &self,
304 path: &str,
305 data: &[(String, String)],
306 files: &[MultipartFile],
307 ) -> Result<T, HttpError> {
308 let url = self.abs_url(path);
309 let mut form = Form::new();
310 for (key, value) in data {
311 form = form.text(key.clone(), value.clone());
312 }
313 for file in files {
314 let part = Part::bytes(file.bytes.clone()).file_name(file.filename.clone());
315 let part = match &file.content_type {
316 Some(ct) => part.mime_str(ct).unwrap_or_else(|_| {
317 Part::bytes(file.bytes.clone()).file_name(file.filename.clone())
318 }),
319 None => part,
320 };
321 form = form.part(file.field.clone(), part);
322 }
323 let resp = self.client.post(&url).multipart(form).send().await?;
324 self.handle_response(resp, &url).await
325 }
326
327 pub async fn delete(&self, path: &str) -> Result<(), HttpError> {
333 let url = self.abs_url(path);
334 let resp = self.client.delete(&url).send().await?;
335
336 let status = resp.status().as_u16();
337 if (200..300).contains(&status) {
338 return Ok(());
339 }
340
341 let body = resp.text().await.ok();
342 Err(HttpError::from_response(status, &url, body.as_deref()))
343 }
344
345 async fn handle_response<T: DeserializeOwned>(
347 &self,
348 resp: reqwest::Response,
349 url: &str,
350 ) -> Result<T, HttpError> {
351 let status = resp.status().as_u16();
352 let text = resp.text().await.unwrap_or_default();
353
354 if (200..300).contains(&status) {
355 serde_json::from_str(&text).map_err(|e| {
356 HttpError::JsonParse(format!("{}: {}", e, &text[..text.len().min(100)]))
357 })
358 } else {
359 Err(HttpError::from_response(status, url, Some(&text)))
360 }
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_abs_url_relative() {
370 let client = HttpClient::new("https://api.usesynth.ai", "test_key", 30).unwrap();
371 assert_eq!(
372 client.abs_url("/api/v1/jobs"),
373 "https://api.usesynth.ai/api/v1/jobs"
374 );
375 assert_eq!(
376 client.abs_url("api/v1/jobs"),
377 "https://api.usesynth.ai/api/v1/jobs"
378 );
379 }
380
381 #[test]
382 fn test_abs_url_absolute() {
383 let client = HttpClient::new("https://api.usesynth.ai", "test_key", 30).unwrap();
384 assert_eq!(
385 client.abs_url("https://other.com/path"),
386 "https://other.com/path"
387 );
388 }
389
390 #[test]
391 fn test_abs_url_api_prefix_dedup() {
392 let client = HttpClient::new("https://api.usesynth.ai/api", "test_key", 30).unwrap();
393 assert_eq!(
394 client.abs_url("api/v1/jobs"),
395 "https://api.usesynth.ai/api/v1/jobs"
396 );
397 }
398
399 #[test]
400 fn test_http_error_display() {
401 let err = HttpError::from_response(404, "https://api.example.com/test", Some("not found"));
402 let msg = format!("{}", err);
403 assert!(msg.contains("404"));
404 assert!(msg.contains("api.example.com"));
405 }
406}