Mizan-Rust backend adapter: server-side substrate + three-way parity
Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:
#[derive(Mizan, Serialize, Deserialize)]
pub struct ProfileOutput { pub user_id: i64, pub name: String }
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }
…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.
New crates:
- cores/mizan-rust/ (Cargo: mizan-core) — IR types, KDL emitter, traits, registry,
runtime (compute_invalidation / compute_merges
ported from mizan-fastapi), graph_check with
structural type-matching
- cores/mizan-rust-macros/ (Cargo: mizan-macros) — #[derive(Mizan)], #[mizan::context],
#[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum) — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/ — AFI fixture port + server / export-ir binaries
Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
now sort alphabetically in both Python and Rust emitters. The IR is a
contract; linkme doesn't preserve declaration order, so canonical sort
is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
the #[mizan::client] macro registers canonical-named entries from
fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
by name — matches the Python types_match_for_merge semantics.
Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py — 12/12 probes against FastAPI + Rust (EXIT=0)
Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
mizan-django/setup/discovery.py (referenced a function that no longer
exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
probe parity (wire-parity harness now polls /api/mizan/session/ on both
backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
don't need per-crate gitignore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
252
cores/mizan-rust/src/runtime.rs
Normal file
252
cores/mizan-rust/src/runtime.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
//! Runtime helpers — error envelope, request handle, invalidation/merge
|
||||
//! resolution. Ports `compute_invalidation` / `compute_merges` /
|
||||
//! `_resolve_merge_slot` / `_scoped_params` from
|
||||
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
|
||||
|
||||
use crate::registry::context_members;
|
||||
use crate::traits::FunctionSpec;
|
||||
use serde_json::Value;
|
||||
use std::any::Any;
|
||||
|
||||
/// Type-erased handle to the framework's request object. The HTTP adapter
|
||||
/// stuffs its native `Request` here; user code casts back via the adapter's
|
||||
/// helper types.
|
||||
#[derive(Clone)]
|
||||
pub struct RequestHandle<'a> {
|
||||
pub inner: &'a (dyn Any + Send + Sync),
|
||||
}
|
||||
|
||||
impl<'a> RequestHandle<'a> {
|
||||
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
|
||||
Self { inner: req }
|
||||
}
|
||||
|
||||
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
|
||||
self.inner.downcast_ref::<T>()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MizanError {
|
||||
NotFound(String),
|
||||
BadRequest(String),
|
||||
ValidationFailed {
|
||||
message: String,
|
||||
details: Value,
|
||||
},
|
||||
Unauthorized(String),
|
||||
Forbidden(String),
|
||||
NotImplementedYet(String),
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
impl MizanError {
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
MizanError::NotFound(_) => "NOT_FOUND",
|
||||
MizanError::BadRequest(_) => "BAD_REQUEST",
|
||||
MizanError::ValidationFailed { .. } => "VALIDATION_FAILED",
|
||||
MizanError::Unauthorized(_) => "UNAUTHORIZED",
|
||||
MizanError::Forbidden(_) => "FORBIDDEN",
|
||||
MizanError::NotImplementedYet(_) => "NOT_IMPLEMENTED",
|
||||
MizanError::InternalError(_) => "INTERNAL_ERROR",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
MizanError::NotFound(m)
|
||||
| MizanError::BadRequest(m)
|
||||
| MizanError::Unauthorized(m)
|
||||
| MizanError::Forbidden(m)
|
||||
| MizanError::NotImplementedYet(m)
|
||||
| MizanError::InternalError(m) => m,
|
||||
MizanError::ValidationFailed { message, .. } => message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http_status(&self) -> u16 {
|
||||
match self {
|
||||
MizanError::NotFound(_) => 404,
|
||||
MizanError::BadRequest(_) => 400,
|
||||
MizanError::ValidationFailed { .. } => 422,
|
||||
MizanError::Unauthorized(_) => 401,
|
||||
MizanError::Forbidden(_) => 403,
|
||||
MizanError::NotImplementedYet(_) => 501,
|
||||
MizanError::InternalError(_) => 500,
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON envelope shape consumers see on the wire.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("code".into(), Value::String(self.code().into()));
|
||||
body.insert("message".into(), Value::String(self.message().into()));
|
||||
if let MizanError::ValidationFailed { details, .. } = self {
|
||||
body.insert("details".into(), details.clone());
|
||||
}
|
||||
Value::Object({
|
||||
let mut env = serde_json::Map::new();
|
||||
env.insert("error".into(), Value::Object(body));
|
||||
env
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the response's `invalidate` array.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InvalidationTarget {
|
||||
/// A whole context is invalidated.
|
||||
Context(String),
|
||||
/// A context, scoped to specific param values.
|
||||
ScopedContext {
|
||||
context: String,
|
||||
params: serde_json::Map<String, Value>,
|
||||
},
|
||||
/// A specific function output is invalidated.
|
||||
Function(String),
|
||||
}
|
||||
|
||||
impl InvalidationTarget {
|
||||
pub fn to_json(&self) -> Value {
|
||||
match self {
|
||||
InvalidationTarget::Context(name) => Value::String(name.clone()),
|
||||
InvalidationTarget::ScopedContext { context, params } => {
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert("context".into(), Value::String(context.clone()));
|
||||
m.insert("params".into(), Value::Object(params.clone()));
|
||||
Value::Object(m)
|
||||
}
|
||||
InvalidationTarget::Function(name) => {
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert("function".into(), Value::String(name.clone()));
|
||||
Value::Object(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the response's `merge` array. Server-resolved slot — the
|
||||
/// kernel writes the value into `bundle[slot]` directly.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeEntry {
|
||||
pub context: String,
|
||||
pub slot: String,
|
||||
pub value: Value,
|
||||
pub params: Option<serde_json::Map<String, Value>>,
|
||||
}
|
||||
|
||||
impl MergeEntry {
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert("context".into(), Value::String(self.context.clone()));
|
||||
m.insert("slot".into(), Value::String(self.slot.clone()));
|
||||
m.insert("value".into(), self.value.clone());
|
||||
if let Some(params) = &self.params {
|
||||
m.insert("params".into(), Value::Object(params.clone()));
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `invalidate` list from a function's `affects` metadata,
|
||||
/// auto-scoping when arg names match context params.
|
||||
pub fn compute_invalidation(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
) -> Vec<InvalidationTarget> {
|
||||
fn_spec
|
||||
.affects()
|
||||
.iter()
|
||||
.map(|target| match target {
|
||||
crate::ir::AffectTarget::Context(name) => {
|
||||
let scoped = scoped_params(name, args);
|
||||
if scoped.is_empty() {
|
||||
InvalidationTarget::Context((*name).into())
|
||||
} else {
|
||||
InvalidationTarget::ScopedContext {
|
||||
context: (*name).into(),
|
||||
params: scoped,
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::ir::AffectTarget::Function { name, .. } => {
|
||||
InvalidationTarget::Function((*name).into())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the `merge` list from a function's `merge` metadata. Each entry
|
||||
/// names the slot inside the context bundle the return value lands in.
|
||||
pub fn compute_merges(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
result: &Value,
|
||||
) -> Vec<MergeEntry> {
|
||||
let targets = fn_spec.merge();
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mutation_output = fn_spec.output_type();
|
||||
let mut out = Vec::new();
|
||||
for ctx_name in targets {
|
||||
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let scoped = scoped_params(ctx_name, args);
|
||||
out.push(MergeEntry {
|
||||
context: (*ctx_name).into(),
|
||||
slot,
|
||||
value: result.clone(),
|
||||
params: if scoped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(scoped)
|
||||
},
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Find the unique function-name slot whose Output type matches the
|
||||
/// mutation's Output type. Matches Python's `types_match_for_merge` —
|
||||
/// structural shape comparison, not name comparison. Returns None on no
|
||||
/// match or ambiguous match.
|
||||
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
|
||||
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
|
||||
let mut matches: Vec<&'static str> = Vec::new();
|
||||
for fn_spec in context_members(context_name) {
|
||||
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
|
||||
{
|
||||
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
|
||||
matches.push(fn_spec.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches.len() == 1 {
|
||||
Some(matches[0].into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Match input args against the context's declared Input field names.
|
||||
fn scoped_params(
|
||||
context_name: &str,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
) -> serde_json::Map<String, Value> {
|
||||
let mut declared: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
|
||||
for fn_spec in context_members(context_name) {
|
||||
for p in fn_spec.input_params() {
|
||||
declared.insert(p.name);
|
||||
}
|
||||
}
|
||||
args.iter()
|
||||
.filter(|(k, _)| declared.contains(k.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user