The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:
1. Moat closes. The codegen logic (how `affects` becomes auto-invalidation,
how named contexts collapse onto bundled fetches, how the registry-to-
Provider mapping is shaped) ships compiled instead of as source bytes
in every consumer's node_modules.
2. Pattern F (lines.push append-walls) becomes structurally unauthorable.
The emit substrate is askama templates in templates/<target>/*.j2 —
actual target-language files with {{ ... }} substitution markers,
syntax-highlighted natively, type-checked against the render context
structs at compile time. The Rust emit modules build typed render
contexts and call .render(); no string-builder surface exists.
3. OpenAPI `default`-bearing fields now emit as non-optional in TS / Python
/ Rust — the server always populates them, so consumer code reads them
without nullable checks. Surfaced by Blazr's typecheck on regeneration.
Layout:
frontends/mizan-rust/ — Rust port of @mizan/base; #[cfg(feature="pyo3")]
exposes PyMizanClient for the Python target.
protocol/mizan-codegen/ — codegen binary source + askama templates.
protocol/mizan-generate/ — npm-package shim. bin/launcher.mjs dispatches
to the platform-appropriate prebuilt binary.
Old generator/ JS tree deleted.
tests/rust/ — wire-parity drivers. drive_kernel exercises
raw client.call() / fetch_context(); drive_emitted
exercises the typed crate the codegen emits.
tests/afi/afi_codegen_app.py — codegen entrypoint module (imports + registers).
backends/mizan-fastapi/.../schema.py — adds outputNullable so the Rust
codegen can wrap T | None responses in Option<T>.
Verification:
- 20 mizan-codegen tests green (IR deserialization, byte-equivalent
parity vs JS baseline for stage1/rust/python/react/vue/svelte,
structural test for channels).
- tests/rust/run_wire_parity.py — 12/12 probes green via the Rust binary
driving the FastAPI fixture end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
3.5 KiB
Rust
122 lines
3.5 KiB
Rust
//! Wire error envelope. Mirrors `MizanError` in `frontends/mizan-base/src/index.ts`.
|
|
//!
|
|
//! Two envelope shapes are tolerated:
|
|
//!
|
|
//! - FastAPI: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
|
//! - Django: `{"error": true, "code": "...", "message": "...", "details": ...}`
|
|
//!
|
|
//! When neither shape parses, `code` falls back to `HTTP_<status>` and the
|
|
//! raw response body is the message.
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MizanError {
|
|
pub status: u16,
|
|
pub code: String,
|
|
pub message: String,
|
|
pub details: Option<Value>,
|
|
pub raw_body: String,
|
|
}
|
|
|
|
|
|
impl MizanError {
|
|
pub fn from_response(status: u16, body: String) -> Self {
|
|
let parsed = serde_json::from_str::<Envelope>(&body).ok();
|
|
let (code, message, details) = match parsed {
|
|
Some(Envelope::Fastapi { error }) => (
|
|
error.code.unwrap_or_else(|| format!("HTTP_{status}")),
|
|
error.message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
|
|
error.details,
|
|
),
|
|
Some(Envelope::Django { code, message, details, .. }) => (
|
|
code.unwrap_or_else(|| format!("HTTP_{status}")),
|
|
message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
|
|
details,
|
|
),
|
|
None => (
|
|
format!("HTTP_{status}"),
|
|
format!("Mizan call failed ({status})"),
|
|
None,
|
|
),
|
|
};
|
|
Self { status, code, message, details, raw_body: body }
|
|
}
|
|
|
|
pub fn transport(message: impl Into<String>) -> Self {
|
|
Self {
|
|
status: 0,
|
|
code: "TRANSPORT".to_string(),
|
|
message: message.into(),
|
|
details: None,
|
|
raw_body: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
impl std::fmt::Display for MizanError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "Mizan {} ({}): {}", self.status, self.code, self.message)
|
|
}
|
|
}
|
|
|
|
|
|
impl std::error::Error for MizanError {}
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum Envelope {
|
|
Fastapi { error: NestedError },
|
|
Django {
|
|
// Django form is `{"error": true, "code": ..., "message": ..., "details": ...}`.
|
|
// `error` is a bool sentinel; the actual fields are siblings.
|
|
#[allow(dead_code)]
|
|
error: bool,
|
|
code: Option<String>,
|
|
message: Option<String>,
|
|
details: Option<Value>,
|
|
},
|
|
}
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
struct NestedError {
|
|
code: Option<String>,
|
|
message: Option<String>,
|
|
details: Option<Value>,
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_fastapi_envelope() {
|
|
let body = r#"{"error":{"code":"BAD_REQUEST","message":"oops","details":{"k":1}}}"#;
|
|
let e = MizanError::from_response(400, body.to_string());
|
|
assert_eq!(e.code, "BAD_REQUEST");
|
|
assert_eq!(e.message, "oops");
|
|
assert_eq!(e.details, Some(serde_json::json!({"k": 1})));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_django_envelope() {
|
|
let body = r#"{"error":true,"code":"NOT_FOUND","message":"missing","details":null}"#;
|
|
let e = MizanError::from_response(404, body.to_string());
|
|
assert_eq!(e.code, "NOT_FOUND");
|
|
assert_eq!(e.message, "missing");
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_on_unparseable_body() {
|
|
let e = MizanError::from_response(500, "Internal Server Error".to_string());
|
|
assert_eq!(e.code, "HTTP_500");
|
|
assert!(e.message.contains("500"));
|
|
}
|
|
}
|