Restore approved state (tree of 4effcc7 "Added LICENSE")
Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
//! Forms endpoints — schema / validate / submit over the registered form
|
||||
//! functions. The Forms capability is AFI-common; the binding is
|
||||
//! per-framework (Django Forms on Django, a `#[mizan(form_name=…,
|
||||
//! form_role=…)]` function here). A form is the set of registered functions
|
||||
//! sharing a `form_name`, each carrying one `form_role`; each role gets its
|
||||
//! own route that dispatches the function whose `(form_name, form_role)`
|
||||
//! matches.
|
||||
//!
|
||||
//! POST /form/:form_name/schema/
|
||||
//! POST /form/:form_name/validate/
|
||||
//! POST /form/:form_name/submit/
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::{FunctionSpec, MizanError, RequestHandle, FUNCTIONS};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::MizanState;
|
||||
|
||||
/// Find the registered form function with this `(form_name, form_role)`.
|
||||
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
||||
}
|
||||
|
||||
/// Dispatch the form function for `(form_name, role)`. Shared by the three
|
||||
/// role routes below.
|
||||
async fn dispatch_role(
|
||||
state: &MizanState,
|
||||
form_name: &str,
|
||||
role: &str,
|
||||
args: Value,
|
||||
) -> Result<Response, ApiError> {
|
||||
let fn_spec = lookup_form_fn(form_name, role).ok_or_else(|| {
|
||||
ApiError(MizanError::NotFound(format!(
|
||||
"no form {form_name:?} with role {role:?}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let args_value = match args {
|
||||
Value::Object(_) | Value::Null => args,
|
||||
other => Value::Object({
|
||||
let mut m = Map::new();
|
||||
m.insert("data".into(), other);
|
||||
m
|
||||
}),
|
||||
};
|
||||
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, args_value).await.map_err(ApiError)?;
|
||||
|
||||
let mut resp = (StatusCode::OK, Json(result)).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// POST /form/:form_name/schema/ — the form's field/schema descriptor.
|
||||
pub async fn form_schema(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(form_name): Path<String>,
|
||||
Json(args): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
dispatch_role(&state, &form_name, "schema", args).await
|
||||
}
|
||||
|
||||
/// POST /form/:form_name/validate/ — validate submitted data without committing.
|
||||
pub async fn form_validate(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(form_name): Path<String>,
|
||||
Json(args): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
dispatch_role(&state, &form_name, "validate", args).await
|
||||
}
|
||||
|
||||
/// POST /form/:form_name/submit/ — validate and commit the form.
|
||||
pub async fn form_submit(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(form_name): Path<String>,
|
||||
Json(args): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
dispatch_role(&state, &form_name, "submit", args).await
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`
|
||||
//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic.
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::{
|
||||
authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header,
|
||||
lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity,
|
||||
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
};
|
||||
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;
|
||||
use crate::state::MizanState;
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -30,7 +33,9 @@ pub struct CallBody {
|
||||
|
||||
impl CallBody {
|
||||
fn resolved_name(&self) -> Option<&str> {
|
||||
self.function_name.as_deref().or(self.fn_.as_deref())
|
||||
self.function_name
|
||||
.as_deref()
|
||||
.or(self.fn_.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,210 +54,44 @@ fn no_store(json: Value) -> Response {
|
||||
resp
|
||||
}
|
||||
|
||||
/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer`
|
||||
/// through the shared `authenticate`. A present-but-invalid token rejects with
|
||||
/// 401 (the `INVALID` contract); no token → anonymous (`None`).
|
||||
pub(crate) fn identity_from_headers(
|
||||
headers: &HeaderMap,
|
||||
state: &MizanState,
|
||||
) -> Result<Option<Identity>, ApiError> {
|
||||
let mwt = headers
|
||||
.get("X-Mizan-Token")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let bearer = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) {
|
||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
||||
AuthOutcome::Anonymous => Ok(None),
|
||||
AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized(
|
||||
"Invalid or expired token".into(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce a function's `@client(auth=...)` against the resolved identity.
|
||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> {
|
||||
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
||||
enforce_auth(identity, &req).map_err(ApiError)
|
||||
}
|
||||
|
||||
/// Reject a client call into a `private` function (no RPC endpoint).
|
||||
fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> {
|
||||
if fn_spec.private() {
|
||||
return Err(ApiError(MizanError::Forbidden(
|
||||
"Function is not client-callable".into(),
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uid_str(identity: Option<&Identity>) -> Option<String> {
|
||||
identity.map(|i| i.user_id.clone())
|
||||
}
|
||||
|
||||
/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body
|
||||
/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the
|
||||
/// invalidated contexts.
|
||||
/// POST /call/ — RPC dispatch.
|
||||
pub async fn function_call(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Body,
|
||||
State(app_state): State<AppStateAny>,
|
||||
Json(body): Json<CallBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let identity = identity_from_headers(&headers, &state)?;
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
let fn_name = body
|
||||
.resolved_name()
|
||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||
.to_string();
|
||||
|
||||
let (fn_name, args) = if content_type.starts_with("multipart/form-data") {
|
||||
parse_multipart(&content_type, body).await?
|
||||
} else {
|
||||
parse_json_call(body).await?
|
||||
};
|
||||
let fn_spec = lookup_function(&fn_name)
|
||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||
|
||||
let fn_spec = lookup_function(&fn_name).ok_or_else(|| {
|
||||
ApiError(MizanError::NotFound(format!(
|
||||
"function {fn_name:?} not registered"
|
||||
)))
|
||||
})?;
|
||||
reject_if_private(fn_spec)?;
|
||||
guard(fn_spec, identity.as_ref())?;
|
||||
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 req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args.clone()))
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let targets = compute_invalidation(fn_spec, &args);
|
||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &body.args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
// Purge the origin cache for everything this mutation invalidated.
|
||||
if !targets.is_empty() {
|
||||
state.cache.purge(&targets, uid_str(identity.as_ref()).as_deref());
|
||||
}
|
||||
|
||||
let payload = CallResponse {
|
||||
result,
|
||||
invalidate,
|
||||
merge: merge_payload,
|
||||
};
|
||||
let mut resp = no_store(serde_json::to_value(&payload).unwrap());
|
||||
if !targets.is_empty() {
|
||||
let header_val = format_invalidate_header(&targets);
|
||||
if let Ok(hv) = HeaderValue::from_str(&header_val) {
|
||||
resp.headers_mut().insert("X-Mizan-Invalidate", hv);
|
||||
}
|
||||
}
|
||||
Ok(resp)
|
||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
||||
}
|
||||
|
||||
async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map<String, Value>), ApiError> {
|
||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?;
|
||||
let call: CallBody = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?;
|
||||
let fn_name = call
|
||||
.resolved_name()
|
||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||
.to_string();
|
||||
Ok((fn_name, call.args))
|
||||
}
|
||||
|
||||
/// Parse a multipart `/call/` request: a JSON `args` field plus file parts.
|
||||
/// Each file part binds into the matching Upload-typed input field as a
|
||||
/// base64-carrying value the `mizan_core::Upload` field deserializes.
|
||||
async fn parse_multipart(
|
||||
content_type: &str,
|
||||
body: axum::body::Body,
|
||||
) -> Result<(String, Map<String, Value>), ApiError> {
|
||||
let boundary = multer::parse_boundary(content_type)
|
||||
.map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?;
|
||||
let stream = body.into_data_stream();
|
||||
let mut mp = multer::Multipart::new(stream, boundary);
|
||||
|
||||
let mut fn_name: Option<String> = None;
|
||||
let mut args: Map<String, Value> = Map::new();
|
||||
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||
|
||||
while let Some(field) = mp
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
let filename = field.file_name().map(|s| s.to_string());
|
||||
let part_content_type = field.content_type().map(|s| s.to_string());
|
||||
|
||||
if filename.is_some() {
|
||||
// A file part → the JSON shape `mizan_core::Upload` deserializes
|
||||
// (filename, content_type, base64 bytes).
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?;
|
||||
files.entry(name).or_default().push(uploaded_file_json(
|
||||
filename,
|
||||
part_content_type,
|
||||
&data,
|
||||
));
|
||||
} else {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?;
|
||||
if name == "fn" {
|
||||
fn_name = Some(text);
|
||||
} else if name == "args" {
|
||||
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
|
||||
ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into()))
|
||||
})?;
|
||||
if let Value::Object(m) = parsed {
|
||||
args = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind file parts into args by field name (single vs list).
|
||||
for (field_name, parts) in files {
|
||||
if parts.len() == 1 {
|
||||
args.insert(field_name, parts.into_iter().next().unwrap());
|
||||
} else {
|
||||
args.insert(field_name, Value::Array(parts));
|
||||
}
|
||||
}
|
||||
|
||||
let fn_name =
|
||||
fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?;
|
||||
Ok((fn_name, args))
|
||||
}
|
||||
|
||||
/// Encode a received file part as the JSON shape an `Upload` field expects.
|
||||
fn uploaded_file_json(filename: Option<String>, content_type: Option<String>, data: &[u8]) -> Value {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
serde_json::json!({
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"data_b64": STANDARD.encode(data),
|
||||
"size": data.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// GET /ctx/:context_name/ — bundled context fetch, origin-cached.
|
||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||
pub async fn context_fetch(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
headers: HeaderMap,
|
||||
State(app_state): State<AppStateAny>,
|
||||
Path(context_name): Path<String>,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
@@ -262,8 +101,6 @@ pub async fn context_fetch(
|
||||
))));
|
||||
}
|
||||
|
||||
let identity = identity_from_headers(&headers, &state)?;
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -275,130 +112,22 @@ pub async fn context_fetch(
|
||||
))));
|
||||
}
|
||||
|
||||
// Origin cache: the canonical-JSON bundle body is keyed by (context,
|
||||
// params, user, rev). The Rust IR carries no per-fn rev yet → rev 0.
|
||||
let cache_params: BTreeMap<String, Value> = params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect();
|
||||
let uid = uid_str(identity.as_ref());
|
||||
|
||||
if let Some(cached) = state
|
||||
.cache
|
||||
.get(&context_name, &cache_params, uid.as_deref(), 0)
|
||||
{
|
||||
return Ok(cached_response(cached, "HIT"));
|
||||
}
|
||||
|
||||
// Enforce auth per member (the bundle is only as open as its strictest fn).
|
||||
// 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 {
|
||||
guard(*fn_spec, identity.as_ref())?;
|
||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
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);
|
||||
}
|
||||
|
||||
let body = canonical_bytes(&Value::Object(bundled));
|
||||
let status = if state.cache.enabled() {
|
||||
state
|
||||
.cache
|
||||
.put(&context_name, &cache_params, body.clone(), uid.as_deref(), 0);
|
||||
"MISS"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Ok(cached_response(body, status))
|
||||
Ok(no_store(Value::Object(bundled)))
|
||||
}
|
||||
|
||||
/// Canonical JSON bytes for the cache body — sorted keys, matching Python's
|
||||
/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible.
|
||||
fn canonical_bytes(v: &Value) -> Vec<u8> {
|
||||
fn sort(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
let mut keys: Vec<&String> = m.keys().collect();
|
||||
keys.sort();
|
||||
let mut out = Map::new();
|
||||
for k in keys {
|
||||
out.insert(k.clone(), sort(&m[k]));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(a) => Value::Array(a.iter().map(sort).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
// Python's default separators add a space after ':' and ','. Match that so
|
||||
// a Rust-written cache body and a Python-written one are byte-equal.
|
||||
let sorted = sort(v);
|
||||
python_json(&sorted)
|
||||
}
|
||||
|
||||
/// Serialize like Python `json.dumps(sort_keys=True)` default separators
|
||||
/// (`", "` and `": "`).
|
||||
fn python_json(v: &Value) -> Vec<u8> {
|
||||
let compact = serde_json::to_string(v).unwrap();
|
||||
// serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults.
|
||||
// This is a structural transform on the already-sorted value, so the
|
||||
// bytes match `json.dumps` for the JSON value space Mizan returns.
|
||||
let spaced = respace(&compact);
|
||||
spaced.into_bytes()
|
||||
}
|
||||
|
||||
/// Insert the spaces Python's default `json.dumps` uses after structural
|
||||
/// `,`/`:` — but only outside string literals.
|
||||
fn respace(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + s.len() / 8);
|
||||
let mut in_str = false;
|
||||
let mut escaped = false;
|
||||
for c in s.chars() {
|
||||
if in_str {
|
||||
out.push(c);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => {
|
||||
in_str = true;
|
||||
out.push(c);
|
||||
}
|
||||
',' => out.push_str(", "),
|
||||
':' => out.push_str(": "),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cached_response(body: Vec<u8>, cache_status: &str) -> Response {
|
||||
let mut resp = (StatusCode::OK, body).into_response();
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
if !cache_status.is_empty() {
|
||||
if let Ok(v) = HeaderValue::from_str(cache_status) {
|
||||
h.insert("X-Mizan-Cache", v);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Coerce string-valued query params into typed JSON via the function's
|
||||
/// declared input_params.
|
||||
/// 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.
|
||||
fn coerce_query_args(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
params: &BTreeMap<String, String>,
|
||||
@@ -408,88 +137,26 @@ fn coerce_query_args(
|
||||
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::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())),
|
||||
};
|
||||
out.insert(ip.name.into(), parsed.unwrap_or_else(|| Value::from(raw.clone())));
|
||||
if let Some(v) = parsed {
|
||||
out.insert(ip.name.into(), v);
|
||||
} else {
|
||||
out.insert(ip.name.into(), Value::from(raw.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
|
||||
/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session
|
||||
/// mechanism; the endpoint here returns a null token and serves as the
|
||||
/// readiness probe the wire-parity harness uses.
|
||||
/// 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.
|
||||
pub async fn session_init() -> Response {
|
||||
no_store(serde_json::json!({ "csrfToken": null }))
|
||||
}
|
||||
|
||||
/// GET /manifest/ — emit the edge manifest (contexts + render_strategy +
|
||||
/// mutations) the way `export_edge_manifest` does, so an HTTP deploy can fetch
|
||||
/// it. Rides the shared `mizan_core::generate_edge_manifest`.
|
||||
pub async fn edge_manifest(State(state): State<Arc<MizanState>>) -> Response {
|
||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
||||
no_store(manifest)
|
||||
}
|
||||
|
||||
/// GET /psr/:context_name/ — the PSR descriptor for one context: its
|
||||
/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or
|
||||
/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge
|
||||
/// re-renders. This is the adapter telling Edge *how* to cache each context —
|
||||
/// the PSR half of the manifest, addressable per-context.
|
||||
pub async fn psr_descriptor(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(context_name): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
||||
let ctx = manifest
|
||||
.get("contexts")
|
||||
.and_then(|c| c.get(&context_name))
|
||||
.ok_or_else(|| {
|
||||
ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not in manifest"
|
||||
)))
|
||||
})?;
|
||||
let render_strategy = ctx
|
||||
.get("render_strategy")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let page_routes = ctx
|
||||
.get("page_routes")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::Array(Vec::new()));
|
||||
Ok(no_store(serde_json::json!({
|
||||
"context": context_name,
|
||||
"render_strategy": render_strategy,
|
||||
"page_routes": page_routes,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's
|
||||
/// output, derived from the registered type graph by `mizan_core::shapes`.
|
||||
pub async fn shape_projection(Path(fn_name): Path<String>) -> Result<Response, ApiError> {
|
||||
let proj = shapes::project_function_output(&fn_name).ok_or_else(|| {
|
||||
ApiError(MizanError::NotFound(format!(
|
||||
"no shape projection for {fn_name:?}"
|
||||
)))
|
||||
})?;
|
||||
Ok(no_store(projection_to_json(&proj)))
|
||||
}
|
||||
|
||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
||||
let mut fields = Vec::new();
|
||||
for f in &proj.fields {
|
||||
match f {
|
||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
||||
shapes::ShapeField::Nested(n, sub) => {
|
||||
fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "type": proj.type_name, "fields": fields })
|
||||
let body = serde_json::json!({ "csrfToken": null });
|
||||
no_store(body)
|
||||
}
|
||||
|
||||
@@ -1,80 +1,58 @@
|
||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry,
|
||||
//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest).
|
||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```ignore
|
||||
//! use axum::Router;
|
||||
//! use mizan_axum::{router, MizanState};
|
||||
//! use mizan_axum::router;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let state = MizanState::builder()
|
||||
//! .app_state(MyState { /* ... */ })
|
||||
//! .build();
|
||||
//! let app = Router::new().nest("/api/mizan", router(state));
|
||||
//! let app = Router::new().nest("/api/mizan", router());
|
||||
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
||||
//! axum::serve(listener, app).await.unwrap();
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||
//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate
|
||||
//! * `GET /ctx/:name/` — bundled context fetch (origin-cached)
|
||||
//! * `GET /ws/` — WebSocket RPC transport (`websocket=` fns)
|
||||
//! * `GET /manifest/` — edge manifest (contexts/render_strategy/mutations)
|
||||
//! * `GET /psr/:context/` — per-context PSR descriptor (render_strategy)
|
||||
//! * `GET /shape/:fn/` — typed query projection (Shapes)
|
||||
//! * `POST /ssr/` — server-side render via the Bun worker
|
||||
//! * `POST /form/:name/{schema,validate,submit}/` — forms binding
|
||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||
//! * `GET /ctx/:name/` — bundled context fetch
|
||||
|
||||
mod errors;
|
||||
mod forms;
|
||||
mod handlers;
|
||||
mod ssr;
|
||||
mod state;
|
||||
mod ws;
|
||||
|
||||
pub use errors::ApiError;
|
||||
pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse};
|
||||
pub use ssr::{ssr_render, SsrRequest};
|
||||
pub use state::{AppStateAny, MizanState, MizanStateBuilder};
|
||||
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 with a fully-configured [`MizanState`] (app state +
|
||||
/// auth + cache + optional SSR worker). Mount under a prefix:
|
||||
/// `Router::new().nest("/api/mizan", router(state))`.
|
||||
pub fn router(state: Arc<MizanState>) -> Router {
|
||||
/// Build the Mizan router with user-supplied app state. The state is
|
||||
/// type-erased into an `Arc<dyn Any + Send + Sync>` 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<S>(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))
|
||||
.route("/ws/", get(ws::ws_handler))
|
||||
.route("/manifest/", get(handlers::edge_manifest))
|
||||
.route("/psr/:context_name/", get(handlers::psr_descriptor))
|
||||
.route("/shape/:fn_name/", get(handlers::shape_projection))
|
||||
.route("/ssr/", post(ssr::ssr_render))
|
||||
.route("/form/:form_name/schema/", post(forms::form_schema))
|
||||
.route("/form/:form_name/validate/", post(forms::form_validate))
|
||||
.route("/form/:form_name/submit/", post(forms::form_submit))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Router variant for the common case of just an app state, no auth/cache.
|
||||
pub fn router_with_state<S>(app_state: S) -> Router
|
||||
where
|
||||
S: Any + Send + Sync + 'static,
|
||||
{
|
||||
router(MizanState::builder().app_state(app_state).build())
|
||||
}
|
||||
|
||||
/// 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 stateless
|
||||
/// test apps.
|
||||
/// 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(MizanState::builder().build())
|
||||
router(())
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
//! SSR endpoint — drive the Bun renderer through the shared `mizan_core`
|
||||
//! `SsrBridge` (same newline-delimited JSON-RPC protocol as the Python
|
||||
//! `SSRBridge`). The bridge spawns on first render and stays alive.
|
||||
//!
|
||||
//! POST /ssr/ { "file": "/abs/Component.tsx", "props": {...} } → { "html": "..." }
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
use mizan_core::MizanError;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::MizanState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SsrRequest {
|
||||
pub file: String,
|
||||
#[serde(default)]
|
||||
pub props: Value,
|
||||
}
|
||||
|
||||
/// POST /ssr/ — render a component file via the Bun SSR worker.
|
||||
pub async fn ssr_render(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Json(req): Json<SsrRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let bridge = state.ssr().ok_or_else(|| {
|
||||
ApiError(MizanError::NotImplementedYet(
|
||||
"no SSR worker configured (set MizanState::builder().ssr_worker(...))".into(),
|
||||
))
|
||||
})?;
|
||||
let props = if req.props.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
req.props
|
||||
};
|
||||
let html = bridge
|
||||
.render(&req.file, props)
|
||||
.map_err(|e| ApiError(MizanError::InternalError(e.to_string())))?;
|
||||
|
||||
let mut resp = axum::response::IntoResponse::into_response(Json(json!({ "html": html })));
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Router state — the Mizan config (auth + origin cache) threaded alongside
|
||||
//! the user's type-erased app state.
|
||||
//!
|
||||
//! `app_state` is the consumer's own state, type-erased into `Arc<dyn Any>`
|
||||
//! and handed to every `dispatch()` via `RequestHandle` (handlers downcast to
|
||||
//! their concrete type — unchanged from the pre-AFI router). `auth` and
|
||||
//! `cache` are the AFI-common config the handlers read for enforcement and
|
||||
//! origin caching; an `SsrBridge` is created lazily on the first SSR render.
|
||||
|
||||
use mizan_core::{AuthConfig, CacheOrchestrator, SsrBridge};
|
||||
use std::any::Any;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// The full state every Mizan handler receives. Built via [`MizanState::builder`].
|
||||
pub struct MizanState {
|
||||
/// The consumer's app state, threaded into dispatch via `RequestHandle`.
|
||||
pub app_state: AppStateAny,
|
||||
/// JWT/MWT auth config (token → identity resolution + enforcement).
|
||||
pub auth: AuthConfig,
|
||||
/// Origin-side HMAC cache orchestrator (disabled by default).
|
||||
pub cache: CacheOrchestrator,
|
||||
/// Mizan API mount point, used by the edge-manifest endpoint.
|
||||
pub base_url: String,
|
||||
/// Lazily-spawned SSR bridge; configured via the builder's `ssr_worker`.
|
||||
pub(crate) ssr_worker: Option<String>,
|
||||
pub(crate) ssr_bridge: OnceLock<SsrBridge>,
|
||||
}
|
||||
|
||||
impl MizanState {
|
||||
pub fn builder() -> MizanStateBuilder {
|
||||
MizanStateBuilder::default()
|
||||
}
|
||||
|
||||
/// The SSR bridge, spawned on first use. `None` if no worker was set.
|
||||
pub fn ssr(&self) -> Option<&SsrBridge> {
|
||||
let worker = self.ssr_worker.as_ref()?;
|
||||
Some(
|
||||
self.ssr_bridge
|
||||
.get_or_init(|| SsrBridge::bun(worker.clone())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`MizanState`]. Defaults: unit app state, no auth, cache
|
||||
/// disabled, `/api/mizan` base URL, no SSR worker.
|
||||
pub struct MizanStateBuilder {
|
||||
app_state: AppStateAny,
|
||||
auth: AuthConfig,
|
||||
cache: CacheOrchestrator,
|
||||
base_url: String,
|
||||
ssr_worker: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MizanStateBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
app_state: Arc::new(()),
|
||||
auth: AuthConfig::new(),
|
||||
cache: CacheOrchestrator::disabled(),
|
||||
base_url: "/api/mizan".to_string(),
|
||||
ssr_worker: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MizanStateBuilder {
|
||||
/// Set the consumer's app state (threaded into dispatch).
|
||||
pub fn app_state<S: Any + Send + Sync + 'static>(mut self, state: S) -> Self {
|
||||
self.app_state = Arc::new(state);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn auth(mut self, auth: AuthConfig) -> Self {
|
||||
self.auth = auth;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cache(mut self, cache: CacheOrchestrator) -> Self {
|
||||
self.cache = cache;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the Bun SSR worker path; the bridge spawns on first render.
|
||||
pub fn ssr_worker(mut self, worker_path: impl Into<String>) -> Self {
|
||||
self.ssr_worker = Some(worker_path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Arc<MizanState> {
|
||||
Arc::new(MizanState {
|
||||
app_state: self.app_state,
|
||||
auth: self.auth,
|
||||
cache: self.cache,
|
||||
base_url: self.base_url,
|
||||
ssr_worker: self.ssr_worker,
|
||||
ssr_bridge: OnceLock::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! WebSocket RPC transport. `@client(websocket=true)` functions declare
|
||||
//! `Transport::Websocket` in the IR; this routes a real Axum WebSocket handler
|
||||
//! that dispatches call/fetch frames through the same `mizan-core` registry
|
||||
//! the HTTP path uses. A call frame naming a non-websocket function is
|
||||
//! rejected, so the transport boundary the IR declares is enforced.
|
||||
//!
|
||||
//! Frame protocol (text JSON), mirroring the HTTP call/ctx shapes:
|
||||
//! → {"id": 1, "op": "call", "fn": "name", "args": {...}}
|
||||
//! → {"id": 2, "op": "fetch", "context": "c", "params": {...}}
|
||||
//! ← {"id": 1, "result": ..., "invalidate": [...], "merge"?: [...]}
|
||||
//! ← {"id": 2, "data": {fnName: result, ...}}
|
||||
//! ← {"id": N, "error": {"code": ..., "message": ...}}
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use futures_util::StreamExt;
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_context, lookup_function, AuthRequirement,
|
||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, Transport, FUNCTIONS,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state::MizanState;
|
||||
|
||||
/// GET /ws/ — upgrade to a Mizan WebSocket RPC connection.
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<MizanState>>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: Arc<MizanState>) {
|
||||
while let Some(Ok(msg)) = socket.next().await {
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
Message::Ping(_) | Message::Pong(_) | Message::Binary(_) => continue,
|
||||
};
|
||||
let reply = handle_frame(&state, &text).await;
|
||||
if socket
|
||||
.send(Message::Text(reply.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_frame(state: &MizanState, text: &str) -> Value {
|
||||
let frame: Value = match serde_json::from_str(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return err_frame(Value::Null, &MizanError::BadRequest(format!("bad frame: {e}"))),
|
||||
};
|
||||
let id = frame.get("id").cloned().unwrap_or(Value::Null);
|
||||
let op = frame.get("op").and_then(|o| o.as_str()).unwrap_or("call");
|
||||
|
||||
match op {
|
||||
"call" => match dispatch_ws_call(state, &frame).await {
|
||||
Ok(v) => with_id(id, v),
|
||||
Err(e) => err_frame(id, &e),
|
||||
},
|
||||
"fetch" => match dispatch_ws_fetch(state, &frame).await {
|
||||
Ok(v) => with_id(id, json!({ "data": v })),
|
||||
Err(e) => err_frame(id, &e),
|
||||
},
|
||||
other => err_frame(id, &MizanError::BadRequest(format!("unknown op {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_ws_call(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
||||
let fn_name = frame
|
||||
.get("fn")
|
||||
.and_then(|f| f.as_str())
|
||||
.ok_or_else(|| MizanError::BadRequest("missing `fn`".into()))?;
|
||||
let args = frame
|
||||
.get("args")
|
||||
.and_then(|a| a.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let fn_spec =
|
||||
lookup_function(fn_name).ok_or_else(|| MizanError::NotFound(format!("{fn_name:?}")))?;
|
||||
if fn_spec.private() {
|
||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||
}
|
||||
// The WS transport only carries functions that opted into it.
|
||||
if !matches!(fn_spec.transport(), Transport::Websocket | Transport::Both) {
|
||||
return Err(MizanError::BadRequest(format!(
|
||||
"function {fn_name:?} is not exposed over the WebSocket transport"
|
||||
)));
|
||||
}
|
||||
enforce_anon_guard(fn_spec)?;
|
||||
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||
|
||||
let targets = compute_invalidation(fn_spec, &args);
|
||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
|
||||
let mut out = Map::new();
|
||||
out.insert("result".into(), result);
|
||||
out.insert("invalidate".into(), Value::Array(invalidate));
|
||||
if !merges.is_empty() {
|
||||
out.insert(
|
||||
"merge".into(),
|
||||
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
|
||||
);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
async fn dispatch_ws_fetch(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
||||
let ctx = frame
|
||||
.get("context")
|
||||
.and_then(|c| c.as_str())
|
||||
.ok_or_else(|| MizanError::BadRequest("missing `context`".into()))?;
|
||||
if lookup_context(ctx).is_none() {
|
||||
return Err(MizanError::NotFound(format!("context {ctx:?}")));
|
||||
}
|
||||
let params = frame
|
||||
.get("params")
|
||||
.and_then(|p| p.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(ctx))
|
||||
.collect();
|
||||
|
||||
let mut bundle = Map::new();
|
||||
for fn_spec in &members {
|
||||
enforce_anon_guard(*fn_spec)?;
|
||||
let mut args = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(v) = params.get(ip.name) {
|
||||
args.insert(ip.name.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
|
||||
bundle.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
Ok(Value::Object(bundle))
|
||||
}
|
||||
|
||||
/// Enforce a function's auth guard for the WS transport. The WS upgrade
|
||||
/// carries no per-frame identity in this baseline, so a guarded function is
|
||||
/// rejected over WS — the same enforce-or-reject contract the HTTP path uses,
|
||||
/// applied with an anonymous identity.
|
||||
fn enforce_anon_guard(fn_spec: &dyn FunctionSpec) -> Result<(), MizanError> {
|
||||
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
||||
mizan_core::enforce_auth(None, &req)
|
||||
}
|
||||
|
||||
fn with_id(id: Value, mut body: Value) -> Value {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert("id".into(), id);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn err_frame(id: Value, e: &MizanError) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"error": { "code": e.code(), "message": e.message() },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user