Skip to main content

synth_ai_core/
http.rs

1//! HTTP client for Synth API calls.
2//!
3//! This module provides an async HTTP client with Bearer authentication,
4//! optional dev headers (X-User-ID, X-Org-ID), and proper error handling.
5
6use 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/// HTTP error details.
17#[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/// HTTP client errors.
37#[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/// Multipart file payload.
53#[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    /// Create an HTTP error from a response.
79    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    /// Get the HTTP status code, if available.
90    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/// Async HTTP client for Synth API.
100///
101/// Provides Bearer token authentication and automatic JSON handling.
102///
103/// # Example
104///
105/// ```ignore
106/// let client = HttpClient::new("https://api.usesynth.ai", "sk_live_...", 30)?;
107/// let result: Value = client.get("/api/v1/jobs", None).await?;
108/// ```
109#[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    /// Create a new HTTP client.
119    ///
120    /// # Arguments
121    ///
122    /// * `base_url` - Base URL for the API (without trailing slash)
123    /// * `api_key` - API key for Bearer authentication
124    /// * `timeout_secs` - Request timeout in seconds
125    ///
126    /// # Environment Variables
127    ///
128    /// Optional headers from environment:
129    /// - `SYNTH_USER_ID` or `X_USER_ID` → `X-User-ID` header
130    /// - `SYNTH_ORG_ID` or `X_ORG_ID` → `X-Org-ID` header
131    pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self, HttpError> {
132        let mut headers = HeaderMap::new();
133
134        // Only add auth headers if api_key is non-empty
135        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        // Optional dev headers
150        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    /// Get the API key used by this client.
187    pub(crate) fn api_key(&self) -> &str {
188        &self.api_key
189    }
190
191    /// Convert a relative path to an absolute URL.
192    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        // Handle /api prefix duplication
200        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    /// Make a GET request.
208    ///
209    /// # Arguments
210    ///
211    /// * `path` - API path (relative or absolute)
212    /// * `params` - Optional query parameters
213    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    /// Make a GET request and return raw bytes.
230    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    /// Make a GET request returning raw JSON Value.
255    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    /// Make a POST request with JSON body.
264    ///
265    /// # Arguments
266    ///
267    /// * `path` - API path
268    /// * `body` - JSON body to send
269    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    /// Make a POST request with JSON body and extra headers.
280    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    /// Make a POST request with multipart form data.
296    ///
297    /// # Arguments
298    ///
299    /// * `path` - API path
300    /// * `data` - Form fields
301    /// * `files` - File parts
302    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    /// Make a DELETE request.
328    ///
329    /// # Arguments
330    ///
331    /// * `path` - API path
332    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    /// Handle HTTP response, returning parsed JSON or error.
346    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}