Files
mizan/frontends/mizan-rust/src/client.rs
Ryth Azhur 43bcf3f26f Mizan codegen substrate: Rust kernel + Rust codegen binary, JS generator deleted
The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:

1. Moat closes. The codegen logic (how `affects` becomes auto-invalidation,
   how named contexts collapse onto bundled fetches, how the registry-to-
   Provider mapping is shaped) ships compiled instead of as source bytes
   in every consumer's node_modules.

2. Pattern F (lines.push append-walls) becomes structurally unauthorable.
   The emit substrate is askama templates in templates/<target>/*.j2 —
   actual target-language files with {{ ... }} substitution markers,
   syntax-highlighted natively, type-checked against the render context
   structs at compile time. The Rust emit modules build typed render
   contexts and call .render(); no string-builder surface exists.

3. OpenAPI `default`-bearing fields now emit as non-optional in TS / Python
   / Rust — the server always populates them, so consumer code reads them
   without nullable checks. Surfaced by Blazr's typecheck on regeneration.

Layout:
  frontends/mizan-rust/        — Rust port of @mizan/base; #[cfg(feature="pyo3")]
                                 exposes PyMizanClient for the Python target.
  protocol/mizan-codegen/      — codegen binary source + askama templates.
  protocol/mizan-generate/     — npm-package shim. bin/launcher.mjs dispatches
                                 to the platform-appropriate prebuilt binary.
                                 Old generator/ JS tree deleted.
  tests/rust/                  — wire-parity drivers. drive_kernel exercises
                                 raw client.call() / fetch_context(); drive_emitted
                                 exercises the typed crate the codegen emits.
  tests/afi/afi_codegen_app.py — codegen entrypoint module (imports + registers).
  backends/mizan-fastapi/.../schema.py — adds outputNullable so the Rust
                                 codegen can wrap T | None responses in Option<T>.

Verification:
  - 20 mizan-codegen tests green (IR deserialization, byte-equivalent
    parity vs JS baseline for stage1/rust/python/react/vue/svelte,
    structural test for channels).
  - tests/rust/run_wire_parity.py — 12/12 probes green via the Rust binary
    driving the FastAPI fixture end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:26:32 -04:00

196 lines
6.7 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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<MizanClient>` 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<MizanConfig>,
http: reqwest::Client,
cookie_jar: Arc<reqwest::cookie::Jar>,
registry: Arc<ContextRegistry>,
queue: Arc<InvalidationQueue>,
session_ready: OnceCell<()>,
}
impl MizanClient {
pub fn new(config: MizanConfig) -> Arc<Self> {
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(&registry));
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<ContextRegistry> {
&self.registry
}
pub fn invalidation_queue(&self) -> &Arc<InvalidationQueue> {
&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<String> {
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<Value, MizanError> {
transport::mizan_fetch(self, context, params).await
}
pub async fn call(&self, fn_name: &str, args: Value) -> Result<Value, MizanError> {
transport::mizan_call(self, fn_name, args).await
}
pub async fn register_context(
self: &Arc<Self>,
name: impl Into<String>,
params: Value,
fetch_fn: FetchFn,
) -> ContextHandle {
self.registry.register(name, params, fetch_fn, None).await
}
pub async fn invalidate(self: &Arc<Self>, name: impl Into<String>) {
self.queue.invalidate(name).await;
}
pub async fn invalidate_scoped(self: &Arc<Self>, name: impl Into<String>, 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;
}
}