Three substrate extensions surfaced by the Blazr session port: 1. **App-state threading.** mizan-axum::router() is now generic over a user-supplied state type and threads `Arc<dyn Any + Send + Sync>` into every dispatch via RequestHandle. Handlers downcast to their concrete AppState. The stateless AFI fixture uses `router_stateless()` (matches the prior signature). RequestHandle gains a `from_dyn()` constructor to wrap already-erased trait-object references. 2. **`[T; N]` and `BTreeMap<K, V>` lowering in #[derive(Mizan)].** Fixed arrays emit as `List<T>` (matches Python `tuple[float,...]` → JSON array). String-keyed maps emit as `List<V>` — closest approximation until KDL grows a `dict` shape. Also: vec-element registrations get a per-function scope suffix so two handlers returning `Vec<Same>` don't collide at the static-name layer. 3. **`types_match` for merge: upsert-into-list semantics.** Now matches Python `types_match_for_merge`: direct (T == T), upsert (slot is `Alias(List(T))`, value is T), and list-replace (both sides list). The AFI fixture only exercised the direct path; the Blazr port's `morph_set_value` returning a single `MorphLayer` into a context with `Vec<MorphLayer>` slot is what surfaced the gap. AFI codegen + wire parity stays 12/12 green after these substrate changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
8.6 KiB
Rust
261 lines
8.6 KiB
Rust
//! 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> {
|
|
/// Wrap a typed reference. The most common path — handlers downcast back
|
|
/// to `T` via `downcast::<T>()`.
|
|
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
|
|
Self { inner: req }
|
|
}
|
|
|
|
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters
|
|
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
|
|
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> 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()
|
|
}
|
|
|