/// A single query/path/body parameter declared on an API route. #[derive(serde::Serialize, Clone)] pub struct ApiParam { pub name: &'static str, /// Rust-like type hint: string | u32 | bool | i64 | json. pub kind: &'static str, /// "required" | "optional" | "default=" pub constraint: &'static str, pub description: &'static str, } /// Static metadata for an HTTP endpoint. /// /// Registered at link time via `inventory::submit!` — generated by /// `#[onto_api(...)]` proc-macro attribute on each handler function. /// Collected via `inventory::iter::()`. #[derive(serde::Serialize, Clone)] pub struct ApiRouteEntry { pub method: &'static str, pub path: &'static str, pub description: &'static str, /// Authentication required: "none" | "viewer" | "bearer" | "admin" pub auth: &'static str, /// Which actors typically call this endpoint. pub actors: &'static [&'static str], pub params: &'static [ApiParam], /// Semantic grouping tags (e.g. "graph", "federation", "describe"). pub tags: &'static [&'static str], /// Non-empty when the endpoint is only compiled under a feature flag. pub feature: &'static str, /// Source file path (relative to workspace root) captured via `file!()`. pub source_file: &'static str, } inventory::collect!(ApiRouteEntry); /// Serialize all statically-registered [`ApiRouteEntry`] items to a /// pretty-printed JSON array, sorted by path then method. pub fn dump_catalog_json() -> String { let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::().collect(); routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method))); serde_json::to_string_pretty(&routes).unwrap_or_else(|_| "[]".to_string()) } /// Serialize all statically-registered [`ApiRouteEntry`] items to a Nickel /// record that imports `../reflection/schemas/api-catalog.ncl`. /// /// The output path is `artifacts/api-catalog-{crate_name}.ncl` relative to /// the project root. Enum tags are capitalised to match the schema contracts: /// `"GET"` → `'GET`, `"viewer"` → `'Viewer`, `"developer"` → `'Developer`. pub fn dump_catalog_ncl(crate_name: &str) -> String { let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::().collect(); routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method))); let routes_ncl = routes .iter() .map(|r| { let actors = r .actors .iter() .map(|a| format!("'{}", ncl_capitalize(a))) .collect::>() .join(", "); let tags = r .tags .iter() .map(|t| format!("\"{}\"", ncl_escape(t))) .collect::>() .join(", "); let params = r .params .iter() .map(|p| { format!( "{{ name = \"{}\", kind = \"{}\", constraint = \"{}\", description = \ \"{}\" }}", ncl_escape(p.name), ncl_escape(p.kind), ncl_escape(p.constraint), ncl_escape(p.description), ) }) .collect::>() .join(", "); format!( " {{\n method = '{method},\n path = \"{path}\",\n description = \ \"{desc}\",\n auth = '{auth},\n actors = [{actors}],\n tags = \ [{tags}],\n params = [{params}],\n feature = \"{feature}\",\n \ source_file = \"{source_file}\",\n }}", method = r.method, path = ncl_escape(r.path), desc = ncl_escape(r.description), auth = ncl_capitalize(r.auth), feature = ncl_escape(r.feature), source_file = ncl_escape(r.source_file), ) }) .collect::>() .join(",\n"); format!( "let schema = import \"../reflection/schemas/api-catalog.ncl\" in\n{{\n crate = \ \"{crate_name}\",\n routes | Array schema.Route = [\n{routes_ncl}\n ],\n}} | \ schema.Catalog\n" ) } fn ncl_capitalize(s: &str) -> String { let mut chars = s.chars(); match chars.next() { None => String::new(), Some(c) => c.to_uppercase().to_string() + chars.as_str(), } } fn ncl_escape(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") }