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 /// How a client proves itself to the gateway.
41 #[derive(Clone)]
42 enum Auth {
43 /// `X-Gresiq-Api-Key` + `X-Gresiq-Api-Secret` headers.
44 KeySecret { api_key: String, api_secret: String },
45 /// `Authorization: Bearer gqi_...`, a write-only ingest key.
46 IngestKey(String),
47 }
48
49 impl std::fmt::Debug for Auth {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Auth::KeySecret { .. } => f.write_str("Auth::KeySecret"),
53 Auth::IngestKey(_) => f.write_str("Auth::IngestKey"),
54 }
55 }
56 }
57
58 /// Talks to the smbCloud GresIQ REST gateway.
59 ///
60 /// Cheap to clone — the inner `reqwest::Client` is `Arc`-backed.
61 /// Build one at startup and clone it wherever you need it.
62 ///
63 /// # Authentication
64 ///
65 /// Every request carries two headers from the GresIQ credentials:
66 /// `X-Gresiq-Api-Key` and `X-Gresiq-Api-Secret`. Get these from the
67 /// GresIQ console after registering a database. A client built with
68 /// `from_ingest_key` sends `Authorization: Bearer` with the write-only ingest
69 /// key instead.
70 ///
71 /// Additional headers can be layered on top via `with_extra_headers` —
72 /// they ride alongside the GresIQ credentials on every subsequent request.
73 #[derive(Debug, Clone)]
74 pub struct GresiqClient {
75 base_url: String,
76 auth: Auth,
77 extra_headers: HashMap<String, String>,
78 http: reqwest::Client,
79 }
80
81 impl GresiqClient {
82 /// Build a client from an environment and credentials.
83 ///
84 /// The base URL is resolved automatically from the environment:
85 /// - `Environment::Dev` → `http://localhost:8088`
86 /// - `Environment::Production` → `https://api.smbcloud.xyz`
87 pub fn from_credentials(environment: Environment, credentials: GresiqCredentials<'_>) -> Self {
88 let base_url = crate::client_credentials::base_url(&environment);
89 GresiqClient {
90 base_url,
91 auth: Auth::KeySecret {
92 api_key: credentials.api_key.to_string(),
93 api_secret: credentials.api_secret.to_string(),
94 },
95 extra_headers: HashMap::new(),
96 http: reqwest::Client::new(),
97 }
98 }
99
100 /// Build a client that authenticates with a write-only `gqi_` ingest key,
101 /// sent as `Authorization: Bearer`. The gateway accepts it only for
102 /// telemetry writes (`insert` and `put_document` on the pulse routes and
103 /// document create); reads are refused. Unlike the key and secret, it is
104 /// intended to be embedded in a shipped client.
105 pub fn from_ingest_key(environment: Environment, ingest_key: &str) -> Self {
106 let base_url = crate::client_credentials::base_url(&environment);
107 GresiqClient {
108 base_url,
109 auth: Auth::IngestKey(ingest_key.to_string()),
110 extra_headers: HashMap::new(),
111 http: reqwest::Client::new(),
112 }
113 }
114
115 /// Like `from_ingest_key`, with an explicit base URL (for tests and
116 /// self-hosted gateways).
117 pub fn from_ingest_key_with_base_url(base_url: &str, ingest_key: &str) -> Self {
118 GresiqClient {
119 base_url: base_url.trim_end_matches('/').to_string(),
120 auth: Auth::IngestKey(ingest_key.to_string()),
121 extra_headers: HashMap::new(),
122 http: reqwest::Client::new(),
123 }
124 }
125
126 /// Attach additional headers sent on every request alongside the GresIQ
127 /// credentials. Replaces any previously set extra headers.
128 ///
129 /// Use this for secondary auth layers so the gateway can identify which
130 /// SDK client is writing, on top of which GresIQ app owns the database.
131 pub fn with_extra_headers(mut self, headers: HashMap<String, String>) -> Self {
132 self.extra_headers = headers;
133 self
134 }
135
136 /// POST a record into a GresIQ-managed table.
137 ///
138 /// `table` is the short, un-prefixed name from the REST path —
139 /// e.g. `"pulse/model_loaded"` or `"pulse_inference_events"`.
140 /// The gateway resolves the tenant prefix from the api_key.
141 ///
142 /// Returns `Err` on network failure or a non-2xx response. The caller
143 /// is responsible for deciding whether to retry, log, or ignore.
144 pub async fn insert<T: Serialize>(&self, table: &str, record: &T) -> Result<(), GresiqError> {
145 let url = format!("{}/gresiq/v1/{}", self.base_url, table);
146 let body = serde_json::json!({ "record": record });
147
148 let response = self.authed_post(&url).json(&body).send().await?;
149
150 if response.status().is_success() {
151 log::debug!("gresiq: {} inserted ok", table);
152 return Ok(());
153 }
154
155 let status = response.status().as_u16();
156 let message = response
157 .text()
158 .await
159 .unwrap_or_else(|_| "unreadable response body".to_string());
160
161 Err(GresiqError::Api { status, message })
162 }
163
164 /// Upsert (or append) a document into a collection via the M1 document
165 /// gateway: `POST /gresiq/v1/collections/:collection` with body
166 /// `{ key?, doc }`.
167 ///
168 /// With `key`: upsert on `(app, collection, key)` — the natural-key target.
169 /// Without `key`: append-only insert with a server-generated UUID key.
170 ///
171 /// This is distinct from [`insert`](Self::insert), which targets the older
172 /// semantic routes (`/gresiq/v1/<table>`); new code writing arbitrary
173 /// documents should use this method.
174 pub async fn upsert_document<T: Serialize>(
175 &self,
176 collection: &str,
177 key: Option<&str>,
178 doc: &T,
179 ) -> Result<(), GresiqError> {
180 let url = format!("{}/gresiq/v1/collections/{}", self.base_url, collection);
181 let body = match key {
182 Some(key) => serde_json::json!({ "key": key, "doc": doc }),
183 None => serde_json::json!({ "doc": doc }),
184 };
185
186 let response = self.authed_post(&url).json(&body).send().await?;
187
188 if response.status().is_success() {
189 log::debug!("gresiq: upserted document into {}", collection);
190 return Ok(());
191 }
192
193 Err(self.api_error(response).await)
194 }
195
196 /// Fetch documents from a collection via
197 /// `GET /gresiq/v1/collections/:collection`, with optional containment
198 /// filter, ordering, and limit (see [`DocumentQuery`]).
199 ///
200 /// Each returned [`GresiqDocument`] deserializes its `doc` into `T`; the
201 /// caller owns the document shape.
202 pub async fn get_collection<T: DeserializeOwned>(
203 &self,
204 collection: &str,
205 query: &DocumentQuery,
206 ) -> Result<Vec<GresiqDocument<T>>, GresiqError> {
207 let url = format!("{}/gresiq/v1/collections/{}", self.base_url, collection);
208
209 let mut params: Vec<(&str, String)> = Vec::new();
210 if let Some(filter) = &query.filter {
211 params.push(("filter", filter.to_string()));
212 }
213 if let Some(order) = &query.order {
214 params.push(("order", order.clone()));
215 }
216 if let Some(dir) = &query.dir {
217 params.push(("dir", dir.clone()));
218 }
219 if let Some(limit) = query.limit {
220 params.push(("limit", limit.to_string()));
221 }
222
223 let response = self.authed_get(&url).query(&params).send().await?;
224
225 if !response.status().is_success() {
226 return Err(self.api_error(response).await);
227 }
228
229 let envelope: DocumentsEnvelope<T> = response.json().await?;
230 Ok(envelope.documents)
231 }
232
233 /// Build a POST request carrying the GresIQ credentials and extra headers.
234 fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
235 self.with_auth(self.http.post(url))
236 }
237
238 /// Build a GET request carrying the GresIQ credentials and extra headers.
239 fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
240 self.with_auth(self.http.get(url))
241 }
242
243 fn with_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
244 let mut builder = match &self.auth {
245 Auth::KeySecret {
246 api_key,
247 api_secret,
248 } => builder
249 .header("X-Gresiq-Api-Key", api_key)
250 .header("X-Gresiq-Api-Secret", api_secret),
251 Auth::IngestKey(key) => builder.bearer_auth(key),
252 };
253 for (key, value) in &self.extra_headers {
254 builder = builder.header(key.as_str(), value.as_str());
255 }
256 builder
257 }
258
259 /// Turn a non-2xx response into a `GresiqError::Api`, reading the body.
260 async fn api_error(&self, response: reqwest::Response) -> GresiqError {
261 let status = response.status().as_u16();
262 let message = response
263 .text()
264 .await
265 .unwrap_or_else(|_| "unreadable response body".to_string());
266 GresiqError::Api { status, message }
267 }
268 }
269
270 #[cfg(test)]
271 mod tests {
272 use super::*;
273 use tokio::io::{AsyncReadExt, AsyncWriteExt};
274
275 /// Accept one request, reply 201, and return the lowercased request head.
276 async fn serve_once() -> (String, tokio::task::JoinHandle<String>) {
277 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
278 let base = format!("http://{}", listener.local_addr().unwrap());
279 let handle = tokio::spawn(async move {
280 let (mut socket, _) = listener.accept().await.unwrap();
281 let mut buf = vec![0u8; 8192];
282 let n = socket.read(&mut buf).await.unwrap();
283 let _ = socket
284 .write_all(
285 b"HTTP/1.1 201 Created\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
286 )
287 .await;
288 String::from_utf8_lossy(&buf[..n]).to_lowercase()
289 });
290 (base, handle)
291 }
292
293 #[tokio::test]
294 async fn ingest_key_is_sent_as_a_bearer_alone() {
295 let (base, handle) = serve_once().await;
296 let client = GresiqClient::from_ingest_key_with_base_url(&base, "gqi_abc");
297 client
298 .insert("pulse/model_loaded", &serde_json::json!({ "edge_id": "e" }))
299 .await
300 .unwrap();
301 let head = handle.await.unwrap();
302 assert!(head.starts_with("post /gresiq/v1/pulse/model_loaded"));
303 assert!(head.contains("authorization: bearer gqi_abc"));
304 assert!(!head.contains("x-gresiq-api-key"));
305 assert!(!head.contains("x-gresiq-api-secret"));
306 }
307
308 #[tokio::test]
309 async fn key_and_secret_still_use_the_headers() {
310 let (base, handle) = serve_once().await;
311 let mut client = GresiqClient::from_credentials(
312 Environment::Dev,
313 GresiqCredentials {
314 api_key: "key",
315 api_secret: "secret",
316 },
317 );
318 client.base_url = base;
319 client
320 .insert("pulse/model_loaded", &serde_json::json!({}))
321 .await
322 .unwrap();
323 let head = handle.await.unwrap();
324 assert!(head.contains("x-gresiq-api-key: key"));
325 assert!(head.contains("x-gresiq-api-secret: secret"));
326 assert!(!head.contains("authorization:"));
327 }
328 }