Skip to main content

cognitum_one/
client.rs

1use std::path::Path;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::Duration;
4
5use reqwest::header::HeaderMap;
6use reqwest::StatusCode;
7use serde::de::DeserializeOwned;
8use serde::Serialize;
9
10use crate::brain::BrainResource;
11use crate::catalog::CatalogResource;
12use crate::contact::ContactResource;
13use crate::devices::DevicesResource;
14use crate::error::Error;
15use crate::leads::LeadsResource;
16use crate::mcp::McpResource;
17use crate::orders::OrdersResource;
18use crate::retry_hint::{equal_jitter_backoff, parse_retry_after};
19use crate::types::HealthResponse;
20
21const DEFAULT_BASE_URL: &str = "https://api.cognitum.one";
22const DEFAULT_TIMEOUT_SECS: u64 = 30;
23const DEFAULT_MAX_RETRIES: u32 = 3;
24
25/// One-shot flag guarding the per-process `danger_accept_invalid_certs` warning.
26static INSECURE_TLS_WARNED: AtomicBool = AtomicBool::new(false);
27
28/// Configuration for the Cognitum [`Client`].
29#[derive(Debug, Clone)]
30pub struct ClientConfig {
31    /// API key used by default in the `X-API-Key` header (per ADR-0003).
32    ///
33    /// If `use_bearer` is enabled via [`ClientBuilder::deprecated_bearer_auth`],
34    /// the legacy `Authorization: Bearer <key>` header is sent alongside
35    /// `X-API-Key` for the deprecation window.
36    pub api_key: String,
37    /// Override the API base URL (useful for testing).
38    pub base_url: Option<String>,
39    /// HTTP request timeout in seconds.
40    pub timeout_secs: u64,
41    /// Maximum number of automatic retries for transient errors (429/500/503).
42    pub max_retries: u32,
43    /// Emit the legacy `Authorization: Bearer <key>` header in addition to
44    /// `X-API-Key`. Kept for 2 minor releases per ADR-0003.
45    pub use_bearer: bool,
46    /// Accept invalid/self-signed TLS certificates. Development only.
47    pub insecure: bool,
48    /// PEM-encoded root certificate to trust as the sole issuer for pinning
49    /// self-signed seed certs. Mutually exclusive with `insecure`.
50    pub trust_root_pem: Option<Vec<u8>>,
51}
52
53impl Default for ClientConfig {
54    fn default() -> Self {
55        Self {
56            api_key: String::new(),
57            base_url: None,
58            timeout_secs: DEFAULT_TIMEOUT_SECS,
59            max_retries: DEFAULT_MAX_RETRIES,
60            use_bearer: false,
61            insecure: false,
62            trust_root_pem: None,
63        }
64    }
65}
66
67/// Builder for [`Client`] giving fine-grained control over auth and TLS knobs.
68///
69/// Prefer [`Client::new`] for simple usage; reach for [`ClientBuilder`] when
70/// you need to talk to a self-signed seed, pin a custom root, or opt into the
71/// deprecated `Authorization: Bearer` header for compatibility with
72/// pre-ADR-0003 servers.
73#[derive(Debug, Clone, Default)]
74pub struct ClientBuilder {
75    config: ClientConfig,
76}
77
78impl ClientBuilder {
79    /// Create an empty builder.
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Set the API key.
85    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
86        self.config.api_key = api_key.into();
87        self
88    }
89
90    /// Override the API base URL (useful for testing).
91    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
92        self.config.base_url = Some(base_url.into());
93        self
94    }
95
96    /// Override the request timeout in seconds.
97    pub fn timeout_secs(mut self, timeout_secs: u64) -> Self {
98        self.config.timeout_secs = timeout_secs;
99        self
100    }
101
102    /// Maximum automatic retries for transient errors.
103    pub fn max_retries(mut self, max_retries: u32) -> Self {
104        self.config.max_retries = max_retries;
105        self
106    }
107
108    /// Enable the legacy `Authorization: Bearer <key>` header **in addition**
109    /// to `X-API-Key` for compatibility with pre-ADR-0003 servers.
110    ///
111    /// This is a deprecation-window flag that will be removed in 2 minor
112    /// releases. On every `true` call the SDK logs a one-line deprecation
113    /// warning via `eprintln!` unless `COGNITUM_SUPPRESS_BEARER_WARNING` is
114    /// set in the environment.
115    pub fn deprecated_bearer_auth(mut self, enabled: bool) -> Self {
116        if enabled && std::env::var("COGNITUM_SUPPRESS_BEARER_WARNING").is_err() {
117            eprintln!(
118                "cognitum-rs: `deprecated_bearer_auth(true)` enables \
119                 `Authorization: Bearer` alongside `X-API-Key` — this is a \
120                 deprecation-window flag (ADR-0003) and will be removed in a \
121                 future minor release. Set \
122                 COGNITUM_SUPPRESS_BEARER_WARNING=1 to silence this warning."
123            );
124        }
125        self.config.use_bearer = enabled;
126        self
127    }
128
129    /// Accept invalid/self-signed TLS certs. **Use only for development
130    /// against local seed hardware.** Production callers must supply a
131    /// pinned root via [`ClientBuilder::trust_root_pem`] instead.
132    pub fn danger_accept_invalid_certs(mut self, enabled: bool) -> Self {
133        self.config.insecure = enabled;
134        self
135    }
136
137    /// Trust a specific root CA (PEM-encoded). Used for pinning the
138    /// self-signed cert of a known seed. When set, this cert is the ONLY
139    /// root (system trust store is disabled).
140    pub fn trust_root_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
141        self.config.trust_root_pem = Some(pem.into());
142        self
143    }
144
145    /// Trust a specific root CA loaded from a file path. PEM-encoded.
146    pub fn trust_root_pem_file(mut self, path: impl AsRef<Path>) -> std::io::Result<Self> {
147        let pem = std::fs::read(path)?;
148        self.config.trust_root_pem = Some(pem);
149        Ok(self)
150    }
151
152    /// Build the configured [`Client`].
153    pub fn build(self) -> Result<Client, Error> {
154        Client::try_with_config(self.config)
155    }
156}
157
158/// The main entry point for the Cognitum API.
159///
160/// Create a [`Client`] with [`Client::new`] (minimal), [`Client::with_config`]
161/// (full control, panics on build failure), or [`Client::builder`] (fallible,
162/// supports TLS knobs).
163#[derive(Debug)]
164pub struct Client {
165    pub(crate) http: reqwest::Client,
166    pub(crate) config: ClientConfig,
167    pub(crate) base_url: String,
168}
169
170impl Client {
171    /// Create a client with the given API key and default settings.
172    pub fn new(api_key: &str) -> Self {
173        let config = ClientConfig {
174            api_key: api_key.to_owned(),
175            ..Default::default()
176        };
177        Self::with_config(config)
178    }
179
180    /// Start configuring a new [`Client`] via the fluent builder.
181    pub fn builder() -> ClientBuilder {
182        ClientBuilder::new()
183    }
184
185    /// Create a client with full configuration control.
186    ///
187    /// Panics if the underlying `reqwest::Client` fails to build. Use
188    /// [`Client::try_with_config`] or [`Client::builder`] for a fallible path.
189    pub fn with_config(config: ClientConfig) -> Self {
190        Self::try_with_config(config).expect("failed to build reqwest client")
191    }
192
193    /// Fallible variant of [`Client::with_config`].
194    pub fn try_with_config(config: ClientConfig) -> Result<Self, Error> {
195        let base_url = config
196            .base_url
197            .clone()
198            .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
199
200        let http = Self::build_http_client(&config)?;
201
202        Ok(Self {
203            http,
204            config,
205            base_url,
206        })
207    }
208
209    /// Read-only view of the effective configuration. Useful for tests and
210    /// for callers that want to log the resolved settings.
211    pub fn config(&self) -> &ClientConfig {
212        &self.config
213    }
214
215    fn build_http_client(config: &ClientConfig) -> Result<reqwest::Client, Error> {
216        if config.insecure && config.trust_root_pem.is_some() {
217            return Err(Error::Validation(
218                "`danger_accept_invalid_certs` and `trust_root_pem` are \
219                 mutually exclusive; pick one TLS mode"
220                    .to_owned(),
221            ));
222        }
223
224        let mut builder =
225            reqwest::Client::builder().timeout(Duration::from_secs(config.timeout_secs));
226
227        if config.insecure {
228            if !INSECURE_TLS_WARNED.swap(true, Ordering::Relaxed) {
229                eprintln!(
230                    "cognitum-rs: TLS certificate validation is DISABLED via \
231                     `danger_accept_invalid_certs(true)`. Never use this in \
232                     production — prefer `trust_root_pem` for self-signed \
233                     seeds (ADR-0007)."
234                );
235            }
236            builder = builder.danger_accept_invalid_certs(true);
237        } else if let Some(pem) = config.trust_root_pem.as_ref() {
238            let cert = reqwest::Certificate::from_pem(pem)
239                .map_err(|e| Error::Validation(format!("invalid trust_root_pem: {e}")))?;
240            builder = builder
241                .tls_built_in_root_certs(false)
242                .add_root_certificate(cert);
243        }
244
245        builder.build().map_err(Error::from)
246    }
247
248    // -- resource accessors --------------------------------------------------
249
250    /// Access the catalog API.
251    pub fn catalog(&self) -> CatalogResource<'_> {
252        CatalogResource { client: self }
253    }
254
255    /// Access the orders API.
256    pub fn orders(&self) -> OrdersResource<'_> {
257        OrdersResource { client: self }
258    }
259
260    /// Access the leads API.
261    pub fn leads(&self) -> LeadsResource<'_> {
262        LeadsResource { client: self }
263    }
264
265    /// Access the contact API.
266    pub fn contact(&self) -> ContactResource<'_> {
267        ContactResource { client: self }
268    }
269
270    /// Access the devices API.
271    pub fn devices(&self) -> DevicesResource<'_> {
272        DevicesResource { client: self }
273    }
274
275    /// Access the MCP tools API.
276    pub fn mcp(&self) -> McpResource<'_> {
277        McpResource { client: self }
278    }
279
280    /// Access the brain / knowledge API.
281    pub fn brain(&self) -> BrainResource<'_> {
282        BrainResource { client: self }
283    }
284
285    /// Perform a health check against the API.
286    pub async fn health(&self) -> Result<HealthResponse, Error> {
287        self.get("/health").await
288    }
289
290    // -- internal HTTP helpers -----------------------------------------------
291
292    pub(crate) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
293        self.request(reqwest::Method::GET, path, Option::<&()>::None)
294            .await
295    }
296
297    pub(crate) async fn post<T: DeserializeOwned, B: Serialize>(
298        &self,
299        path: &str,
300        body: &B,
301    ) -> Result<T, Error> {
302        self.request(reqwest::Method::POST, path, Some(body)).await
303    }
304
305    async fn request<T: DeserializeOwned, B: Serialize>(
306        &self,
307        method: reqwest::Method,
308        path: &str,
309        body: Option<&B>,
310    ) -> Result<T, Error> {
311        let url = format!("{}{}", self.base_url, path);
312        let mut attempts = 0u32;
313
314        loop {
315            attempts += 1;
316
317            let mut req = self
318                .http
319                .request(method.clone(), &url)
320                .header("X-API-Key", &self.config.api_key);
321
322            if self.config.use_bearer {
323                // Deprecation window: keep Bearer alongside X-API-Key so
324                // servers mid-migration continue to authenticate us.
325                req = req.header("Authorization", format!("Bearer {}", self.config.api_key));
326            }
327
328            if let Some(b) = body {
329                req = req.json(b);
330            }
331
332            let response = req.send().await?;
333            let status = response.status();
334
335            // Fast path: success responses stream the body for parsing
336            // without any Retry-After work.
337            if status.is_success() {
338                let text = response.text().await?;
339                let parsed: T = serde_json::from_str(&text)?;
340                return Ok(parsed);
341            }
342
343            // Drain headers and body once so we can both (a) compute a
344            // Retry-After hint and (b) surface the body verbatim if we
345            // stop retrying.
346            let headers = response.headers().clone();
347            let body_text = response.text().await.unwrap_or_default();
348
349            if Self::is_retryable(status) && attempts <= self.config.max_retries {
350                let delay = self.backoff_duration(status, attempts, &headers, &body_text);
351                tokio::time::sleep(delay).await;
352                continue;
353            }
354
355            return Err(Self::map_error(status, &headers, body_text));
356        }
357    }
358
359    fn is_retryable(status: StatusCode) -> bool {
360        matches!(
361            status,
362            StatusCode::TOO_MANY_REQUESTS
363                | StatusCode::INTERNAL_SERVER_ERROR
364                | StatusCode::SERVICE_UNAVAILABLE
365        )
366    }
367
368    /// Per-attempt backoff (ADR-0005 §"Backoff formula").
369    ///
370    /// For 429 we first consult the parsed server hint. For 500/503 (or
371    /// 429 without a hint) we fall back to equal-jitter exponential
372    /// backoff: `min(cap, base * 2^attempt + uniform[0, base))`.
373    fn backoff_duration(
374        &self,
375        status: StatusCode,
376        attempt: u32,
377        headers: &HeaderMap,
378        body_text: &str,
379    ) -> Duration {
380        if status == StatusCode::TOO_MANY_REQUESTS {
381            if let Some(d) = parse_retry_after(headers, body_text) {
382                return d;
383            }
384        }
385        equal_jitter_backoff(attempt)
386    }
387
388    fn map_error(status: StatusCode, headers: &HeaderMap, body: String) -> Error {
389        match status {
390            StatusCode::UNAUTHORIZED => Error::Auth(body),
391            StatusCode::TOO_MANY_REQUESTS => {
392                // Populate the retry hint with whatever we parsed. If
393                // nothing was advertised, fall back to ADR-0005 equal
394                // jitter on attempt 1 so callers that sleep on
395                // `err.retry_after()` still get a sane, non-zero delay.
396                let retry_after_ms = parse_retry_after(headers, &body)
397                    .unwrap_or_else(|| equal_jitter_backoff(1))
398                    .as_millis() as u64;
399                Error::RateLimit { retry_after_ms }
400            }
401            StatusCode::UNPROCESSABLE_ENTITY | StatusCode::BAD_REQUEST => Error::Validation(body),
402            StatusCode::NOT_FOUND => Error::NotFound(body),
403            _ => Error::Api {
404                code: status.as_u16(),
405                message: body,
406            },
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn builder_defaults_to_x_api_key_only() {
417        let builder = ClientBuilder::new().api_key("k");
418        assert!(!builder.config.use_bearer);
419        assert!(!builder.config.insecure);
420        assert!(builder.config.trust_root_pem.is_none());
421    }
422
423    #[test]
424    fn deprecated_bearer_auth_flag_sets_use_bearer() {
425        std::env::set_var("COGNITUM_SUPPRESS_BEARER_WARNING", "1");
426        let builder = ClientBuilder::new()
427            .api_key("k")
428            .deprecated_bearer_auth(true);
429        assert!(builder.config.use_bearer);
430        std::env::remove_var("COGNITUM_SUPPRESS_BEARER_WARNING");
431    }
432
433    #[test]
434    fn mutually_exclusive_tls_modes_fail_to_build() {
435        let config = ClientConfig {
436            api_key: "k".into(),
437            insecure: true,
438            trust_root_pem: Some(b"not a real pem".to_vec()),
439            ..Default::default()
440        };
441        let err = Client::try_with_config(config).unwrap_err();
442        match err {
443            Error::Validation(msg) => {
444                assert!(msg.contains("mutually exclusive"), "msg = {msg}");
445            }
446            other => panic!("expected Validation, got {other:?}"),
447        }
448    }
449
450    #[test]
451    fn invalid_pem_is_surfaced_as_validation_error() {
452        let config = ClientConfig {
453            api_key: "k".into(),
454            trust_root_pem: Some(b"not a pem".to_vec()),
455            ..Default::default()
456        };
457        let err = Client::try_with_config(config).unwrap_err();
458        assert!(matches!(err, Error::Validation(_)), "got {err:?}");
459    }
460
461    #[test]
462    fn danger_accept_invalid_certs_builds_successfully() {
463        let client = ClientBuilder::new()
464            .api_key("k")
465            .danger_accept_invalid_certs(true)
466            .build()
467            .expect("client should build with insecure TLS");
468        assert!(client.config.insecure);
469    }
470}