@smbcloud / cli / commits / dc9f00e

Add GresiqClient::from_ingest_key for write-only ingest keys

A client built with from_ingest_key sends Authorization: Bearer with a gqi_ key instead of the key and secret headers. insert now goes through the same auth helper as the other requests. Existing constructors are unchanged. The version is left for the release. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 20, 2026 at 10:03 UTC dc9f00e90fb63142cb33db98ed6c6c87f8aac13d
2 files changed +125 -20
crates/smbcloud-gresiq-sdk/Cargo.toml
+3
@@ -24,3 +24,6 @@ serde_json = { workspace = true }
24 smbcloud-network = { workspace = true }
25 thiserror = { workspace = true }
26 tokio = { workspace = true, features = ["rt"] }
27 +
28 +[dev-dependencies]
29 +tokio = { workspace = true, features = ["rt", "macros", "net", "io-util"] }
crates/smbcloud-gresiq-sdk/src/client.rs
+122 -20
@@ -37,6 +37,24 @@ 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.
@@ -46,15 +64,16 @@ struct DocumentsEnvelope<T> {
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
49 -/// GresIQ console after registering a database.
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,
56 - api_key: String,
57 - api_secret: String,
76 + auth: Auth,
77 extra_headers: HashMap<String, String>,
78 http: reqwest::Client,
79 }
@@ -69,8 +88,36 @@ impl GresiqClient {
88 let base_url = crate::client_credentials::base_url(&environment);
89 GresiqClient {
90 base_url,
72 - api_key: credentials.api_key.to_string(),
73 - api_secret: credentials.api_secret.to_string(),
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 }
@@ -98,18 +145,7 @@ impl GresiqClient {
145 let url = format!("{}/gresiq/v1/{}", self.base_url, table);
146 let body = serde_json::json!({ "record": record });
147
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?;
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);
@@ -205,9 +241,15 @@ impl GresiqClient {
241 }
242
243 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);
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 }
@@ -224,3 +266,63 @@ impl GresiqClient {
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 +}