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