@smbcloud / cli / commits / 057d251

feat(gresiq-sdk): add document-gateway upsert/query methods (ADR-0003 M2)

Add GresiqClient::upsert_document(collection, key, doc) and get_collection(collection, query) targeting the M1 document gateway (POST/GET /gresiq/v1/collections/:collection), plus the DocumentQuery and GresiqDocument<T> public types. The existing insert() (semantic routes) is untouched — these are additive for the M2 dual-write path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

keypair34 committed Jun 28, 2026 at 21:53 UTC 057d25181c8515f9ae8c7845c53bbebd2cd988aa
2 files changed +134 -2
crates/smbcloud-gresiq-sdk/src/client.rs
+133 -1
@@ -1,9 +1,42 @@
1 use crate::client_credentials::GresiqCredentials;
2 use crate::error::GresiqError;
3 -use serde::Serialize;
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.
@@ -91,4 +124,103 @@ impl GresiqClient {
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 }
crates/smbcloud-gresiq-sdk/src/smbcloud_gresiq_sdk.rs
+1 -1
@@ -40,7 +40,7 @@ mod client_credentials;
40 mod error;
41 mod onde_apps;
42
43 -pub use client::GresiqClient;
43 +pub use client::{DocumentQuery, GresiqClient, GresiqDocument};
44 pub use client_credentials::{base_url, GresiqCredentials};
45 pub use error::GresiqError;
46 pub use onde_apps::{