Skip to main content

smbcloud_gresiq_sdk/
client.rs

1use crate::client_credentials::GresiqCredentials;
2use crate::error::GresiqError;
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5use smbcloud_network::environment::Environment;
6use std::collections::HashMap;
7
8/// Query parameters for `get_collection` against the document gateway.
9///
10/// `filter` is a JSON containment object (`doc @> filter`); the gateway can
11/// only order by `created_at` / `updated_at`, so `order`/`dir` are limited to
12/// those columns. `limit` is clamped server-side to `1..=1000`.
13#[derive(Debug, Clone, Default)]
14pub struct DocumentQuery {
15    pub filter: Option<serde_json::Value>,
16    pub order: Option<String>,
17    pub dir: Option<String>,
18    pub limit: Option<u32>,
19}
20
21/// One row from the document gateway's `{ documents: [...] }` envelope.
22///
23/// `T` is the caller's document shape — schema knowledge lives in the caller.
24/// The platform metadata (`id`, `key`, timestamps) rides alongside it.
25#[derive(Debug, Clone, Deserialize)]
26pub struct GresiqDocument<T> {
27    pub id: String,
28    pub key: String,
29    pub collection: String,
30    pub doc: T,
31    pub created_at: String,
32    pub updated_at: String,
33}
34
35#[derive(Debug, Deserialize)]
36struct DocumentsEnvelope<T> {
37    documents: Vec<GresiqDocument<T>>,
38}
39
40/// Talks to the smbCloud GresIQ REST gateway.
41///
42/// Cheap to clone — the inner `reqwest::Client` is `Arc`-backed.
43/// Build one at startup and clone it wherever you need it.
44///
45/// # Authentication
46///
47/// Every request carries two headers from the GresIQ credentials:
48/// `X-Gresiq-Api-Key` and `X-Gresiq-Api-Secret`. Get these from the
49/// GresIQ console after registering a database.
50///
51/// Additional headers can be layered on top via `with_extra_headers` —
52/// they ride alongside the GresIQ credentials on every subsequent request.
53#[derive(Debug, Clone)]
54pub struct GresiqClient {
55    base_url: String,
56    api_key: String,
57    api_secret: String,
58    extra_headers: HashMap<String, String>,
59    http: reqwest::Client,
60}
61
62impl GresiqClient {
63    /// Build a client from an environment and credentials.
64    ///
65    /// The base URL is resolved automatically from the environment:
66    /// - `Environment::Dev` → `http://localhost:8088`
67    /// - `Environment::Production` → `https://api.smbcloud.xyz`
68    pub fn from_credentials(environment: Environment, credentials: GresiqCredentials<'_>) -> Self {
69        let base_url = crate::client_credentials::base_url(&environment);
70        GresiqClient {
71            base_url,
72            api_key: credentials.api_key.to_string(),
73            api_secret: credentials.api_secret.to_string(),
74            extra_headers: HashMap::new(),
75            http: reqwest::Client::new(),
76        }
77    }
78
79    /// Attach additional headers sent on every request alongside the GresIQ
80    /// credentials. Replaces any previously set extra headers.
81    ///
82    /// Use this for secondary auth layers so the gateway can identify which
83    /// SDK client is writing, on top of which GresIQ app owns the database.
84    pub fn with_extra_headers(mut self, headers: HashMap<String, String>) -> Self {
85        self.extra_headers = headers;
86        self
87    }
88
89    /// POST a record into a GresIQ-managed table.
90    ///
91    /// `table` is the short, un-prefixed name from the REST path —
92    /// e.g. `"pulse/model_loaded"` or `"pulse_inference_events"`.
93    /// The gateway resolves the tenant prefix from the api_key.
94    ///
95    /// Returns `Err` on network failure or a non-2xx response. The caller
96    /// is responsible for deciding whether to retry, log, or ignore.
97    pub async fn insert<T: Serialize>(&self, table: &str, record: &T) -> Result<(), GresiqError> {
98        let url = format!("{}/gresiq/v1/{}", self.base_url, table);
99        let body = serde_json::json!({ "record": record });
100
101        let mut builder = self
102            .http
103            .post(&url)
104            .header("X-Gresiq-Api-Key", &self.api_key)
105            .header("X-Gresiq-Api-Secret", &self.api_secret)
106            .json(&body);
107
108        for (key, value) in &self.extra_headers {
109            builder = builder.header(key.as_str(), value.as_str());
110        }
111
112        let response = builder.send().await?;
113
114        if response.status().is_success() {
115            log::debug!("gresiq: {} inserted ok", table);
116            return Ok(());
117        }
118
119        let status = response.status().as_u16();
120        let message = response
121            .text()
122            .await
123            .unwrap_or_else(|_| "unreadable response body".to_string());
124
125        Err(GresiqError::Api { status, message })
126    }
127
128    /// Upsert (or append) a document into a collection via the M1 document
129    /// gateway: `POST /gresiq/v1/collections/:collection` with body
130    /// `{ key?, doc }`.
131    ///
132    /// With `key`: upsert on `(app, collection, key)` — the natural-key target.
133    /// Without `key`: append-only insert with a server-generated UUID key.
134    ///
135    /// This is distinct from [`insert`](Self::insert), which targets the older
136    /// semantic routes (`/gresiq/v1/<table>`); new code writing arbitrary
137    /// documents should use this method.
138    pub async fn upsert_document<T: Serialize>(
139        &self,
140        collection: &str,
141        key: Option<&str>,
142        doc: &T,
143    ) -> Result<(), GresiqError> {
144        let url = format!("{}/gresiq/v1/collections/{}", self.base_url, collection);
145        let body = match key {
146            Some(key) => serde_json::json!({ "key": key, "doc": doc }),
147            None => serde_json::json!({ "doc": doc }),
148        };
149
150        let response = self.authed_post(&url).json(&body).send().await?;
151
152        if response.status().is_success() {
153            log::debug!("gresiq: upserted document into {}", collection);
154            return Ok(());
155        }
156
157        Err(self.api_error(response).await)
158    }
159
160    /// Fetch documents from a collection via
161    /// `GET /gresiq/v1/collections/:collection`, with optional containment
162    /// filter, ordering, and limit (see [`DocumentQuery`]).
163    ///
164    /// Each returned [`GresiqDocument`] deserializes its `doc` into `T`; the
165    /// caller owns the document shape.
166    pub async fn get_collection<T: DeserializeOwned>(
167        &self,
168        collection: &str,
169        query: &DocumentQuery,
170    ) -> Result<Vec<GresiqDocument<T>>, GresiqError> {
171        let url = format!("{}/gresiq/v1/collections/{}", self.base_url, collection);
172
173        let mut params: Vec<(&str, String)> = Vec::new();
174        if let Some(filter) = &query.filter {
175            params.push(("filter", filter.to_string()));
176        }
177        if let Some(order) = &query.order {
178            params.push(("order", order.clone()));
179        }
180        if let Some(dir) = &query.dir {
181            params.push(("dir", dir.clone()));
182        }
183        if let Some(limit) = query.limit {
184            params.push(("limit", limit.to_string()));
185        }
186
187        let response = self.authed_get(&url).query(&params).send().await?;
188
189        if !response.status().is_success() {
190            return Err(self.api_error(response).await);
191        }
192
193        let envelope: DocumentsEnvelope<T> = response.json().await?;
194        Ok(envelope.documents)
195    }
196
197    /// Build a POST request carrying the GresIQ credentials and extra headers.
198    fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
199        self.with_auth(self.http.post(url))
200    }
201
202    /// Build a GET request carrying the GresIQ credentials and extra headers.
203    fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
204        self.with_auth(self.http.get(url))
205    }
206
207    fn with_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
208        let mut builder = builder
209            .header("X-Gresiq-Api-Key", &self.api_key)
210            .header("X-Gresiq-Api-Secret", &self.api_secret);
211        for (key, value) in &self.extra_headers {
212            builder = builder.header(key.as_str(), value.as_str());
213        }
214        builder
215    }
216
217    /// Turn a non-2xx response into a `GresiqError::Api`, reading the body.
218    async fn api_error(&self, response: reqwest::Response) -> GresiqError {
219        let status = response.status().as_u16();
220        let message = response
221            .text()
222            .await
223            .unwrap_or_else(|_| "unreadable response body".to_string());
224        GresiqError::Api { status, message }
225    }
226}