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(¶ms).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::{