//! `MizanClient` — the kernel entry point. //! //! Mirrors the `configure(opts)` + module-level state in //! `frontends/mizan-base/src/index.ts`, but as an owned struct because //! Rust lacks module-level mutable state. Consumers hold an //! `Arc` and pass it everywhere the TS code would have //! used the module-level `config`. //! //! Public surface: //! - `MizanClient::new(config)` — build with reqwest cookie jar. //! - `client.fetch_context(name, params)` — async, returns parsed JSON bundle. //! - `client.call(fn_name, args)` — async, applies merge + invalidation //! from the response then returns `result`. //! - `client.register_context(name, params, fetch_fn)` — register an //! instance; returns a `ContextHandle`. //! - `client.invalidate(name)` / `client.invalidate_scoped(name, params)` //! — schedule invalidation via the kernel queue. //! - `client.merge(context, params, slot, value)` — splice a value into //! a context bundle slot. use std::sync::Arc; use std::time::Duration; use reqwest::cookie::CookieStore; use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT}; use reqwest::Url; use serde_json::Value; use tokio::sync::OnceCell; use crate::context::{ContextHandle, ContextRegistry, FetchFn}; use crate::error::MizanError; use crate::invalidation::InvalidationQueue; use crate::transport; pub struct MizanConfig { pub base_url: String, pub session: bool, pub csrf_cookie_name: String, pub csrf_header_name: String, pub extra_headers: Vec<(String, String)>, } impl Default for MizanConfig { fn default() -> Self { Self { base_url: "/api/mizan".to_string(), session: true, csrf_cookie_name: "csrftoken".to_string(), csrf_header_name: "X-CSRFToken".to_string(), extra_headers: Vec::new(), } } } pub struct MizanClient { config: Arc, http: reqwest::Client, cookie_jar: Arc, registry: Arc, queue: Arc, session_ready: OnceCell<()>, } impl MizanClient { pub fn new(config: MizanConfig) -> Arc { let cookie_jar = Arc::new(reqwest::cookie::Jar::default()); let http = reqwest::Client::builder() .cookie_provider(Arc::clone(&cookie_jar)) .build() .expect("reqwest client construction"); let registry = Arc::new(ContextRegistry::new()); let queue = InvalidationQueue::new(Arc::clone(®istry)); Arc::new(Self { config: Arc::new(config), http, cookie_jar, registry, queue, session_ready: OnceCell::new(), }) } pub fn config(&self) -> &MizanConfig { &self.config } pub fn http(&self) -> &reqwest::Client { &self.http } pub fn context_registry(&self) -> &Arc { &self.registry } pub fn invalidation_queue(&self) -> &Arc { &self.queue } /// Hit `/session/` once on first call to bootstrap the CSRF cookie. /// No-op when `config.session == false`. Three attempts with 100ms /// × attempt backoff. pub async fn ensure_session_ready(&self) -> Result<(), MizanError> { if !self.config.session { return Ok(()); } self.session_ready .get_or_try_init(|| async { if self.read_csrf_cookie().is_some() { return Ok(()); } let url = Url::parse(&format!("{}/session/", self.config.base_url.trim_end_matches('/'))) .map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?; for attempt in 0..3 { let res = self.http.get(url.clone()).send().await; if res.is_ok() && self.read_csrf_cookie().is_some() { return Ok(()); } if attempt < 2 { tokio::time::sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await; } } // Mirror TS: failing to bootstrap is non-fatal — subsequent // calls proceed without CSRF and may still succeed (e.g., // FastAPI configs that don't require it). Ok(()) }) .await .copied() } pub(crate) async fn resolve_headers(&self) -> HeaderMap { let mut headers = HeaderMap::new(); for (name, value) in &self.config.extra_headers { if let (Ok(n), Ok(v)) = (HeaderName::try_from(name.as_str()), HeaderValue::try_from(value.as_str())) { headers.insert(n, v); } } if let Some(token) = self.read_csrf_cookie() { if let (Ok(n), Ok(v)) = ( HeaderName::try_from(self.config.csrf_header_name.as_str()), HeaderValue::try_from(token.as_str()), ) { headers.insert(n, v); } } headers.insert(ACCEPT, HeaderValue::from_static("application/json")); headers } fn read_csrf_cookie(&self) -> Option { let url = Url::parse(&self.config.base_url).ok()?; let header = self.cookie_jar.cookies(&url)?; let raw = header.to_str().ok()?; let needle = format!("{}=", self.config.csrf_cookie_name); raw.split(';') .map(|p| p.trim()) .find_map(|p| p.strip_prefix(&needle)) .map(|v| v.trim_matches('"').to_string()) } // ── High-level API ───────────────────────────────────────────────── pub async fn fetch_context(&self, context: &str, params: &Value) -> Result { transport::mizan_fetch(self, context, params).await } pub async fn call(&self, fn_name: &str, args: Value) -> Result { transport::mizan_call(self, fn_name, args).await } pub async fn register_context( self: &Arc, name: impl Into, params: Value, fetch_fn: FetchFn, ) -> ContextHandle { self.registry.register(name, params, fetch_fn, None).await } pub async fn invalidate(self: &Arc, name: impl Into) { self.queue.invalidate(name).await; } pub async fn invalidate_scoped(self: &Arc, name: impl Into, params: Value) { self.queue.invalidate_scoped(name, params).await; } pub async fn merge(&self, context: &str, params: Option<&Value>, slot: &str, value: &Value) { self.registry.merge(context, params, slot, value).await; } }