1 use crate::client_credentials::GresiqCredentials;
2 use crate::error::GresiqError;
3 use serde::de::DeserializeOwned;
4 use serde::{Deserialize, Serialize};
5 use smbcloud_network::environment::Environment;
6 use 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)]
14 pub 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)]
26 pub 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)]
36 struct 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)]
54 pub 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
62 impl 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 }