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, CONTENT_TYPE};
7use serde::de::DeserializeOwned;
8use serde_json::Value;
9use std::env;
10use std::time::Duration;
11use thiserror::Error;
12
13/// HTTP error details.
14#[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/// HTTP client errors.
34#[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    /// Create an HTTP error from a response.
51    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    /// Get the HTTP status code, if available.
62    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/// Async HTTP client for Synth API.
72///
73/// Provides Bearer token authentication and automatic JSON handling.
74///
75/// # Example
76///
77/// ```ignore
78/// let client = HttpClient::new("https://api.usesynth.ai", "sk_live_...", 30)?;
79/// let result: Value = client.get("/api/v1/jobs", None).await?;
80/// ```
81#[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    /// Create a new HTTP client.
91    ///
92    /// # Arguments
93    ///
94    /// * `base_url` - Base URL for the API (without trailing slash)
95    /// * `api_key` - API key for Bearer authentication
96    /// * `timeout_secs` - Request timeout in seconds
97    ///
98    /// # Environment Variables
99    ///
100    /// Optional headers from environment:
101    /// - `SYNTH_USER_ID` or `X_USER_ID` → `X-User-ID` header
102    /// - `SYNTH_ORG_ID` or `X_ORG_ID` → `X-Org-ID` header
103    pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self, HttpError> {
104        let mut headers = HeaderMap::new();
105
106        // Bearer token
107        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        // Content-Type
116        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
117
118        // Optional dev headers
119        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    /// Convert a relative path to an absolute URL.
151    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        // Handle /api prefix duplication
159        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    /// Make a GET request.
167    ///
168    /// # Arguments
169    ///
170    /// * `path` - API path (relative or absolute)
171    /// * `params` - Optional query parameters
172    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    /// Make a GET request returning raw JSON Value.
189    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    /// Make a POST request with JSON body.
198    ///
199    /// # Arguments
200    ///
201    /// * `path` - API path
202    /// * `body` - JSON body to send
203    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    /// Make a DELETE request.
214    ///
215    /// # Arguments
216    ///
217    /// * `path` - API path
218    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    /// Handle HTTP response, returning parsed JSON or error.
232    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}