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