A channel's message slots are named from the client, on every backend
The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
backends/mizan-rust-axum/Cargo.lock
generated
17
backends/mizan-rust-axum/Cargo.lock
generated
@@ -264,12 +264,28 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
@@ -300,6 +316,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"linkme",
|
||||
"minijinja",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
|
||||
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
||||
//! with a Cache-Control: no-store header.
|
||||
//! Render a `MizanError` as an axum `Response`: the JSON envelope
|
||||
//! `{"error": {"code": ..., "message": ..., "details": ...}}` under a
|
||||
//! `Cache-Control: no-store` header.
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -15,11 +15,24 @@ impl From<MizanError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each variant's status spelled as an axum constant. Naming the constant
|
||||
/// rather than round-tripping a `u16` leaves no numeric value axum could
|
||||
/// reject, so the mapping is total.
|
||||
fn status_of(err: &MizanError) -> StatusCode {
|
||||
match err {
|
||||
MizanError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
MizanError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
MizanError::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
MizanError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
MizanError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
MizanError::NotImplementedYet(_) => StatusCode::NOT_IMPLEMENTED,
|
||||
MizanError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||
//! HTTP handlers for the Mizan endpoints.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
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,
|
||||
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -21,24 +21,16 @@ use crate::errors::ApiError;
|
||||
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||
/// Body for POST /call/.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallBody {
|
||||
pub fn_: Option<String>,
|
||||
/// `fn` is a Rust keyword, hence the serde rename.
|
||||
#[serde(rename = "fn")]
|
||||
pub function_name: Option<String>,
|
||||
pub function_name: 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,
|
||||
@@ -47,28 +39,37 @@ pub struct CallResponse {
|
||||
pub merge: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
fn no_store(json: Value) -> Response {
|
||||
let mut resp = (StatusCode::OK, Json(json)).into_response();
|
||||
fn no_store<T: Serialize>(body: T) -> Response {
|
||||
let mut resp = (StatusCode::OK, Json(body)).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
}
|
||||
|
||||
/// POST /call/ — RPC dispatch.
|
||||
/// POST /call/ — RPC dispatch. The caller picks the `fn` string, so the
|
||||
/// handler selects the registrations that string names and matches over the
|
||||
/// two shapes that selection has; `[]` is the selection a string nothing
|
||||
/// registered under makes, and it is answered with the NOT_FOUND envelope.
|
||||
pub async fn function_call(
|
||||
State(app_state): State<AppStateAny>,
|
||||
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 registered = function_named(&body.function_name);
|
||||
let fn_spec = match registered.as_slice() {
|
||||
[] => {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"function {:?} not registered",
|
||||
body.function_name
|
||||
))))
|
||||
}
|
||||
[fn_spec, ..] => *fn_spec,
|
||||
};
|
||||
|
||||
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 result = match fn_spec.dispatch(req, Value::Object(body.args.clone())).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => return Err(ApiError(e)),
|
||||
};
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||
.iter()
|
||||
@@ -81,82 +82,86 @@ pub async fn function_call(
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
let payload = CallResponse {
|
||||
Ok(no_store(CallResponse {
|
||||
result,
|
||||
invalidate,
|
||||
merge: merge_payload,
|
||||
};
|
||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||
/// GET /ctx/:context_name/ — bundled context fetch. The caller picks the
|
||||
/// path segment, so `[]` is the selection a segment no registered function
|
||||
/// declares membership in makes, answered with the NOT_FOUND envelope.
|
||||
pub async fn context_fetch(
|
||||
State(app_state): State<AppStateAny>,
|
||||
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 = context_members(&context_name);
|
||||
let selected = match members.as_slice() {
|
||||
[] => {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} names no registered functions"
|
||||
))))
|
||||
}
|
||||
selected => selected,
|
||||
};
|
||||
|
||||
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();
|
||||
for fn_spec in &members {
|
||||
for fn_spec in selected {
|
||||
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)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||
Ok(result) => {
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
Err(e) => return Err(ApiError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
/// A query string carries every value as text, so each declared input param
|
||||
/// reads its raw text as the primitive it declares. Text spelling something
|
||||
/// else stays the text it already is: `dispatch` validates every arg against
|
||||
/// the declared shape and is the one step that words the VALIDATION_FAILED
|
||||
/// answer, so re-wording it here would give one request two spellings of the
|
||||
/// same complaint.
|
||||
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())),
|
||||
for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) {
|
||||
let as_text = Value::from(raw.clone());
|
||||
let coerced = match ip.primitive {
|
||||
Primitive::String => as_text,
|
||||
Primitive::Boolean => match raw.as_str() {
|
||||
"true" => Value::Bool(true),
|
||||
"false" => Value::Bool(false),
|
||||
_spells_neither => as_text,
|
||||
},
|
||||
Primitive::Integer => match raw.parse::<i64>() {
|
||||
Ok(integer) => Value::from(integer),
|
||||
Err(_spells_no_integer) => as_text,
|
||||
},
|
||||
Primitive::Number => match raw.parse::<f64>() {
|
||||
Ok(float) => match Number::from_f64(float) {
|
||||
Some(number) => Value::Number(number),
|
||||
None => as_text,
|
||||
},
|
||||
Err(_spells_no_number) => as_text,
|
||||
},
|
||||
};
|
||||
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(), coerced);
|
||||
}
|
||||
}
|
||||
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.
|
||||
/// GET /session/ — emits `{"csrfToken": null}`.
|
||||
pub async fn session_init() -> Response {
|
||||
let body = serde_json::json!({ "csrfToken": null });
|
||||
no_store(body)
|
||||
no_store(serde_json::json!({ "csrfToken": null }))
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||
//! Exposed endpoints:
|
||||
//! * `GET /session/` — session-init probe
|
||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||
//! * `GET /ctx/:name/` — bundled context fetch
|
||||
|
||||
@@ -51,8 +51,7 @@ where
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// dispatch path receives a unit-typed handle.
|
||||
pub fn router_stateless() -> Router {
|
||||
router(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user