diff --git a/backends/mizan-rust-axum/src/handlers.rs b/backends/mizan-rust-axum/src/handlers.rs index 03352fd..c1cbe19 100644 --- a/backends/mizan-rust-axum/src/handlers.rs +++ b/backends/mizan-rust-axum/src/handlers.rs @@ -1,6 +1,6 @@ //! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`. -use axum::extract::{Path, Query}; +use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; @@ -10,10 +10,17 @@ use mizan_core::{ }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::any::Any; use std::collections::BTreeMap; +use std::sync::Arc; use crate::errors::ApiError; +/// Type-erased application state threaded into every `dispatch()` call via +/// `RequestHandle`. User handlers downcast to their concrete state type. +/// `Arc` keeps the clone cheap across per-request handler invocations. +pub type AppStateAny = Arc; + /// Body for POST /call/. Matches the Python `CallBody` shape. #[derive(Debug, Deserialize)] pub struct CallBody { @@ -48,7 +55,10 @@ fn no_store(json: Value) -> Response { } /// POST /call/ — RPC dispatch. -pub async fn function_call(Json(body): Json) -> Result { +pub async fn function_call( + State(app_state): State, + Json(body): Json, +) -> Result { let fn_name = body .resolved_name() .ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))? @@ -57,8 +67,7 @@ pub async fn function_call(Json(body): Json) -> Result = compute_invalidation(fn_spec, &body.args) @@ -82,6 +91,7 @@ pub async fn function_call(Json(body): Json) -> Result, Path(context_name): Path, Query(params): Query>, ) -> Result { @@ -105,10 +115,9 @@ pub async fn context_fetch( // 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, ¶ms); - let req = RequestHandle::new(&unit); + 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); } diff --git a/backends/mizan-rust-axum/src/lib.rs b/backends/mizan-rust-axum/src/lib.rs index a28a36d..df370fd 100644 --- a/backends/mizan-rust-axum/src/lib.rs +++ b/backends/mizan-rust-axum/src/lib.rs @@ -22,16 +22,37 @@ mod errors; mod handlers; pub use errors::ApiError; -pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse}; +pub use handlers::{ + context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse, +}; use axum::routing::{get, post}; use axum::Router; +use std::any::Any; +use std::sync::Arc; -/// Build the Mizan router. Mount it under a prefix: -/// `Router::new().nest("/api/mizan", router())`. -pub fn router() -> Router { +/// Build the Mizan router with user-supplied app state. The state is +/// type-erased into an `Arc` and threaded into every +/// dispatch via `RequestHandle`. Handlers downcast to their concrete state +/// type. +/// +/// Mount under a prefix: +/// `Router::new().nest("/api/mizan", router(my_state))`. +pub fn router(state: S) -> Router +where + S: Any + Send + Sync + 'static, +{ + let state: AppStateAny = Arc::new(state); Router::new() .route("/session/", get(handlers::session_init)) .route("/call/", post(handlers::function_call)) .route("/ctx/:context_name/", get(handlers::context_fetch)) + .with_state(state) +} + +/// 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. +pub fn router_stateless() -> Router { + router(()) } diff --git a/cores/mizan-rust-macros/src/function.rs b/cores/mizan-rust-macros/src/function.rs index 22e30a9..3de43ae 100644 --- a/cores/mizan-rust-macros/src/function.rs +++ b/cores/mizan-rust-macros/src/function.rs @@ -240,7 +240,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { }); // Also register the element type itself by its own name. `TYPE_NAME` // is an associated const, so this is usable in a static initializer. - let elem_static = element_type_static_ident(elem); + // The static ident scopes by the function name so two handlers + // returning `Vec` don't collide; the IrSnapshot's BTreeMap + // dedupes by the entry's `name` at emit time. + let elem_static = + element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case()); type_registrations.push(quote! { #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] #[linkme(crate = ::mizan_core::__priv::linkme)] @@ -481,14 +485,16 @@ fn build_dispatch( } } -fn element_type_static_ident(ty: &Type) -> syn::Ident { - // Derive a unique static-name for the type's registration entry. Uses - // the last path segment's identifier as the discriminator. +fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident { + // Derive a unique static-name for the type's registration entry, + // scoped by the surrounding function so siblings returning the same + // `Vec` don't collide at the static-name layer. The IR-side + // BTreeMap dedupes by TypeEntry.name at emission time. let last = match ty { Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()), _ => None, }; let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case(); - format_ident!("__MIZAN_TYPE_ELEM_{}", suffix) + format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope) } diff --git a/cores/mizan-rust-macros/src/shape.rs b/cores/mizan-rust-macros/src/shape.rs index 8bc9f68..54300ef 100644 --- a/cores/mizan-rust-macros/src/shape.rs +++ b/cores/mizan-rust-macros/src/shape.rs @@ -56,12 +56,68 @@ pub fn type_shape_expr(ty: &Type) -> TokenStream { ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) }; } + if let Some(elem) = unwrap_array(ty) { + // `[T; N]` lowers to `list { T }` on the wire — JSON arrays don't + // carry length, so the IR contract is the same as `Vec`. + let inner_shape = type_shape_expr(&elem); + return quote! { + ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) + }; + } + if let Some(elem) = unwrap_btreemap_value(ty) { + // `BTreeMap` on the wire is a JSON object keyed by `K`'s + // string form. The Mizan IR doesn't model dynamic-keyed maps as a + // distinct shape — closest equivalent is a list of value entries. + let inner_shape = type_shape_expr(&elem); + return quote! { + ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) + }; + } if let Some(p) = primitive_of(ty) { return quote! { ::mizan_core::TypeShape::Primitive(#p) }; } // Fallback: assume a user-defined struct/enum implementing MizanType. - // The Ref name comes from `::type_name()` at runtime. - quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::type_name()) } + // The Ref name comes from `::TYPE_NAME` (associated const). + quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) } +} + +/// If `ty` is `[T; N]`, return `T`. Otherwise None. +pub fn unwrap_array(ty: &Type) -> Option { + if let Type::Array(a) = ty { + Some((*a.elem).clone()) + } else { + None + } +} + +/// If `ty` is `BTreeMap` or `HashMap`, return `V` (the value). +/// String-keyed maps land on the wire as JSON objects; the IR carries the +/// value shape as a list element since KDL doesn't model dynamic-keyed maps +/// distinctly yet. +pub fn unwrap_btreemap_value(ty: &Type) -> Option { + let path = match ty { + Type::Path(TypePath { qself: None, path }) => path, + _ => return None, + }; + let last = path.segments.last()?; + let name = last.ident.to_string(); + if name != "BTreeMap" && name != "HashMap" { + return None; + } + let args = match &last.arguments { + PathArguments::AngleBracketed(a) => a, + _ => return None, + }; + // BTreeMap — second type argument is V. + let mut type_args = args.args.iter().filter_map(|a| { + if let GenericArgument::Type(t) = a { + Some(t.clone()) + } else { + None + } + }); + type_args.next()?; // skip K + type_args.next() } /// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a diff --git a/cores/mizan-rust/src/graph_check.rs b/cores/mizan-rust/src/graph_check.rs index 2ed2e80..d3b0681 100644 --- a/cores/mizan-rust/src/graph_check.rs +++ b/cores/mizan-rust/src/graph_check.rs @@ -16,9 +16,29 @@ pub(crate) fn resolve_type_shape(name: &str) -> Option { None } -/// Structural equality on named types. Two types are merge-compatible iff -/// they have identical shape — matches Python's `types_match_for_merge`. -pub(crate) fn types_match(a: &NamedType, b: &NamedType) -> bool { +/// Merge-compatibility on named types. A mutation return `value` can +/// splice into a context slot `slot` when any of three shapes hold — +/// matches Python's `types_match_for_merge`: +/// * direct: `slot` shape equals `value` shape → replace +/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id +/// * list-replace: `slot` is `list[T]`, `value` is `list[T]` +/// +/// The first argument is the slot (context member's output type); the +/// second is the value (mutation's output type). +pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool { + if named_shapes_equal(slot, value) { + return true; + } + // Upsert: slot is `Alias(List(T))`, value is `T`-shaped. + if let NamedType::Alias(TypeShape::List(elem)) = slot { + if shape_matches_named(elem, value) { + return true; + } + } + false +} + +fn named_shapes_equal(a: &NamedType, b: &NamedType) -> bool { match (a, b) { (NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb), (NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb), @@ -27,6 +47,21 @@ pub(crate) fn types_match(a: &NamedType, b: &NamedType) -> bool { } } +/// True when a `TypeShape` (the slot's list-element) describes the same +/// shape as a `NamedType` (the mutation's full output). +fn shape_matches_named(shape: &TypeShape, named: &NamedType) -> bool { + match shape { + TypeShape::Ref(name) => { + if let Some(referenced) = resolve_type_shape(name) { + named_shapes_equal(&referenced, named) + } else { + false + } + } + _ => false, + } +} + fn fields_match(a: &[StructField], b: &[StructField]) -> bool { if a.len() != b.len() { return false; diff --git a/cores/mizan-rust/src/runtime.rs b/cores/mizan-rust/src/runtime.rs index de084a9..c1a01fd 100644 --- a/cores/mizan-rust/src/runtime.rs +++ b/cores/mizan-rust/src/runtime.rs @@ -17,10 +17,18 @@ pub struct RequestHandle<'a> { } impl<'a> RequestHandle<'a> { + /// Wrap a typed reference. The most common path — handlers downcast back + /// to `T` via `downcast::()`. pub fn new(req: &'a T) -> Self { Self { inner: req } } + /// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters + /// that thread an `Arc` app state in. + pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self { + Self { inner: req } + } + pub fn downcast(&self) -> Option<&'a T> { self.inner.downcast_ref::() } diff --git a/tests/afi/rust_app/src/bin/server.rs b/tests/afi/rust_app/src/bin/server.rs index f64dff0..54c8ad5 100644 --- a/tests/afi/rust_app/src/bin/server.rs +++ b/tests/afi/rust_app/src/bin/server.rs @@ -14,7 +14,7 @@ async fn main() { .and_then(|s| s.parse().ok()) .unwrap_or(8765); - let app = Router::new().nest("/api/mizan", mizan_axum::router()); + let app = Router::new().nest("/api/mizan", mizan_axum::router_stateless()); let bind = format!("127.0.0.1:{port}"); let listener = tokio::net::TcpListener::bind(&bind)