Skip to main content

arcly_http_core/web/
dynamic.rs

1//! Runtime-mutable plugin routes under the `/_plugins` namespace.
2//!
3//! The core router stays frozen — dynamism is confined to one catch-all
4//! mounted at `/_plugins/*rest`, which dispatches through an
5//! `ArcSwap<HashMap>` table. The hot path inside the namespace pays one
6//! atomic load + one hash lookup; routes outside the namespace pay nothing.
7//!
8//! Mount/unmount use `rcu` (clone-and-swap), so writers never block readers.
9//! Mutations are expected to be rare (feature flags, tenant provisioning) —
10//! the table clone on write is the deliberate trade for a lock-free read.
11//!
12//! Paths are matched **exactly** (no `:params`), consistent with static
13//! plugin routes being mounted verbatim. Dynamic handlers receive the same
14//! `RequestContext` as every other route.
15
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use arc_swap::ArcSwap;
20use axum::http::Method;
21use axum::routing::any;
22
23use crate::core::engine::FrozenDiContainer;
24use crate::core::plugins::PluginHandler;
25use crate::http::Response;
26use crate::web::boundary::BoundaryFilter;
27use crate::web::context::RequestContext;
28
29/// Namespace prefix for all dynamic routes.
30pub const DYNAMIC_PREFIX: &str = "/_plugins";
31
32type Table = HashMap<(Method, String), PluginHandler>;
33
34/// Process-wide table of runtime-mounted routes. Automatically provided in
35/// the DI container — resolve with `Inject<DynamicRouteTable>` (or
36/// `ctx.inject::<DynamicRouteTable>()`) and mount/unmount at any time.
37pub struct DynamicRouteTable {
38    table: ArcSwap<Table>,
39    /// Global interceptors, installed once by the launch path so handlers
40    /// can be composed at MOUNT time — previously composition ran per
41    /// request, paying one `Arc` allocation per interceptor layer on every
42    /// `/_plugins` hit.
43    globals: std::sync::OnceLock<&'static [&'static dyn crate::web::interceptors::Interceptor]>,
44}
45
46impl DynamicRouteTable {
47    #[doc(hidden)]
48    pub fn new() -> Self {
49        Self {
50            table: ArcSwap::from_pointee(Table::new()),
51            globals: std::sync::OnceLock::new(),
52        }
53    }
54
55    /// Launch-path hook: install the global interceptor slice before any
56    /// plugin `on_start` can mount routes.
57    #[doc(hidden)]
58    pub fn set_globals(
59        &self,
60        globals: &'static [&'static dyn crate::web::interceptors::Interceptor],
61    ) {
62        let _ = self.globals.set(globals);
63    }
64
65    /// Mount a handler at `/_plugins/<path>`. `path` must start with `/`
66    /// and is matched exactly. Returns the previous handler if one was
67    /// replaced.
68    pub fn mount<F, Fut>(&self, method: Method, path: &str, handler: F) -> Option<PluginHandler>
69    where
70        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
71        Fut: std::future::Future<Output = Response> + Send + 'static,
72    {
73        let full = format!("{DYNAMIC_PREFIX}{path}");
74        let raw: PluginHandler = Arc::new(move |ctx| Box::pin(handler(ctx)));
75        // Compose the interceptor chain NOW — lookups hand back a
76        // ready-to-run handler with zero per-request composition cost.
77        let h: PluginHandler = match self.globals.get() {
78            Some(globals) => crate::web::interceptors::compose_chain(globals, raw),
79            None => raw,
80        };
81        let key = (method, full);
82        let mut replaced = None;
83        self.table.rcu(|cur| {
84            let mut next: Table = (**cur).clone();
85            replaced = next.insert(key.clone(), h.clone());
86            next
87        });
88        replaced
89    }
90
91    /// Remove a previously mounted route. Returns `true` if it existed.
92    pub fn unmount(&self, method: Method, path: &str) -> bool {
93        let key = (method, format!("{DYNAMIC_PREFIX}{path}"));
94        let mut removed = false;
95        self.table.rcu(|cur| {
96            let mut next: Table = (**cur).clone();
97            removed = next.remove(&key).is_some();
98            next
99        });
100        removed
101    }
102
103    /// Lock-free lookup: one atomic load + one hash probe.
104    fn lookup(&self, method: &Method, path: &str) -> Option<PluginHandler> {
105        self.table
106            .load()
107            .get(&(method.clone(), path.to_owned()))
108            .cloned()
109    }
110}
111
112/// Build the `/_plugins/*rest` catch-all. Shares the exact request pipeline
113/// (boundary filters → body cap → trace → auth) with every other route.
114#[doc(hidden)]
115pub fn dynamic_dispatch_route(
116    container: std::sync::Arc<FrozenDiContainer>,
117    filters: &'static [&'static dyn BoundaryFilter],
118) -> axum::routing::MethodRouter {
119    let handler = move |req: axum::extract::Request| {
120        let container = container.clone();
121        async move {
122            let (parts, body) = req.into_parts();
123            let table = container.get::<DynamicRouteTable>();
124            let Some(h) = table.lookup(&parts.method, parts.uri.path()) else {
125                return Response::builder()
126                    .status(404)
127                    .body(axum::body::Body::from("dynamic route not found"))
128                    .expect("static 404");
129            };
130            // Label metrics by the namespace catch-all, not the raw path —
131            // dynamic paths are unbounded, the pattern keeps cardinality safe.
132            crate::web::boundary::run_entry(
133                parts,
134                body,
135                Default::default(),
136                container,
137                "/_plugins/*",
138                None,
139                filters,
140                &h,
141            )
142            .await
143        }
144    };
145
146    any(handler)
147}