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

@@ -27,6 +27,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64",
"bytes",
"futures-util",
"http",
@@ -38,6 +39,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@@ -45,8 +47,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -74,18 +78,90 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -110,6 +186,23 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
@@ -123,17 +216,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -286,10 +411,15 @@ name = "mizan-axum"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"futures-util",
"http-body-util",
"mizan-core",
"multer",
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tower",
"tower-http",
]
@@ -299,10 +429,13 @@ name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"hmac",
"linkme",
"mizan-macros",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -315,6 +448,23 @@ dependencies = [
"syn",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -333,6 +483,15 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -351,6 +510,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -429,6 +618,28 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -451,6 +662,18 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -468,12 +691,33 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
@@ -493,6 +737,18 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -557,12 +813,48 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -584,6 +876,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"

View File

@@ -7,9 +7,17 @@ license = "Elastic-2.0"
[dependencies]
mizan-core = { path = "../../cores/mizan-rust" }
axum = "0.7"
axum = { version = "0.7", features = ["ws", "multipart"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }
futures-util = "0.3"
multer = "3"
base64 = "0.22"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
tokio-tungstenite = "0.24"
http-body-util = "0.1"

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() },
})
}

View File

@@ -0,0 +1,422 @@
//! Runtime behavior tests for the axum adapter — the conformance ceiling that
//! the source-presence probes set the floor for. Each AFI-common HTTP cell is
//! driven end to end through the real router (`tower::ServiceExt::oneshot`,
//! no socket) and asserted on the wire bytes/headers; the WebSocket cell runs
//! against a real bound port.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::{
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
};
use mizan_axum::{router, MizanState};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
use tower::ServiceExt;
// ─── Fixture: the functions these tests dispatch ────────────────────────────
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Profile {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Ok {
pub ok: bool,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Secret {
pub flag: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct UploadEcho {
pub filename: String,
pub size: i64,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct SchemaOut {
pub fields: Vec<String>,
}
#[mizan::context("bprofile")]
pub struct BProfileCtx;
#[mizan::client(context = BProfileCtx)]
pub async fn b_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
Profile {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client(affects = BProfileCtx)]
pub async fn b_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Ok {
let _ = (user_id, name);
Ok { ok: true }
}
#[mizan::client(auth = "staff")]
pub async fn b_secret(_req: &RequestHandle<'_>) -> Secret {
Secret {
flag: "top-secret".into(),
}
}
#[mizan::client(websocket)]
pub async fn b_ping(_req: &RequestHandle<'_>, n: i64) -> Ok {
let _ = n;
Ok { ok: true }
}
#[mizan::client]
pub async fn b_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> UploadEcho {
let _ = user_id;
UploadEcho {
filename: avatar.filename.clone().unwrap_or_default(),
size: avatar.size() as i64,
}
}
#[mizan::client(form_name = "contact", form_role = "submit")]
pub async fn b_contact_submit(_req: &RequestHandle<'_>, name: String) -> Ok {
let _ = name;
Ok { ok: true }
}
#[mizan::client(form_name = "contact", form_role = "schema")]
pub async fn b_contact_schema(_req: &RequestHandle<'_>) -> SchemaOut {
SchemaOut {
fields: vec!["name".into()],
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
fn stateless_app() -> axum::Router {
router(MizanState::builder().build())
}
async fn body_json(resp: axum::response::Response) -> Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
async fn post_call(app: &axum::Router, fn_name: &str, args: Value) -> axum::response::Response {
let req = Request::builder()
.method("POST")
.uri("/call/")
.header("content-type", "application/json")
.body(Body::from(json!({"fn": fn_name, "args": args}).to_string()))
.unwrap();
app.clone().oneshot(req).await.unwrap()
}
// ─── invalidate_header + invalidate_body + rpc_call ──────────────────────────
#[tokio::test]
async fn call_emits_invalidate_body_and_header() {
let app = stateless_app();
let resp = post_call(&app, "b_update_profile", json!({"user_id": 7, "name": "Z"})).await;
assert_eq!(resp.status(), StatusCode::OK);
// The header is co-equal with the body channel: scoped to user_id=7.
let header = resp
.headers()
.get("X-Mizan-Invalidate")
.expect("X-Mizan-Invalidate present")
.to_str()
.unwrap()
.to_string();
assert_eq!(header, "bprofile;user_id=7");
assert_eq!(
resp.headers().get("cache-control").unwrap(),
"no-store"
);
let body = body_json(resp).await;
assert_eq!(body["result"], json!({"ok": true}));
// Body invalidate entry is the scoped object form.
assert_eq!(
body["invalidate"],
json!([{"context": "bprofile", "params": {"user_id": 7}}])
);
}
// ─── auth_enforcement ────────────────────────────────────────────────────────
#[tokio::test]
async fn auth_guard_rejects_anonymous_and_admits_staff() {
// No auth config + a staff-guarded fn → anonymous is rejected 401.
let app = stateless_app();
let resp = post_call(&app, "b_secret", json!({})).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
// With a JWT config + a staff token, the same call is admitted. Mint at
// the real clock so the token is unexpired when the handler verifies it.
let cfg = JwtConfig::new("beh-secret");
let token = mizan::create_access_token(&cfg, "1", "sid", /*staff*/ true, false, mizan::now_unix());
let auth = AuthConfig {
jwt: Some(cfg),
mwt_secret: None,
mwt_audience: "mizan".into(),
};
let app = router(MizanState::builder().auth(auth).build());
let req = Request::builder()
.method("POST")
.uri("/call/")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["result"], json!({"flag": "top-secret"}));
}
#[tokio::test]
async fn auth_guard_forbids_non_staff_token() {
// A valid but non-staff token → 403 on a staff-guarded fn.
let cfg = JwtConfig::new("beh-secret");
let token = mizan::create_access_token(&cfg, "2", "sid", /*staff*/ false, false, mizan::now_unix());
let auth = AuthConfig {
jwt: Some(cfg),
mwt_secret: None,
mwt_audience: "mizan".into(),
};
let app = router(MizanState::builder().auth(auth).build());
let req = Request::builder()
.method("POST")
.uri("/call/")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn invalid_token_is_rejected_not_downgraded() {
// A present-but-bad bearer rejects (401) even on an unguarded context —
// the INVALID-sentinel contract.
let auth = AuthConfig {
jwt: Some(JwtConfig::new("beh-secret")),
mwt_secret: None,
mwt_audience: "mizan".into(),
};
let app = router(MizanState::builder().auth(auth).build());
let req = Request::builder()
.method("GET")
.uri("/ctx/bprofile/?user_id=1")
.header("authorization", "Bearer not-a-real-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ─── origin_cache ────────────────────────────────────────────────────────────
#[tokio::test]
async fn context_fetch_uses_origin_cache() {
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("cache-secret".into()));
let app = router(MizanState::builder().cache(cache).build());
// First fetch: MISS, populates the cache.
let req = Request::builder()
.uri("/ctx/bprofile/?user_id=3")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
let first = body_json(resp).await;
assert_eq!(first["b_user_profile"]["user_id"], json!(3));
// Second fetch: HIT, served from cache.
let req = Request::builder()
.uri("/ctx/bprofile/?user_id=3")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "HIT");
let second = body_json(resp).await;
assert_eq!(first, second);
// A mutation scoped to user_id=3 purges that key → next fetch MISSes.
let _ = post_call(&app, "b_update_profile", json!({"user_id": 3, "name": "New"})).await;
let req = Request::builder()
.uri("/ctx/bprofile/?user_id=3")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
}
// ─── upload ──────────────────────────────────────────────────────────────────
#[tokio::test]
async fn multipart_upload_binds_into_input() {
let app = stateless_app();
let boundary = "----mizanbeh";
let file_bytes = b"PNGDATA-0123456789";
let body = format!(
"--{b}\r\nContent-Disposition: form-data; name=\"fn\"\r\n\r\nb_set_avatar\r\n\
--{b}\r\nContent-Disposition: form-data; name=\"args\"\r\n\r\n{{\"user_id\":9}}\r\n\
--{b}\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\
Content-Type: image/png\r\n\r\n{data}\r\n--{b}--\r\n",
b = boundary,
data = String::from_utf8_lossy(file_bytes),
);
let req = Request::builder()
.method("POST")
.uri("/call/")
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["result"]["filename"], json!("a.png"));
assert_eq!(body["result"]["size"], json!(file_bytes.len()));
}
// ─── edge_manifest + psr ─────────────────────────────────────────────────────
#[tokio::test]
async fn manifest_and_psr_descriptor() {
let app = stateless_app();
let req = Request::builder()
.uri("/manifest/")
.body(Body::empty())
.unwrap();
let manifest = body_json(app.clone().oneshot(req).await.unwrap()).await;
// bprofile is user-scoped (user_id) → dynamic_cached.
assert_eq!(
manifest["contexts"]["bprofile"]["render_strategy"],
json!("dynamic_cached")
);
assert_eq!(
manifest["mutations"]["b_update_profile"]["affects"],
json!(["bprofile"])
);
// Per-context PSR descriptor.
let req = Request::builder()
.uri("/psr/bprofile/")
.body(Body::empty())
.unwrap();
let psr = body_json(app.oneshot(req).await.unwrap()).await;
assert_eq!(psr["render_strategy"], json!("dynamic_cached"));
}
// ─── shapes ──────────────────────────────────────────────────────────────────
#[tokio::test]
async fn shape_projection_endpoint() {
let app = stateless_app();
let req = Request::builder()
.uri("/shape/b_user_profile/")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
// Output type name is camelCased by the macro (`b_user_profile` →
// `bUserProfile`), suffixed `Output`.
assert_eq!(body["type"], json!("bUserProfileOutput"));
let fields = body["fields"].as_array().unwrap();
assert!(fields.contains(&json!("user_id")));
assert!(fields.contains(&json!("name")));
}
// ─── forms ───────────────────────────────────────────────────────────────────
#[tokio::test]
async fn forms_schema_and_submit_routes() {
let app = stateless_app();
let req = Request::builder()
.method("POST")
.uri("/form/contact/schema/")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["fields"], json!(["name"]));
let req = Request::builder()
.method("POST")
.uri("/form/contact/submit/")
.header("content-type", "application/json")
.body(Body::from(json!({"name": "Ada"}).to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_json(resp).await, json!({"ok": true}));
}
// ─── websocket ───────────────────────────────────────────────────────────────
#[tokio::test]
async fn websocket_transport_dispatches_and_rejects_non_ws_fn() {
use tokio_tungstenite::tungstenite::Message;
// Bind a real socket — the WS upgrade needs an actual connection.
let app = stateless_app();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let url = format!("ws://{addr}/ws/");
let (mut socket, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
// A websocket-declared fn dispatches.
use futures_util::{SinkExt, StreamExt};
socket
.send(Message::Text(
json!({"id": 1, "op": "call", "fn": "b_ping", "args": {"n": 5}}).to_string(),
))
.await
.unwrap();
let reply = socket.next().await.unwrap().unwrap();
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
assert_eq!(v["id"], json!(1));
assert_eq!(v["result"], json!({"ok": true}));
// A non-websocket fn over WS is rejected (transport boundary enforced).
socket
.send(Message::Text(
json!({"id": 2, "op": "call", "fn": "b_user_profile", "args": {"user_id": 1}})
.to_string(),
))
.await
.unwrap();
let reply = socket.next().await.unwrap().unwrap();
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
assert_eq!(v["id"], json!(2));
assert!(v["error"]["message"]
.as_str()
.unwrap()
.contains("WebSocket transport"));
server.abort();
}