claude/smbcloud-github-app-k9641p
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 | /// 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(¶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 | } |