use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use reqwest::header::HeaderMap;
use reqwest::StatusCode;
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::brain::BrainResource;
use crate::catalog::CatalogResource;
use crate::contact::ContactResource;
use crate::devices::DevicesResource;
use crate::error::Error;
use crate::leads::LeadsResource;
use crate::mcp::McpResource;
use crate::orders::OrdersResource;
use crate::retry_hint::{equal_jitter_backoff, parse_retry_after};
use crate::types::HealthResponse;
const DEFAULT_BASE_URL: &str = "https://api.cognitum.one";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_MAX_RETRIES: u32 = 3;
static INSECURE_TLS_WARNED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub api_key: String,
pub base_url: Option<String>,
pub timeout_secs: u64,
pub max_retries: u32,
pub use_bearer: bool,
pub insecure: bool,
pub trust_root_pem: Option<Vec<u8>>,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
api_key: String::new(),
base_url: None,
timeout_secs: DEFAULT_TIMEOUT_SECS,
max_retries: DEFAULT_MAX_RETRIES,
use_bearer: false,
insecure: false,
trust_root_pem: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ClientBuilder {
config: ClientConfig,
}
impl ClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.config.api_key = api_key.into();
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.config.base_url = Some(base_url.into());
self
}
pub fn timeout_secs(mut self, timeout_secs: u64) -> Self {
self.config.timeout_secs = timeout_secs;
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.config.max_retries = max_retries;
self
}
pub fn deprecated_bearer_auth(mut self, enabled: bool) -> Self {
if enabled && std::env::var("COGNITUM_SUPPRESS_BEARER_WARNING").is_err() {
eprintln!(
"cognitum-rs: `deprecated_bearer_auth(true)` enables \
`Authorization: Bearer` alongside `X-API-Key` โ this is a \
deprecation-window flag (ADR-0003) and will be removed in a \
future minor release. Set \
COGNITUM_SUPPRESS_BEARER_WARNING=1 to silence this warning."
);
}
self.config.use_bearer = enabled;
self
}
pub fn danger_accept_invalid_certs(mut self, enabled: bool) -> Self {
self.config.insecure = enabled;
self
}
pub fn trust_root_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.config.trust_root_pem = Some(pem.into());
self
}
pub fn trust_root_pem_file(mut self, path: impl AsRef<Path>) -> std::io::Result<Self> {
let pem = std::fs::read(path)?;
self.config.trust_root_pem = Some(pem);
Ok(self)
}
pub fn build(self) -> Result<Client, Error> {
Client::try_with_config(self.config)
}
}
#[derive(Debug)]
pub struct Client {
pub(crate) http: reqwest::Client,
pub(crate) config: ClientConfig,
pub(crate) base_url: String,
}
impl Client {
pub fn new(api_key: &str) -> Self {
let config = ClientConfig {
api_key: api_key.to_owned(),
..Default::default()
};
Self::with_config(config)
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub fn with_config(config: ClientConfig) -> Self {
Self::try_with_config(config).expect("failed to build reqwest client")
}
pub fn try_with_config(config: ClientConfig) -> Result<Self, Error> {
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
let http = Self::build_http_client(&config)?;
Ok(Self {
http,
config,
base_url,
})
}
pub fn config(&self) -> &ClientConfig {
&self.config
}
fn build_http_client(config: &ClientConfig) -> Result<reqwest::Client, Error> {
if config.insecure && config.trust_root_pem.is_some() {
return Err(Error::Validation(
"`danger_accept_invalid_certs` and `trust_root_pem` are \
mutually exclusive; pick one TLS mode"
.to_owned(),
));
}
let mut builder =
reqwest::Client::builder().timeout(Duration::from_secs(config.timeout_secs));
if config.insecure {
if !INSECURE_TLS_WARNED.swap(true, Ordering::Relaxed) {
eprintln!(
"cognitum-rs: TLS certificate validation is DISABLED via \
`danger_accept_invalid_certs(true)`. Never use this in \
production โ prefer `trust_root_pem` for self-signed \
seeds (ADR-0007)."
);
}
builder = builder.danger_accept_invalid_certs(true);
} else if let Some(pem) = config.trust_root_pem.as_ref() {
let cert = reqwest::Certificate::from_pem(pem)
.map_err(|e| Error::Validation(format!("invalid trust_root_pem: {e}")))?;
builder = builder
.tls_built_in_root_certs(false)
.add_root_certificate(cert);
}
builder.build().map_err(Error::from)
}
pub fn catalog(&self) -> CatalogResource<'_> {
CatalogResource { client: self }
}
pub fn orders(&self) -> OrdersResource<'_> {
OrdersResource { client: self }
}
pub fn leads(&self) -> LeadsResource<'_> {
LeadsResource { client: self }
}
pub fn contact(&self) -> ContactResource<'_> {
ContactResource { client: self }
}
pub fn devices(&self) -> DevicesResource<'_> {
DevicesResource { client: self }
}
pub fn mcp(&self) -> McpResource<'_> {
McpResource { client: self }
}
pub fn brain(&self) -> BrainResource<'_> {
BrainResource { client: self }
}
pub async fn health(&self) -> Result<HealthResponse, Error> {
self.get("/health").await
}
pub(crate) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
self.request(reqwest::Method::GET, path, Option::<&()>::None)
.await
}
pub(crate) async fn post<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, Error> {
self.request(reqwest::Method::POST, path, Some(body)).await
}
async fn request<T: DeserializeOwned, B: Serialize>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&B>,
) -> Result<T, Error> {
let url = format!("{}{}", self.base_url, path);
let mut attempts = 0u32;
loop {
attempts += 1;
let mut req = self
.http
.request(method.clone(), &url)
.header("X-API-Key", &self.config.api_key);
if self.config.use_bearer {
req = req.header("Authorization", format!("Bearer {}", self.config.api_key));
}
if let Some(b) = body {
req = req.json(b);
}
let response = req.send().await?;
let status = response.status();
if status.is_success() {
let text = response.text().await?;
let parsed: T = serde_json::from_str(&text)?;
return Ok(parsed);
}
let headers = response.headers().clone();
let body_text = response.text().await.unwrap_or_default();
if Self::is_retryable(status) && attempts <= self.config.max_retries {
let delay = self.backoff_duration(status, attempts, &headers, &body_text);
tokio::time::sleep(delay).await;
continue;
}
return Err(Self::map_error(status, &headers, body_text));
}
}
fn is_retryable(status: StatusCode) -> bool {
matches!(
status,
StatusCode::TOO_MANY_REQUESTS
| StatusCode::INTERNAL_SERVER_ERROR
| StatusCode::SERVICE_UNAVAILABLE
)
}
fn backoff_duration(
&self,
status: StatusCode,
attempt: u32,
headers: &HeaderMap,
body_text: &str,
) -> Duration {
if status == StatusCode::TOO_MANY_REQUESTS {
if let Some(d) = parse_retry_after(headers, body_text) {
return d;
}
}
equal_jitter_backoff(attempt)
}
fn map_error(status: StatusCode, headers: &HeaderMap, body: String) -> Error {
match status {
StatusCode::UNAUTHORIZED => Error::Auth(body),
StatusCode::TOO_MANY_REQUESTS => {
let retry_after_ms = parse_retry_after(headers, &body)
.unwrap_or_else(|| equal_jitter_backoff(1))
.as_millis() as u64;
Error::RateLimit { retry_after_ms }
}
StatusCode::UNPROCESSABLE_ENTITY | StatusCode::BAD_REQUEST => Error::Validation(body),
StatusCode::NOT_FOUND => Error::NotFound(body),
_ => Error::Api {
code: status.as_u16(),
message: body,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_defaults_to_x_api_key_only() {
let builder = ClientBuilder::new().api_key("k");
assert!(!builder.config.use_bearer);
assert!(!builder.config.insecure);
assert!(builder.config.trust_root_pem.is_none());
}
#[test]
fn deprecated_bearer_auth_flag_sets_use_bearer() {
std::env::set_var("COGNITUM_SUPPRESS_BEARER_WARNING", "1");
let builder = ClientBuilder::new()
.api_key("k")
.deprecated_bearer_auth(true);
assert!(builder.config.use_bearer);
std::env::remove_var("COGNITUM_SUPPRESS_BEARER_WARNING");
}
#[test]
fn mutually_exclusive_tls_modes_fail_to_build() {
let config = ClientConfig {
api_key: "k".into(),
insecure: true,
trust_root_pem: Some(b"not a real pem".to_vec()),
..Default::default()
};
let err = Client::try_with_config(config).unwrap_err();
match err {
Error::Validation(msg) => {
assert!(msg.contains("mutually exclusive"), "msg = {msg}");
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn invalid_pem_is_surfaced_as_validation_error() {
let config = ClientConfig {
api_key: "k".into(),
trust_root_pem: Some(b"not a pem".to_vec()),
..Default::default()
};
let err = Client::try_with_config(config).unwrap_err();
assert!(matches!(err, Error::Validation(_)), "got {err:?}");
}
#[test]
fn danger_accept_invalid_certs_builds_successfully() {
let client = ClientBuilder::new()
.api_key("k")
.danger_accept_invalid_certs(true)
.build()
.expect("client should build with insecure TLS");
assert!(client.config.insecure);
}
}