Skip to main content

arcly_http/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}
40
41impl DynamicRouteTable {
42    pub(crate) fn new() -> Self {
43        Self {
44            table: ArcSwap::from_pointee(Table::new()),
45        }
46    }
47
48    /// Mount a handler at `/_plugins/<path>`. `path` must start with `/`
49    /// and is matched exactly. Returns the previous handler if one was
50    /// replaced.
51    pub fn mount<F, Fut>(&self, method: Method, path: &str, handler: F) -> Option<PluginHandler>
52    where
53        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
54        Fut: std::future::Future<Output = Response> + Send + 'static,
55    {
56        let full = format!("{DYNAMIC_PREFIX}{path}");
57        let h: PluginHandler = Arc::new(move |ctx| Box::pin(handler(ctx)));
58        let key = (method, full);
59        let mut replaced = None;
60        self.table.rcu(|cur| {
61            let mut next: Table = (**cur).clone();
62            replaced = next.insert(key.clone(), h.clone());
63            next
64        });
65        replaced
66    }
67
68    /// Remove a previously mounted route. Returns `true` if it existed.
69    pub fn unmount(&self, method: Method, path: &str) -> bool {
70        let key = (method, format!("{DYNAMIC_PREFIX}{path}"));
71        let mut removed = false;
72        self.table.rcu(|cur| {
73            let mut next: Table = (**cur).clone();
74            removed = next.remove(&key).is_some();
75            next
76        });
77        removed
78    }
79
80    /// Lock-free lookup: one atomic load + one hash probe.
81    fn lookup(&self, method: &Method, path: &str) -> Option<PluginHandler> {
82        self.table
83            .load()
84            .get(&(method.clone(), path.to_owned()))
85            .cloned()
86    }
87}
88
89/// Build the `/_plugins/*rest` catch-all. Shares the exact request pipeline
90/// (boundary filters → body cap → trace → auth) with every other route.
91pub(crate) fn dynamic_dispatch_route(
92    container: &'static FrozenDiContainer,
93    globals: &'static [&'static dyn crate::web::interceptors::Interceptor],
94    filters: &'static [&'static dyn BoundaryFilter],
95) -> axum::routing::MethodRouter {
96    let handler = move |req: axum::extract::Request| async move {
97        let (parts, body) = req.into_parts();
98        let table = container.get::<DynamicRouteTable>();
99        let Some(h) = table.lookup(&parts.method, parts.uri.path()) else {
100            return Response::builder()
101                .status(404)
102                .body(axum::body::Body::from("dynamic route not found"))
103                .expect("static 404");
104        };
105        let chain = crate::web::interceptors::compose_chain(globals, h);
106        // Label metrics by the namespace catch-all, not the raw path —
107        // dynamic paths are unbounded, the pattern keeps cardinality safe.
108        crate::web::boundary::run_entry(
109            parts,
110            body,
111            Default::default(),
112            container,
113            "/_plugins/*",
114            None,
115            filters,
116            &chain,
117        )
118        .await
119    };
120
121    any(handler)
122}