development
claude/smbcloud-github-app-k9641p
development
feature/apps
feature/build-release
feature/ci-docs
feature/ci-ruby-gems
feature/cicd
feature/completion
feature/dist-nuget
feature/fix-ci-sdk-pypi-rubygem
feature/fix-clippy-on-stable-channel
feature/fix-deployment-script
feature/github-email
feature/gresiq-v1
feature/project-new-structure
feature/rsync-deploy
feature/ruby-runner
feature/sdk-npm
feature/sdk-workflows
feature/smbcloud-cli-release-npm
feature/smbcloud-deploy
feature/wasm
main
release/v0.7.4
| 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(¶ms).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 | } |