Skip to main content

edgequake_sdk/
client.rs

1//! HTTP client with builder pattern, retry, and auth/tenant middleware.
2
3use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
4use reqwest::{Client, Method, Response, StatusCode};
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::config::{Auth, ClientConfig, TenantContext};
11use crate::error::{Error, Result};
12
13/// Internal shared state.
14struct ClientInner {
15    http: Client,
16    config: ClientConfig,
17    auth: Auth,
18    tenant: TenantContext,
19}
20
21/// The EdgeQuake SDK client. Thread-safe (`Clone + Send + Sync`).
22#[derive(Clone)]
23pub struct EdgeQuakeClient {
24    inner: Arc<ClientInner>,
25}
26
27impl EdgeQuakeClient {
28    /// Start building a new client.
29    pub fn builder() -> ClientBuilder {
30        ClientBuilder::default()
31    }
32
33    // ── Resource accessors ──────────────────────────────────────────
34
35    pub fn documents(&self) -> crate::resources::documents::DocumentsResource<'_> {
36        crate::resources::documents::DocumentsResource { client: self }
37    }
38
39    pub fn graph(&self) -> crate::resources::graph::GraphResource<'_> {
40        crate::resources::graph::GraphResource { client: self }
41    }
42
43    pub fn entities(&self) -> crate::resources::entities::EntitiesResource<'_> {
44        crate::resources::entities::EntitiesResource { client: self }
45    }
46
47    pub fn relationships(&self) -> crate::resources::relationships::RelationshipsResource<'_> {
48        crate::resources::relationships::RelationshipsResource { client: self }
49    }
50
51    pub fn query(&self) -> crate::resources::query::QueryResource<'_> {
52        crate::resources::query::QueryResource { client: self }
53    }
54
55    pub fn chat(&self) -> crate::resources::chat::ChatResource<'_> {
56        crate::resources::chat::ChatResource { client: self }
57    }
58
59    pub fn auth(&self) -> crate::resources::auth::AuthResource<'_> {
60        crate::resources::auth::AuthResource { client: self }
61    }
62
63    pub fn users(&self) -> crate::resources::users::UsersResource<'_> {
64        crate::resources::users::UsersResource { client: self }
65    }
66
67    pub fn api_keys(&self) -> crate::resources::api_keys::ApiKeysResource<'_> {
68        crate::resources::api_keys::ApiKeysResource { client: self }
69    }
70
71    pub fn tenants(&self) -> crate::resources::tenants::TenantsResource<'_> {
72        crate::resources::tenants::TenantsResource { client: self }
73    }
74
75    pub fn conversations(&self) -> crate::resources::conversations::ConversationsResource<'_> {
76        crate::resources::conversations::ConversationsResource { client: self }
77    }
78
79    pub fn folders(&self) -> crate::resources::folders::FoldersResource<'_> {
80        crate::resources::folders::FoldersResource { client: self }
81    }
82
83    pub fn tasks(&self) -> crate::resources::tasks::TasksResource<'_> {
84        crate::resources::tasks::TasksResource { client: self }
85    }
86
87    pub fn pipeline(&self) -> crate::resources::pipeline::PipelineResource<'_> {
88        crate::resources::pipeline::PipelineResource { client: self }
89    }
90
91    pub fn costs(&self) -> crate::resources::costs::CostsResource<'_> {
92        crate::resources::costs::CostsResource { client: self }
93    }
94
95    pub fn chunks(&self) -> crate::resources::chunks::ChunksResource<'_> {
96        crate::resources::chunks::ChunksResource { client: self }
97    }
98
99    pub fn provenance(&self) -> crate::resources::provenance::ProvenanceResource<'_> {
100        crate::resources::provenance::ProvenanceResource { client: self }
101    }
102
103    pub fn models(&self) -> crate::resources::models::ModelsResource<'_> {
104        crate::resources::models::ModelsResource { client: self }
105    }
106
107    pub fn workspaces(&self) -> crate::resources::workspaces::WorkspacesResource<'_> {
108        crate::resources::workspaces::WorkspacesResource { client: self }
109    }
110
111    pub fn health(&self) -> crate::resources::health::HealthResource<'_> {
112        crate::resources::health::HealthResource { client: self }
113    }
114
115    pub fn pdf(&self) -> crate::resources::pdf::PdfResource<'_> {
116        crate::resources::pdf::PdfResource { client: self }
117    }
118
119    pub fn lineage(&self) -> crate::resources::lineage::LineageResource<'_> {
120        crate::resources::lineage::LineageResource { client: self }
121    }
122
123    pub fn settings(&self) -> crate::resources::settings::SettingsResource<'_> {
124        crate::resources::settings::SettingsResource { client: self }
125    }
126
127    // ── Low-level request helpers (used by resources) ───────────────
128
129    /// Build a full URL from a path segment.
130    pub(crate) fn url(&self, path: &str) -> Result<url::Url> {
131        let base = &self.inner.config.base_url;
132        let full = if path.starts_with('/') {
133            format!("{}{}", base.trim_end_matches('/'), path)
134        } else {
135            format!("{}/{}", base.trim_end_matches('/'), path)
136        };
137        url::Url::parse(&full).map_err(Error::Url)
138    }
139
140    /// Execute a GET request and deserialize JSON.
141    pub(crate) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
142        self.request(Method::GET, path, Option::<&()>::None).await
143    }
144
145    /// Execute a POST request with a JSON body.
146    pub(crate) async fn post<B: Serialize, T: DeserializeOwned>(
147        &self,
148        path: &str,
149        body: Option<&B>,
150    ) -> Result<T> {
151        self.request(Method::POST, path, body).await
152    }
153
154    /// Execute a PUT request with a JSON body.
155    pub(crate) async fn put<B: Serialize, T: DeserializeOwned>(
156        &self,
157        path: &str,
158        body: Option<&B>,
159    ) -> Result<T> {
160        self.request(Method::PUT, path, body).await
161    }
162
163    /// Execute a PATCH request with a JSON body.
164    pub(crate) async fn patch<B: Serialize, T: DeserializeOwned>(
165        &self,
166        path: &str,
167        body: Option<&B>,
168    ) -> Result<T> {
169        self.request(Method::PATCH, path, body).await
170    }
171
172    /// Execute a DELETE request.
173    pub(crate) async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
174        self.request(Method::DELETE, path, Option::<&()>::None).await
175    }
176
177    /// Execute a DELETE and discard the body (returns `()`).
178    pub(crate) async fn delete_no_content(&self, path: &str) -> Result<()> {
179        let resp = self.send_with_retry(Method::DELETE, path, Option::<&()>::None).await?;
180        let status = resp.status();
181        if status.is_success() {
182            Ok(())
183        } else {
184            Err(Error::from_response(resp).await)
185        }
186    }
187
188    /// Execute a GET request and return raw bytes (for CSV/binary downloads).
189    pub(crate) async fn get_raw(&self, path: &str) -> Result<Vec<u8>> {
190        let resp = self.send_with_retry(Method::GET, path, Option::<&()>::None).await?;
191        let status = resp.status();
192        if status.is_success() {
193            resp.bytes().await.map(|b| b.to_vec()).map_err(Error::Network)
194        } else {
195            Err(Error::from_response(resp).await)
196        }
197    }
198
199    /// Execute a POST and discard the body (returns `()`).
200    pub(crate) async fn post_no_content<B: Serialize>(
201        &self,
202        path: &str,
203        body: Option<&B>,
204    ) -> Result<()> {
205        let resp = self.send_with_retry(Method::POST, path, body).await?;
206        let status = resp.status();
207        if status.is_success() {
208            Ok(())
209        } else {
210            Err(Error::from_response(resp).await)
211        }
212    }
213
214    /// Core request method with retry.
215    async fn request<B: Serialize, T: DeserializeOwned>(
216        &self,
217        method: Method,
218        path: &str,
219        body: Option<&B>,
220    ) -> Result<T> {
221        let resp = self.send_with_retry(method, path, body).await?;
222        let status = resp.status();
223        if status.is_success() {
224            let bytes = resp.bytes().await.map_err(Error::Network)?;
225            serde_json::from_slice(&bytes).map_err(Error::Json)
226        } else {
227            Err(Error::from_response(resp).await)
228        }
229    }
230
231    /// Send with retry + exponential backoff.
232    async fn send_with_retry<B: Serialize>(
233        &self,
234        method: Method,
235        path: &str,
236        body: Option<&B>,
237    ) -> Result<Response> {
238        let max_retries = self.inner.config.max_retries;
239        let backoff = self.inner.config.retry_backoff;
240        let mut last_err: Option<Error> = None;
241
242        for attempt in 0..=max_retries {
243            if attempt > 0 {
244                let wait = backoff * 2u32.saturating_pow(attempt - 1);
245                tokio::time::sleep(wait).await;
246            }
247
248            match self.send_once(method.clone(), path, body).await {
249                Ok(resp) => {
250                    // Only retry on 429 / 5xx
251                    if (resp.status() == StatusCode::TOO_MANY_REQUESTS
252                        || resp.status().is_server_error())
253                        && attempt < max_retries {
254                            last_err = Some(Error::from_response(resp).await);
255                            continue;
256                        }
257                    return Ok(resp);
258                }
259                Err(e) if e.is_retryable() && attempt < max_retries => {
260                    last_err = Some(e);
261                    continue;
262                }
263                Err(e) => return Err(e),
264            }
265        }
266
267        Err(last_err.unwrap_or(Error::Config("max retries exhausted".into())))
268    }
269
270    /// Send a single request (no retry).
271    async fn send_once<B: Serialize>(
272        &self,
273        method: Method,
274        path: &str,
275        body: Option<&B>,
276    ) -> Result<Response> {
277        let url = self.url(path)?;
278        let mut req = self.inner.http.request(method, url);
279
280        // Auth header
281        match &self.inner.auth {
282            Auth::None => {}
283            Auth::ApiKey(key) => {
284                req = req.header("X-API-Key", key.as_str());
285            }
286            Auth::Bearer(token) => {
287                req = req.header(
288                    AUTHORIZATION,
289                    format!("Bearer {}", token),
290                );
291            }
292        }
293
294        // Tenant headers
295        if let Some(tid) = &self.inner.tenant.tenant_id {
296            req = req.header("X-Tenant-ID", tid.as_str());
297        }
298        if let Some(uid) = &self.inner.tenant.user_id {
299            req = req.header("X-User-ID", uid.as_str());
300        }
301        if let Some(wid) = &self.inner.tenant.workspace_id {
302            req = req.header("X-Workspace-ID", wid.as_str());
303        }
304
305        // Body
306        if let Some(b) = body {
307            req = req.json(b);
308        }
309
310        req.send().await.map_err(Error::Network)
311    }
312
313    /// Get a raw response (for streaming).
314    pub(crate) async fn raw_get(&self, path: &str) -> Result<Response> {
315        self.send_with_retry(Method::GET, path, Option::<&()>::None).await
316    }
317
318    /// Get the base URL.
319    pub fn base_url(&self) -> &str {
320        &self.inner.config.base_url
321    }
322}
323
324// ── Builder ────────────────────────────────────────────────────────
325
326/// Builder for [`EdgeQuakeClient`].
327pub struct ClientBuilder {
328    config: ClientConfig,
329    auth: Auth,
330    tenant: TenantContext,
331}
332
333impl Default for ClientBuilder {
334    fn default() -> Self {
335        Self {
336            config: ClientConfig::default(),
337            auth: Auth::None,
338            tenant: TenantContext::default(),
339        }
340    }
341}
342
343impl ClientBuilder {
344    pub fn base_url(mut self, url: impl Into<String>) -> Self {
345        self.config.base_url = url.into();
346        self
347    }
348
349    pub fn api_key(mut self, key: impl Into<String>) -> Self {
350        self.auth = Auth::ApiKey(key.into());
351        self
352    }
353
354    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
355        self.auth = Auth::Bearer(token.into());
356        self
357    }
358
359    pub fn tenant_id(mut self, id: impl Into<String>) -> Self {
360        self.tenant.tenant_id = Some(id.into());
361        self
362    }
363
364    pub fn user_id(mut self, id: impl Into<String>) -> Self {
365        self.tenant.user_id = Some(id.into());
366        self
367    }
368
369    pub fn workspace_id(mut self, id: impl Into<String>) -> Self {
370        self.tenant.workspace_id = Some(id.into());
371        self
372    }
373
374    pub fn timeout(mut self, d: Duration) -> Self {
375        self.config.timeout = d;
376        self
377    }
378
379    pub fn connect_timeout(mut self, d: Duration) -> Self {
380        self.config.connect_timeout = d;
381        self
382    }
383
384    pub fn max_retries(mut self, n: u32) -> Self {
385        self.config.max_retries = n;
386        self
387    }
388
389    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
390        self.config.user_agent = ua.into();
391        self
392    }
393
394    /// Build the client. Fails if the base URL is invalid.
395    pub fn build(self) -> Result<EdgeQuakeClient> {
396        // Validate base URL
397        let _ = url::Url::parse(&self.config.base_url)
398            .map_err(|_| Error::Config(format!("invalid base_url: {}", self.config.base_url)))?;
399
400        let mut headers = HeaderMap::new();
401        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
402        headers.insert(
403            USER_AGENT,
404            HeaderValue::from_str(&self.config.user_agent)
405                .unwrap_or_else(|_| HeaderValue::from_static("edgequake-rust-sdk/0.1.0")),
406        );
407
408        let http = Client::builder()
409            .timeout(self.config.timeout)
410            .connect_timeout(self.config.connect_timeout)
411            .default_headers(headers)
412            .build()
413            .map_err(Error::Network)?;
414
415        Ok(EdgeQuakeClient {
416            inner: Arc::new(ClientInner {
417                http,
418                config: self.config,
419                auth: self.auth,
420                tenant: self.tenant,
421            }),
422        })
423    }
424}
425
426impl std::fmt::Debug for EdgeQuakeClient {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        f.debug_struct("EdgeQuakeClient")
429            .field("base_url", &self.inner.config.base_url)
430            .finish()
431    }
432}