mizan-axum + macros: state threading, array/map lowering, merge shape semantics
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
//! 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::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
@@ -10,10 +10,17 @@ use mizan_core::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
use std::any::Any;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
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<dyn Any + Send + Sync>;
|
||||||
|
|
||||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CallBody {
|
pub struct CallBody {
|
||||||
@@ -48,7 +55,10 @@ fn no_store(json: Value) -> Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /call/ — RPC dispatch.
|
/// POST /call/ — RPC dispatch.
|
||||||
pub async fn function_call(Json(body): Json<CallBody>) -> Result<Response, ApiError> {
|
pub async fn function_call(
|
||||||
|
State(app_state): State<AppStateAny>,
|
||||||
|
Json(body): Json<CallBody>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
let fn_name = body
|
let fn_name = body
|
||||||
.resolved_name()
|
.resolved_name()
|
||||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||||
@@ -57,8 +67,7 @@ pub async fn function_call(Json(body): Json<CallBody>) -> Result<Response, ApiEr
|
|||||||
let fn_spec = lookup_function(&fn_name)
|
let fn_spec = lookup_function(&fn_name)
|
||||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||||
|
|
||||||
let unit = ();
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
let req = RequestHandle::new(&unit);
|
|
||||||
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
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)
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||||
@@ -82,6 +91,7 @@ pub async fn function_call(Json(body): Json<CallBody>) -> Result<Response, ApiEr
|
|||||||
|
|
||||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||||
pub async fn context_fetch(
|
pub async fn context_fetch(
|
||||||
|
State(app_state): State<AppStateAny>,
|
||||||
Path(context_name): Path<String>,
|
Path(context_name): Path<String>,
|
||||||
Query(params): Query<BTreeMap<String, String>>,
|
Query(params): Query<BTreeMap<String, String>>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
@@ -105,10 +115,9 @@ pub async fn context_fetch(
|
|||||||
// Convert query params (all-string values) to the JSON arg map. Numeric
|
// Convert query params (all-string values) to the JSON arg map. Numeric
|
||||||
// params get parsed via the per-function input_params primitive table.
|
// params get parsed via the per-function input_params primitive table.
|
||||||
let mut bundled = Map::new();
|
let mut bundled = Map::new();
|
||||||
let unit = ();
|
|
||||||
for fn_spec in &members {
|
for fn_spec in &members {
|
||||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
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)?;
|
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,37 @@ mod errors;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
|
|
||||||
pub use errors::ApiError;
|
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::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use std::any::Any;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Build the Mizan router. Mount it under a prefix:
|
/// Build the Mizan router with user-supplied app state. The state is
|
||||||
/// `Router::new().nest("/api/mizan", router())`.
|
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
|
||||||
pub fn router() -> Router {
|
/// 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<S>(state: S) -> Router
|
||||||
|
where
|
||||||
|
S: Any + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let state: AppStateAny = Arc::new(state);
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/session/", get(handlers::session_init))
|
.route("/session/", get(handlers::session_init))
|
||||||
.route("/call/", post(handlers::function_call))
|
.route("/call/", post(handlers::function_call))
|
||||||
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
.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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
});
|
});
|
||||||
// Also register the element type itself by its own name. `TYPE_NAME`
|
// Also register the element type itself by its own name. `TYPE_NAME`
|
||||||
// is an associated const, so this is usable in a static initializer.
|
// 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<Same>` 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! {
|
type_registrations.push(quote! {
|
||||||
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
||||||
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||||
@@ -481,14 +485,16 @@ fn build_dispatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn element_type_static_ident(ty: &Type) -> syn::Ident {
|
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident {
|
||||||
// Derive a unique static-name for the type's registration entry. Uses
|
// Derive a unique static-name for the type's registration entry,
|
||||||
// the last path segment's identifier as the discriminator.
|
// scoped by the surrounding function so siblings returning the same
|
||||||
|
// `Vec<T>` don't collide at the static-name layer. The IR-side
|
||||||
|
// BTreeMap dedupes by TypeEntry.name at emission time.
|
||||||
let last = match ty {
|
let last = match ty {
|
||||||
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
|
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case();
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,68 @@ pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
|||||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
|
::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<T>`.
|
||||||
|
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<K, V>` 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) {
|
if let Some(p) = primitive_of(ty) {
|
||||||
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
||||||
}
|
}
|
||||||
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
||||||
// The Ref name comes from `<T as MizanType>::type_name()` at runtime.
|
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
|
||||||
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::type_name()) }
|
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<Type> {
|
||||||
|
if let Type::Array(a) = ty {
|
||||||
|
Some((*a.elem).clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `ty` is `BTreeMap<K, V>` or `HashMap<K, V>`, 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<Type> {
|
||||||
|
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<K, V> — 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
|
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
||||||
|
|||||||
@@ -16,9 +16,29 @@ pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Structural equality on named types. Two types are merge-compatible iff
|
/// Merge-compatibility on named types. A mutation return `value` can
|
||||||
/// they have identical shape — matches Python's `types_match_for_merge`.
|
/// splice into a context slot `slot` when any of three shapes hold —
|
||||||
pub(crate) fn types_match(a: &NamedType, b: &NamedType) -> bool {
|
/// 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) {
|
match (a, b) {
|
||||||
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
|
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
|
||||||
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb),
|
(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 {
|
fn fields_match(a: &[StructField], b: &[StructField]) -> bool {
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ pub struct RequestHandle<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> RequestHandle<'a> {
|
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 {
|
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
|
||||||
Self { inner: req }
|
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> {
|
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
|
||||||
self.inner.downcast_ref::<T>()
|
self.inner.downcast_ref::<T>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async fn main() {
|
|||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(8765);
|
.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 bind = format!("127.0.0.1:{port}");
|
||||||
let listener = tokio::net::TcpListener::bind(&bind)
|
let listener = tokio::net::TcpListener::bind(&bind)
|
||||||
|
|||||||
Reference in New Issue
Block a user