//! Edge manifest — the static JSON that Mizan Edge reads to configure CDN //! cache rules + invalidation routing. //! //! Mirrors `backends/mizan-django/src/mizan/export/__init__.py`'s //! `generate_edge_manifest`: a `{version, contexts, mutations}` document where //! each context carries its functions, endpoints, params, `user_scoped`, and //! `render_strategy` (the PSR axis), and each mutation carries its `affects` //! and `auto_scoped_params`. Keys are emitted alphabetically (the Django //! command serializes with `sort_keys=True`); `to_json_string` matches that. use crate::registry::{context_members, CONTEXTS, FUNCTIONS}; use crate::traits::FunctionSpec; use serde_json::{json, Map, Value}; use std::collections::BTreeSet; /// Params that imply a user-scoped context → `render_strategy: /// "dynamic_cached"`. Anything else renders as `"psr"`. Matches Python's /// `_USER_SCOPED_PARAMS`. const USER_SCOPED_PARAMS: [&str; 4] = ["user_id", "user", "owner_id", "account_id"]; /// Build the edge manifest as a `serde_json::Value`. `base_url` is the Mizan /// mount point (default `/api/mizan`). pub fn generate_edge_manifest(base_url: &str) -> Value { let mut contexts = Map::new(); // Contexts, alphabetical by name (BTreeSet over the registered names). let ctx_names: BTreeSet<&'static str> = CONTEXTS.iter().map(|c| c.name).collect(); for ctx_name in &ctx_names { let members = context_members(ctx_name); if members.is_empty() { continue; } let mut param_names: BTreeSet<&'static str> = BTreeSet::new(); let mut functions_meta: Vec = Vec::new(); let mut page_routes: Vec = Vec::new(); for fn_spec in &members { for p in fn_spec.input_params() { param_names.insert(p.name); } // The Rust IR has no view-path/route metadata yet; every function // is an RPC path. (`route`/`view_path` land with the view-path // macro extension.) functions_meta.push(json!({ "name": fn_spec.name(), "path": "rpc" })); } let user_scoped = param_names .iter() .any(|p| USER_SCOPED_PARAMS.contains(p)); let mut ctx_entry = Map::new(); ctx_entry.insert("functions".into(), Value::Array(functions_meta)); ctx_entry.insert( "endpoints".into(), json!([format!("{base_url}/ctx/{ctx_name}/")]), ); ctx_entry.insert( "params".into(), Value::Array( param_names .iter() .map(|p| Value::String((*p).to_string())) .collect(), ), ); ctx_entry.insert("user_scoped".into(), Value::Bool(user_scoped)); ctx_entry.insert( "render_strategy".into(), Value::String( if user_scoped { "dynamic_cached" } else { "psr" } .to_string(), ), ); if !page_routes.is_empty() { page_routes.sort(); ctx_entry.insert( "page_routes".into(), Value::Array(page_routes.into_iter().map(Value::String).collect()), ); } contexts.insert((*ctx_name).to_string(), Value::Object(ctx_entry)); } // Mutations — every non-private function declaring `affects`, alphabetical. let mut fns: Vec<&'static dyn FunctionSpec> = FUNCTIONS.iter().copied().collect(); fns.sort_by_key(|f| f.name()); let mut mutations = Map::new(); for fn_spec in &fns { let affected: BTreeSet<&'static str> = fn_spec .affects() .iter() .filter_map(|a| match a { crate::ir::AffectTarget::Context(name) => Some(*name), crate::ir::AffectTarget::Function { context, .. } => *context, }) .collect(); if affected.is_empty() { continue; } let mut mutation = Map::new(); mutation.insert( "affects".into(), Value::Array( affected .iter() .map(|c| Value::String((*c).to_string())) .collect(), ), ); // Auto-scoped params: this mutation's params that also name a param of // an affected context. let fn_params: BTreeSet<&'static str> = fn_spec.input_params().iter().map(|p| p.name).collect(); let mut auto_scoped: BTreeSet<&'static str> = BTreeSet::new(); for ctx in &affected { let mut ctx_params: BTreeSet<&'static str> = BTreeSet::new(); for m in context_members(ctx) { for p in m.input_params() { ctx_params.insert(p.name); } } for p in fn_params.intersection(&ctx_params) { auto_scoped.insert(*p); } } if !auto_scoped.is_empty() { mutation.insert( "auto_scoped_params".into(), Value::Array( auto_scoped .iter() .map(|p| Value::String((*p).to_string())) .collect(), ), ); } if fn_spec.private() { mutation.insert("private".into(), Value::Bool(true)); } mutations.insert(fn_spec.name().to_string(), Value::Object(mutation)); } json!({ "version": 1, "contexts": Value::Object(contexts), "mutations": Value::Object(mutations), }) } /// JSON-serialize the manifest with sorted keys (the Django command uses /// `json.dumps(..., sort_keys=True)`); `indent` of 0 → compact. pub fn generate_edge_manifest_json(base_url: &str, indent: usize) -> String { let value = generate_edge_manifest(base_url); let sorted = sort_value(&value); if indent == 0 { serde_json::to_string(&sorted).unwrap() } else { serde_json::to_string_pretty(&sorted).unwrap() } } /// Recursively re-key every object so serialization is sorted-key, matching /// Python's `sort_keys=True`. (serde_json::Map preserves insertion order, so /// we rebuild via BTreeMap ordering.) fn sort_value(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_value(&m[k])); } Value::Object(out) } Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()), other => other.clone(), } }