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:
312
backends/mizan-rust-axum/Cargo.lock
generated
312
backends/mizan-rust-axum/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
89
backends/mizan-rust-axum/src/forms.rs
Normal file
89
backends/mizan-rust-axum/src/forms.rs
Normal 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
|
||||
}
|
||||
@@ -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, ¶ms);
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
50
backends/mizan-rust-axum/src/ssr.rs
Normal file
50
backends/mizan-rust-axum/src/ssr.rs
Normal 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)
|
||||
}
|
||||
106
backends/mizan-rust-axum/src/state.rs
Normal file
106
backends/mizan-rust-axum/src/state.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
174
backends/mizan-rust-axum/src/ws.rs
Normal file
174
backends/mizan-rust-axum/src/ws.rs
Normal 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() },
|
||||
})
|
||||
}
|
||||
422
backends/mizan-rust-axum/tests/behavior.rs
Normal file
422
backends/mizan-rust-axum/tests/behavior.rs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user