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
+}