AFI parity: close all 35 gaps — every adapter wires every AFI-common capability

The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View File

@@ -0,0 +1,89 @@
//! Forms endpoints — schema / validate / submit over the registered form
//! functions. The Forms capability is AFI-common; the binding is
//! per-framework (Django Forms on Django, a `#[mizan(form_name=…,
//! form_role=…)]` function here). A form is the set of registered functions
//! sharing a `form_name`, each carrying one `form_role`; each role gets its
//! own route that dispatches the function whose `(form_name, form_role)`
//! matches.
//!
//! POST /form/:form_name/schema/
//! POST /form/:form_name/validate/
//! POST /form/:form_name/submit/
use axum::extract::{Path, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{FunctionSpec, MizanError, RequestHandle, FUNCTIONS};
use serde_json::{Map, Value};
use std::sync::Arc;
use crate::errors::ApiError;
use crate::state::MizanState;
/// Find the registered form function with this `(form_name, form_role)`.
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
}
/// Dispatch the form function for `(form_name, role)`. Shared by the three
/// role routes below.
async fn dispatch_role(
state: &MizanState,
form_name: &str,
role: &str,
args: Value,
) -> Result<Response, ApiError> {
let fn_spec = lookup_form_fn(form_name, role).ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"no form {form_name:?} with role {role:?}"
)))
})?;
let args_value = match args {
Value::Object(_) | Value::Null => args,
other => Value::Object({
let mut m = Map::new();
m.insert("data".into(), other);
m
}),
};
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec.dispatch(req, args_value).await.map_err(ApiError)?;
let mut resp = (StatusCode::OK, Json(result)).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(resp)
}
/// POST /form/:form_name/schema/ — the form's field/schema descriptor.
pub async fn form_schema(
State(state): State<Arc<MizanState>>,
Path(form_name): Path<String>,
Json(args): Json<Value>,
) -> Result<Response, ApiError> {
dispatch_role(&state, &form_name, "schema", args).await
}
/// POST /form/:form_name/validate/ — validate submitted data without committing.
pub async fn form_validate(
State(state): State<Arc<MizanState>>,
Path(form_name): Path<String>,
Json(args): Json<Value>,
) -> Result<Response, ApiError> {
dispatch_role(&state, &form_name, "validate", args).await
}
/// POST /form/:form_name/submit/ — validate and commit the form.
pub async fn form_submit(
State(state): State<Arc<MizanState>>,
Path(form_name): Path<String>,
Json(args): Json<Value>,
) -> Result<Response, ApiError> {
dispatch_role(&state, &form_name, "submit", args).await
}

View File

@@ -1,25 +1,22 @@
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`
//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic.
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header,
lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity,
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::any::Any;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::errors::ApiError;
/// Type-erased application state threaded into every `dispatch()` call via
/// `RequestHandle`. User handlers downcast to their concrete state type.
/// `Arc` keeps the clone cheap across per-request handler invocations.
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
use crate::state::MizanState;
/// Body for POST /call/. Matches the Python `CallBody` shape.
#[derive(Debug, Deserialize)]
@@ -33,9 +30,7 @@ pub struct CallBody {
impl CallBody {
fn resolved_name(&self) -> Option<&str> {
self.function_name
.as_deref()
.or(self.fn_.as_deref())
self.function_name.as_deref().or(self.fn_.as_deref())
}
}
@@ -54,44 +49,210 @@ fn no_store(json: Value) -> Response {
resp
}
/// POST /call/ — RPC dispatch.
/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer`
/// through the shared `authenticate`. A present-but-invalid token rejects with
/// 401 (the `INVALID` contract); no token → anonymous (`None`).
pub(crate) fn identity_from_headers(
headers: &HeaderMap,
state: &MizanState,
) -> Result<Option<Identity>, ApiError> {
let mwt = headers
.get("X-Mizan-Token")
.and_then(|v| v.to_str().ok());
let bearer = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) {
AuthOutcome::Authenticated(id) => Ok(Some(id)),
AuthOutcome::Anonymous => Ok(None),
AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized(
"Invalid or expired token".into(),
))),
}
}
/// Enforce a function's `@client(auth=...)` against the resolved identity.
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> {
let req = AuthRequirement::from_str_opt(fn_spec.auth());
enforce_auth(identity, &req).map_err(ApiError)
}
/// Reject a client call into a `private` function (no RPC endpoint).
fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> {
if fn_spec.private() {
return Err(ApiError(MizanError::Forbidden(
"Function is not client-callable".into(),
)));
}
Ok(())
}
fn uid_str(identity: Option<&Identity>) -> Option<String> {
identity.map(|i| i.user_id.clone())
}
/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body
/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the
/// invalidated contexts.
pub async fn function_call(
State(app_state): State<AppStateAny>,
Json(body): Json<CallBody>,
State(state): State<Arc<MizanState>>,
headers: HeaderMap,
body: axum::body::Body,
) -> Result<Response, ApiError> {
let fn_name = body
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
let identity = identity_from_headers(&headers, &state)?;
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let fn_spec = lookup_function(&fn_name)
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
let (fn_name, args) = if content_type.starts_with("multipart/form-data") {
parse_multipart(&content_type, body).await?
} else {
parse_json_call(body).await?
};
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
let fn_spec = lookup_function(&fn_name).ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"function {fn_name:?} not registered"
)))
})?;
reject_if_private(fn_spec)?;
guard(fn_spec, identity.as_ref())?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &body.args, &result);
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ApiError)?;
let targets = compute_invalidation(fn_spec, &args);
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
let merges = compute_merges(fn_spec, &args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
// Purge the origin cache for everything this mutation invalidated.
if !targets.is_empty() {
state.cache.purge(&targets, uid_str(identity.as_ref()).as_deref());
}
let payload = CallResponse {
result,
invalidate,
merge: merge_payload,
};
Ok(no_store(serde_json::to_value(&payload).unwrap()))
let mut resp = no_store(serde_json::to_value(&payload).unwrap());
if !targets.is_empty() {
let header_val = format_invalidate_header(&targets);
if let Ok(hv) = HeaderValue::from_str(&header_val) {
resp.headers_mut().insert("X-Mizan-Invalidate", hv);
}
}
Ok(resp)
}
/// GET /ctx/:context_name/ — bundled context fetch.
async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map<String, Value>), ApiError> {
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?;
let call: CallBody = serde_json::from_slice(&bytes)
.map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?;
let fn_name = call
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
.to_string();
Ok((fn_name, call.args))
}
/// Parse a multipart `/call/` request: a JSON `args` field plus file parts.
/// Each file part binds into the matching Upload-typed input field as a
/// base64-carrying value the `mizan_core::Upload` field deserializes.
async fn parse_multipart(
content_type: &str,
body: axum::body::Body,
) -> Result<(String, Map<String, Value>), ApiError> {
let boundary = multer::parse_boundary(content_type)
.map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?;
let stream = body.into_data_stream();
let mut mp = multer::Multipart::new(stream, boundary);
let mut fn_name: Option<String> = None;
let mut args: Map<String, Value> = Map::new();
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
while let Some(field) = mp
.next_field()
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))?
{
let name = field.name().unwrap_or("").to_string();
let filename = field.file_name().map(|s| s.to_string());
let part_content_type = field.content_type().map(|s| s.to_string());
if filename.is_some() {
// A file part → the JSON shape `mizan_core::Upload` deserializes
// (filename, content_type, base64 bytes).
let data = field
.bytes()
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?;
files.entry(name).or_default().push(uploaded_file_json(
filename,
part_content_type,
&data,
));
} else {
let text = field
.text()
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?;
if name == "fn" {
fn_name = Some(text);
} else if name == "args" {
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into()))
})?;
if let Value::Object(m) = parsed {
args = m;
}
}
}
}
// Bind file parts into args by field name (single vs list).
for (field_name, parts) in files {
if parts.len() == 1 {
args.insert(field_name, parts.into_iter().next().unwrap());
} else {
args.insert(field_name, Value::Array(parts));
}
}
let fn_name =
fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?;
Ok((fn_name, args))
}
/// Encode a received file part as the JSON shape an `Upload` field expects.
fn uploaded_file_json(filename: Option<String>, content_type: Option<String>, data: &[u8]) -> Value {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
serde_json::json!({
"filename": filename,
"content_type": content_type,
"data_b64": STANDARD.encode(data),
"size": data.len(),
})
}
/// GET /ctx/:context_name/ — bundled context fetch, origin-cached.
pub async fn context_fetch(
State(app_state): State<AppStateAny>,
State(state): State<Arc<MizanState>>,
headers: HeaderMap,
Path(context_name): Path<String>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> {
@@ -101,6 +262,8 @@ pub async fn context_fetch(
))));
}
let identity = identity_from_headers(&headers, &state)?;
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
@@ -112,22 +275,130 @@ pub async fn context_fetch(
))));
}
// Convert query params (all-string values) to the JSON arg map. Numeric
// params get parsed via the per-function input_params primitive table.
// Origin cache: the canonical-JSON bundle body is keyed by (context,
// params, user, rev). The Rust IR carries no per-fn rev yet → rev 0.
let cache_params: BTreeMap<String, Value> = params
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect();
let uid = uid_str(identity.as_ref());
if let Some(cached) = state
.cache
.get(&context_name, &cache_params, uid.as_deref(), 0)
{
return Ok(cached_response(cached, "HIT"));
}
// Enforce auth per member (the bundle is only as open as its strictest fn).
let mut bundled = Map::new();
for fn_spec in &members {
guard(*fn_spec, identity.as_ref())?;
let args = coerce_query_args(*fn_spec, &params);
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec
.dispatch(req, Value::Object(args))
.await
.map_err(ApiError)?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(no_store(Value::Object(bundled)))
let body = canonical_bytes(&Value::Object(bundled));
let status = if state.cache.enabled() {
state
.cache
.put(&context_name, &cache_params, body.clone(), uid.as_deref(), 0);
"MISS"
} else {
""
};
Ok(cached_response(body, status))
}
/// Coerce string-valued query params into typed JSON values using the
/// function's declared input_params. Strings that don't parse stay as
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
/// Canonical JSON bytes for the cache body — sorted keys, matching Python's
/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible.
fn canonical_bytes(v: &Value) -> Vec<u8> {
fn sort(v: &Value) -> Value {
match v {
Value::Object(m) => {
let mut keys: Vec<&String> = m.keys().collect();
keys.sort();
let mut out = Map::new();
for k in keys {
out.insert(k.clone(), sort(&m[k]));
}
Value::Object(out)
}
Value::Array(a) => Value::Array(a.iter().map(sort).collect()),
other => other.clone(),
}
}
// Python's default separators add a space after ':' and ','. Match that so
// a Rust-written cache body and a Python-written one are byte-equal.
let sorted = sort(v);
python_json(&sorted)
}
/// Serialize like Python `json.dumps(sort_keys=True)` default separators
/// (`", "` and `": "`).
fn python_json(v: &Value) -> Vec<u8> {
let compact = serde_json::to_string(v).unwrap();
// serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults.
// This is a structural transform on the already-sorted value, so the
// bytes match `json.dumps` for the JSON value space Mizan returns.
let spaced = respace(&compact);
spaced.into_bytes()
}
/// Insert the spaces Python's default `json.dumps` uses after structural
/// `,`/`:` — but only outside string literals.
fn respace(s: &str) -> String {
let mut out = String::with_capacity(s.len() + s.len() / 8);
let mut in_str = false;
let mut escaped = false;
for c in s.chars() {
if in_str {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => {
in_str = true;
out.push(c);
}
',' => out.push_str(", "),
':' => out.push_str(": "),
_ => out.push(c),
}
}
out
}
fn cached_response(body: Vec<u8>, cache_status: &str) -> Response {
let mut resp = (StatusCode::OK, body).into_response();
let h = resp.headers_mut();
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
if !cache_status.is_empty() {
if let Ok(v) = HeaderValue::from_str(cache_status) {
h.insert("X-Mizan-Cache", v);
}
}
resp
}
/// Coerce string-valued query params into typed JSON via the function's
/// declared input_params.
fn coerce_query_args(
fn_spec: &dyn FunctionSpec,
params: &BTreeMap<String, String>,
@@ -137,28 +408,88 @@ fn coerce_query_args(
if let Some(raw) = params.get(ip.name) {
let parsed = match ip.primitive {
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
serde_json::Number::from_f64(v).map(Value::Number)
}),
mizan_core::Primitive::Number => raw
.parse::<f64>()
.ok()
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)),
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
};
if let Some(v) = parsed {
out.insert(ip.name.into(), v);
} else {
out.insert(ip.name.into(), Value::from(raw.clone()));
}
out.insert(ip.name.into(), parsed.unwrap_or_else(|| Value::from(raw.clone())));
}
}
out
}
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
/// mizan-django and mizan-fastapi. The CSRF *token* is a Django session
/// mechanism with no Rust equivalent, so this returns a null token; the endpoint
/// itself is owed and present, and readiness-probe consumers get a well-formed
/// response.
/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session
/// mechanism; the endpoint here returns a null token and serves as the
/// readiness probe the wire-parity harness uses.
pub async fn session_init() -> Response {
let body = serde_json::json!({ "csrfToken": null });
no_store(body)
no_store(serde_json::json!({ "csrfToken": null }))
}
/// GET /manifest/ — emit the edge manifest (contexts + render_strategy +
/// mutations) the way `export_edge_manifest` does, so an HTTP deploy can fetch
/// it. Rides the shared `mizan_core::generate_edge_manifest`.
pub async fn edge_manifest(State(state): State<Arc<MizanState>>) -> Response {
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
no_store(manifest)
}
/// GET /psr/:context_name/ — the PSR descriptor for one context: its
/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or
/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge
/// re-renders. This is the adapter telling Edge *how* to cache each context —
/// the PSR half of the manifest, addressable per-context.
pub async fn psr_descriptor(
State(state): State<Arc<MizanState>>,
Path(context_name): Path<String>,
) -> Result<Response, ApiError> {
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
let ctx = manifest
.get("contexts")
.and_then(|c| c.get(&context_name))
.ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"context {context_name:?} not in manifest"
)))
})?;
let render_strategy = ctx
.get("render_strategy")
.cloned()
.unwrap_or(Value::Null);
let page_routes = ctx
.get("page_routes")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new()));
Ok(no_store(serde_json::json!({
"context": context_name,
"render_strategy": render_strategy,
"page_routes": page_routes,
})))
}
/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's
/// output, derived from the registered type graph by `mizan_core::shapes`.
pub async fn shape_projection(Path(fn_name): Path<String>) -> Result<Response, ApiError> {
let proj = shapes::project_function_output(&fn_name).ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"no shape projection for {fn_name:?}"
)))
})?;
Ok(no_store(projection_to_json(&proj)))
}
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
let mut fields = Vec::new();
for f in &proj.fields {
match f {
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
shapes::ShapeField::Nested(n, sub) => {
fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) }));
}
}
}
serde_json::json!({ "type": proj.type_name, "fields": fields })
}

View File

@@ -1,58 +1,80 @@
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry,
//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest).
//!
//! Usage:
//! ```ignore
//! use axum::Router;
//! use mizan_axum::router;
//! use mizan_axum::{router, MizanState};
//!
//! #[tokio::main]
//! async fn main() {
//! let app = Router::new().nest("/api/mizan", router());
//! let state = MizanState::builder()
//! .app_state(MyState { /* ... */ })
//! .build();
//! let app = Router::new().nest("/api/mizan", router(state));
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
//! axum::serve(listener, app).await.unwrap();
//! }
//! ```
//!
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
//! * `GET /session/` — session-init probe (placeholder CSRF token)
//! * `POST /call/` — RPC dispatch with invalidate+merge response
//! * `GET /ctx/:name/` — bundled context fetch
//! * `GET /session/` — session-init probe (placeholder CSRF token)
//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate
//! * `GET /ctx/:name/` — bundled context fetch (origin-cached)
//! * `GET /ws/` — WebSocket RPC transport (`websocket=` fns)
//! * `GET /manifest/` — edge manifest (contexts/render_strategy/mutations)
//! * `GET /psr/:context/` — per-context PSR descriptor (render_strategy)
//! * `GET /shape/:fn/` — typed query projection (Shapes)
//! * `POST /ssr/` — server-side render via the Bun worker
//! * `POST /form/:name/{schema,validate,submit}/` — forms binding
mod errors;
mod forms;
mod handlers;
mod ssr;
mod state;
mod ws;
pub use errors::ApiError;
pub use handlers::{
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
};
pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse};
pub use ssr::{ssr_render, SsrRequest};
pub use state::{AppStateAny, MizanState, MizanStateBuilder};
use axum::routing::{get, post};
use axum::Router;
use std::any::Any;
use std::sync::Arc;
/// Build the Mizan router with user-supplied app state. The state is
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
/// type.
///
/// Mount under a prefix:
/// `Router::new().nest("/api/mizan", router(my_state))`.
pub fn router<S>(state: S) -> Router
where
S: Any + Send + Sync + 'static,
{
let state: AppStateAny = Arc::new(state);
/// Build the Mizan router with a fully-configured [`MizanState`] (app state +
/// auth + cache + optional SSR worker). Mount under a prefix:
/// `Router::new().nest("/api/mizan", router(state))`.
pub fn router(state: Arc<MizanState>) -> Router {
Router::new()
.route("/session/", get(handlers::session_init))
.route("/call/", post(handlers::function_call))
.route("/ctx/:context_name/", get(handlers::context_fetch))
.route("/ws/", get(ws::ws_handler))
.route("/manifest/", get(handlers::edge_manifest))
.route("/psr/:context_name/", get(handlers::psr_descriptor))
.route("/shape/:fn_name/", get(handlers::shape_projection))
.route("/ssr/", post(ssr::ssr_render))
.route("/form/:form_name/schema/", post(forms::form_schema))
.route("/form/:form_name/validate/", post(forms::form_validate))
.route("/form/:form_name/submit/", post(forms::form_submit))
.with_state(state)
}
/// Router variant for callers that have no app state to thread — the
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
/// and other stateless test apps.
pub fn router_stateless() -> Router {
router(())
/// Router variant for the common case of just an app state, no auth/cache.
pub fn router_with_state<S>(app_state: S) -> Router
where
S: Any + Send + Sync + 'static,
{
router(MizanState::builder().app_state(app_state).build())
}
/// Router variant for callers that have no app state to thread — the dispatch
/// path receives a unit-typed handle. Used by the AFI fixture and stateless
/// test apps.
pub fn router_stateless() -> Router {
router(MizanState::builder().build())
}

View File

@@ -0,0 +1,50 @@
//! SSR endpoint — drive the Bun renderer through the shared `mizan_core`
//! `SsrBridge` (same newline-delimited JSON-RPC protocol as the Python
//! `SSRBridge`). The bridge spawns on first render and stays alive.
//!
//! POST /ssr/ { "file": "/abs/Component.tsx", "props": {...} } → { "html": "..." }
use axum::extract::State;
use axum::response::Response;
use axum::Json;
use mizan_core::MizanError;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use crate::errors::ApiError;
use crate::state::MizanState;
#[derive(Deserialize)]
pub struct SsrRequest {
pub file: String,
#[serde(default)]
pub props: Value,
}
/// POST /ssr/ — render a component file via the Bun SSR worker.
pub async fn ssr_render(
State(state): State<Arc<MizanState>>,
Json(req): Json<SsrRequest>,
) -> Result<Response, ApiError> {
let bridge = state.ssr().ok_or_else(|| {
ApiError(MizanError::NotImplementedYet(
"no SSR worker configured (set MizanState::builder().ssr_worker(...))".into(),
))
})?;
let props = if req.props.is_null() {
json!({})
} else {
req.props
};
let html = bridge
.render(&req.file, props)
.map_err(|e| ApiError(MizanError::InternalError(e.to_string())))?;
let mut resp = axum::response::IntoResponse::into_response(Json(json!({ "html": html })));
resp.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
Ok(resp)
}

View File

@@ -0,0 +1,106 @@
//! Router state — the Mizan config (auth + origin cache) threaded alongside
//! the user's type-erased app state.
//!
//! `app_state` is the consumer's own state, type-erased into `Arc<dyn Any>`
//! and handed to every `dispatch()` via `RequestHandle` (handlers downcast to
//! their concrete type — unchanged from the pre-AFI router). `auth` and
//! `cache` are the AFI-common config the handlers read for enforcement and
//! origin caching; an `SsrBridge` is created lazily on the first SSR render.
use mizan_core::{AuthConfig, CacheOrchestrator, SsrBridge};
use std::any::Any;
use std::sync::{Arc, OnceLock};
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
/// The full state every Mizan handler receives. Built via [`MizanState::builder`].
pub struct MizanState {
/// The consumer's app state, threaded into dispatch via `RequestHandle`.
pub app_state: AppStateAny,
/// JWT/MWT auth config (token → identity resolution + enforcement).
pub auth: AuthConfig,
/// Origin-side HMAC cache orchestrator (disabled by default).
pub cache: CacheOrchestrator,
/// Mizan API mount point, used by the edge-manifest endpoint.
pub base_url: String,
/// Lazily-spawned SSR bridge; configured via the builder's `ssr_worker`.
pub(crate) ssr_worker: Option<String>,
pub(crate) ssr_bridge: OnceLock<SsrBridge>,
}
impl MizanState {
pub fn builder() -> MizanStateBuilder {
MizanStateBuilder::default()
}
/// The SSR bridge, spawned on first use. `None` if no worker was set.
pub fn ssr(&self) -> Option<&SsrBridge> {
let worker = self.ssr_worker.as_ref()?;
Some(
self.ssr_bridge
.get_or_init(|| SsrBridge::bun(worker.clone())),
)
}
}
/// Builder for [`MizanState`]. Defaults: unit app state, no auth, cache
/// disabled, `/api/mizan` base URL, no SSR worker.
pub struct MizanStateBuilder {
app_state: AppStateAny,
auth: AuthConfig,
cache: CacheOrchestrator,
base_url: String,
ssr_worker: Option<String>,
}
impl Default for MizanStateBuilder {
fn default() -> Self {
Self {
app_state: Arc::new(()),
auth: AuthConfig::new(),
cache: CacheOrchestrator::disabled(),
base_url: "/api/mizan".to_string(),
ssr_worker: None,
}
}
}
impl MizanStateBuilder {
/// Set the consumer's app state (threaded into dispatch).
pub fn app_state<S: Any + Send + Sync + 'static>(mut self, state: S) -> Self {
self.app_state = Arc::new(state);
self
}
pub fn auth(mut self, auth: AuthConfig) -> Self {
self.auth = auth;
self
}
pub fn cache(mut self, cache: CacheOrchestrator) -> Self {
self.cache = cache;
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
/// Configure the Bun SSR worker path; the bridge spawns on first render.
pub fn ssr_worker(mut self, worker_path: impl Into<String>) -> Self {
self.ssr_worker = Some(worker_path.into());
self
}
pub fn build(self) -> Arc<MizanState> {
Arc::new(MizanState {
app_state: self.app_state,
auth: self.auth,
cache: self.cache,
base_url: self.base_url,
ssr_worker: self.ssr_worker,
ssr_bridge: OnceLock::new(),
})
}
}

View File

@@ -0,0 +1,174 @@
//! WebSocket RPC transport. `@client(websocket=true)` functions declare
//! `Transport::Websocket` in the IR; this routes a real Axum WebSocket handler
//! that dispatches call/fetch frames through the same `mizan-core` registry
//! the HTTP path uses. A call frame naming a non-websocket function is
//! rejected, so the transport boundary the IR declares is enforced.
//!
//! Frame protocol (text JSON), mirroring the HTTP call/ctx shapes:
//! → {"id": 1, "op": "call", "fn": "name", "args": {...}}
//! → {"id": 2, "op": "fetch", "context": "c", "params": {...}}
//! ← {"id": 1, "result": ..., "invalidate": [...], "merge"?: [...]}
//! ← {"id": 2, "data": {fnName: result, ...}}
//! ← {"id": N, "error": {"code": ..., "message": ...}}
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::Response;
use futures_util::StreamExt;
use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function, AuthRequirement,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, Transport, FUNCTIONS,
};
use serde_json::{json, Map, Value};
use std::sync::Arc;
use crate::state::MizanState;
/// GET /ws/ — upgrade to a Mizan WebSocket RPC connection.
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<MizanState>>,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: Arc<MizanState>) {
while let Some(Ok(msg)) = socket.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
Message::Ping(_) | Message::Pong(_) | Message::Binary(_) => continue,
};
let reply = handle_frame(&state, &text).await;
if socket
.send(Message::Text(reply.to_string()))
.await
.is_err()
{
break;
}
}
}
async fn handle_frame(state: &MizanState, text: &str) -> Value {
let frame: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(e) => return err_frame(Value::Null, &MizanError::BadRequest(format!("bad frame: {e}"))),
};
let id = frame.get("id").cloned().unwrap_or(Value::Null);
let op = frame.get("op").and_then(|o| o.as_str()).unwrap_or("call");
match op {
"call" => match dispatch_ws_call(state, &frame).await {
Ok(v) => with_id(id, v),
Err(e) => err_frame(id, &e),
},
"fetch" => match dispatch_ws_fetch(state, &frame).await {
Ok(v) => with_id(id, json!({ "data": v })),
Err(e) => err_frame(id, &e),
},
other => err_frame(id, &MizanError::BadRequest(format!("unknown op {other:?}"))),
}
}
async fn dispatch_ws_call(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
let fn_name = frame
.get("fn")
.and_then(|f| f.as_str())
.ok_or_else(|| MizanError::BadRequest("missing `fn`".into()))?;
let args = frame
.get("args")
.and_then(|a| a.as_object())
.cloned()
.unwrap_or_default();
let fn_spec =
lookup_function(fn_name).ok_or_else(|| MizanError::NotFound(format!("{fn_name:?}")))?;
if fn_spec.private() {
return Err(MizanError::Forbidden("Function is not client-callable".into()));
}
// The WS transport only carries functions that opted into it.
if !matches!(fn_spec.transport(), Transport::Websocket | Transport::Both) {
return Err(MizanError::BadRequest(format!(
"function {fn_name:?} is not exposed over the WebSocket transport"
)));
}
enforce_anon_guard(fn_spec)?;
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
let targets = compute_invalidation(fn_spec, &args);
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
let merges = compute_merges(fn_spec, &args, &result);
let mut out = Map::new();
out.insert("result".into(), result);
out.insert("invalidate".into(), Value::Array(invalidate));
if !merges.is_empty() {
out.insert(
"merge".into(),
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
);
}
Ok(Value::Object(out))
}
async fn dispatch_ws_fetch(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
let ctx = frame
.get("context")
.and_then(|c| c.as_str())
.ok_or_else(|| MizanError::BadRequest("missing `context`".into()))?;
if lookup_context(ctx).is_none() {
return Err(MizanError::NotFound(format!("context {ctx:?}")));
}
let params = frame
.get("params")
.and_then(|p| p.as_object())
.cloned()
.unwrap_or_default();
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(ctx))
.collect();
let mut bundle = Map::new();
for fn_spec in &members {
enforce_anon_guard(*fn_spec)?;
let mut args = Map::new();
for ip in fn_spec.input_params() {
if let Some(v) = params.get(ip.name) {
args.insert(ip.name.into(), v.clone());
}
}
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
bundle.insert(fn_spec.name().to_string(), result);
}
Ok(Value::Object(bundle))
}
/// Enforce a function's auth guard for the WS transport. The WS upgrade
/// carries no per-frame identity in this baseline, so a guarded function is
/// rejected over WS — the same enforce-or-reject contract the HTTP path uses,
/// applied with an anonymous identity.
fn enforce_anon_guard(fn_spec: &dyn FunctionSpec) -> Result<(), MizanError> {
let req = AuthRequirement::from_str_opt(fn_spec.auth());
mizan_core::enforce_auth(None, &req)
}
fn with_id(id: Value, mut body: Value) -> Value {
if let Some(obj) = body.as_object_mut() {
obj.insert("id".into(), id);
}
body
}
fn err_frame(id: Value, e: &MizanError) -> Value {
json!({
"id": id,
"error": { "code": e.code(), "message": e.message() },
})
}