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
25static INSECURE_TLS_WARNED: AtomicBool = AtomicBool::new(false);
27
28#[derive(Debug, Clone)]
30pub struct ClientConfig {
31 pub api_key: String,
37 pub base_url: Option<String>,
39 pub timeout_secs: u64,
41 pub max_retries: u32,
43 pub use_bearer: bool,
46 pub insecure: bool,
48 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#[derive(Debug, Clone, Default)]
74pub struct ClientBuilder {
75 config: ClientConfig,
76}
77
78impl ClientBuilder {
79 pub fn new() -> Self {
81 Self::default()
82 }
83
84 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 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 pub fn timeout_secs(mut self, timeout_secs: u64) -> Self {
98 self.config.timeout_secs = timeout_secs;
99 self
100 }
101
102 pub fn max_retries(mut self, max_retries: u32) -> Self {
104 self.config.max_retries = max_retries;
105 self
106 }
107
108 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 pub fn danger_accept_invalid_certs(mut self, enabled: bool) -> Self {
133 self.config.insecure = enabled;
134 self
135 }
136
137 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 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 pub fn build(self) -> Result<Client, Error> {
154 Client::try_with_config(self.config)
155 }
156}
157
158#[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 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 pub fn builder() -> ClientBuilder {
182 ClientBuilder::new()
183 }
184
185 pub fn with_config(config: ClientConfig) -> Self {
190 Self::try_with_config(config).expect("failed to build reqwest client")
191 }
192
193 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 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 pub fn catalog(&self) -> CatalogResource<'_> {
252 CatalogResource { client: self }
253 }
254
255 pub fn orders(&self) -> OrdersResource<'_> {
257 OrdersResource { client: self }
258 }
259
260 pub fn leads(&self) -> LeadsResource<'_> {
262 LeadsResource { client: self }
263 }
264
265 pub fn contact(&self) -> ContactResource<'_> {
267 ContactResource { client: self }
268 }
269
270 pub fn devices(&self) -> DevicesResource<'_> {
272 DevicesResource { client: self }
273 }
274
275 pub fn mcp(&self) -> McpResource<'_> {
277 McpResource { client: self }
278 }
279
280 pub fn brain(&self) -> BrainResource<'_> {
282 BrainResource { client: self }
283 }
284
285 pub async fn health(&self) -> Result<HealthResponse, Error> {
287 self.get("/health").await
288 }
289
290 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 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 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 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 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 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}