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:
2026-05-17 22:31:26 -04:00
parent 9900f8a36f
commit 45bde51166
47 changed files with 4187 additions and 147 deletions

View File

@@ -0,0 +1,27 @@
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
//! with a Cache-Control: no-store header.
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::MizanError;
pub struct ApiError(pub MizanError);
impl From<MizanError> for ApiError {
fn from(e: MizanError) -> Self {
Self(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.0.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut resp = (status, Json(self.0.to_json())).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
}

View File

@@ -0,0 +1,153 @@
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
use axum::extract::{Path, Query};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use crate::errors::ApiError;
/// Body for POST /call/. Matches the Python `CallBody` shape.
#[derive(Debug, Deserialize)]
pub struct CallBody {
pub fn_: Option<String>,
#[serde(rename = "fn")]
pub function_name: Option<String>,
#[serde(default)]
pub args: Map<String, Value>,
}
impl CallBody {
fn resolved_name(&self) -> Option<&str> {
self.function_name
.as_deref()
.or(self.fn_.as_deref())
}
}
#[derive(Debug, Serialize)]
pub struct CallResponse {
pub result: Value,
pub invalidate: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge: Option<Vec<Value>>,
}
fn no_store(json: Value) -> Response {
let mut resp = (StatusCode::OK, Json(json)).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
/// POST /call/ — RPC dispatch.
pub async fn function_call(Json(body): Json<CallBody>) -> Result<Response, ApiError> {
let fn_name = body
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
.to_string();
let fn_spec = lookup_function(&fn_name)
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
let unit = ();
let req = RequestHandle::new(&unit);
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
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 merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let payload = CallResponse {
result,
invalidate,
merge: merge_payload,
};
Ok(no_store(serde_json::to_value(&payload).unwrap()))
}
/// GET /ctx/:context_name/ — bundled context fetch.
pub async fn context_fetch(
Path(context_name): Path<String>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> {
if lookup_context(&context_name).is_none() {
return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} not registered"
))));
}
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(&context_name))
.collect();
if members.is_empty() {
return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
}
// Convert query params (all-string values) to the JSON arg map. Numeric
// params get parsed via the per-function input_params primitive table.
let mut bundled = Map::new();
let unit = ();
for fn_spec in &members {
let args = coerce_query_args(*fn_spec, &params);
let req = RequestHandle::new(&unit);
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)))
}
/// 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.
fn coerce_query_args(
fn_spec: &dyn FunctionSpec,
params: &BTreeMap<String, String>,
) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
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::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
}
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
/// readiness-probe consumers see a well-formed response.
pub async fn session_init() -> Response {
let body = serde_json::json!({ "csrfToken": null });
no_store(body)
}

View File

@@ -0,0 +1,37 @@
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
//!
//! Usage:
//! ```ignore
//! use axum::Router;
//! use mizan_axum::router;
//!
//! #[tokio::main]
//! async fn main() {
//! let app = Router::new().nest("/api/mizan", router());
//! 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
mod errors;
mod handlers;
pub use errors::ApiError;
pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse};
use axum::routing::{get, post};
use axum::Router;
/// Build the Mizan router. Mount it under a prefix:
/// `Router::new().nest("/api/mizan", router())`.
pub fn router() -> Router {
Router::new()
.route("/session/", get(handlers::session_init))
.route("/call/", post(handlers::function_call))
.route("/ctx/:context_name/", get(handlers::context_fetch))
}