arcly-http 0.4.0

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Runtime-mutable plugin routes under the `/_plugins` namespace.
//!
//! The core router stays frozen — dynamism is confined to one catch-all
//! mounted at `/_plugins/*rest`, which dispatches through an
//! `ArcSwap<HashMap>` table. The hot path inside the namespace pays one
//! atomic load + one hash lookup; routes outside the namespace pay nothing.
//!
//! Mount/unmount use `rcu` (clone-and-swap), so writers never block readers.
//! Mutations are expected to be rare (feature flags, tenant provisioning) —
//! the table clone on write is the deliberate trade for a lock-free read.
//!
//! Paths are matched **exactly** (no `:params`), consistent with static
//! plugin routes being mounted verbatim. Dynamic handlers receive the same
//! `RequestContext` as every other route.

use std::collections::HashMap;
use std::sync::Arc;

use arc_swap::ArcSwap;
use axum::http::Method;
use axum::routing::any;

use crate::core::engine::FrozenDiContainer;
use crate::core::plugins::PluginHandler;
use crate::http::Response;
use crate::web::boundary::BoundaryFilter;
use crate::web::context::RequestContext;

/// Namespace prefix for all dynamic routes.
pub const DYNAMIC_PREFIX: &str = "/_plugins";

type Table = HashMap<(Method, String), PluginHandler>;

/// Process-wide table of runtime-mounted routes. Automatically provided in
/// the DI container — resolve with `Inject<DynamicRouteTable>` (or
/// `ctx.inject::<DynamicRouteTable>()`) and mount/unmount at any time.
pub struct DynamicRouteTable {
    table: ArcSwap<Table>,
    /// Global interceptors, installed once by the launch path so handlers
    /// can be composed at MOUNT time — previously composition ran per
    /// request, paying one `Arc` allocation per interceptor layer on every
    /// `/_plugins` hit.
    globals: std::sync::OnceLock<&'static [&'static dyn crate::web::interceptors::Interceptor]>,
}

impl DynamicRouteTable {
    pub(crate) fn new() -> Self {
        Self {
            table: ArcSwap::from_pointee(Table::new()),
            globals: std::sync::OnceLock::new(),
        }
    }

    /// Launch-path hook: install the global interceptor slice before any
    /// plugin `on_start` can mount routes.
    pub(crate) fn set_globals(
        &self,
        globals: &'static [&'static dyn crate::web::interceptors::Interceptor],
    ) {
        let _ = self.globals.set(globals);
    }

    /// Mount a handler at `/_plugins/<path>`. `path` must start with `/`
    /// and is matched exactly. Returns the previous handler if one was
    /// replaced.
    pub fn mount<F, Fut>(&self, method: Method, path: &str, handler: F) -> Option<PluginHandler>
    where
        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
        Fut: std::future::Future<Output = Response> + Send + 'static,
    {
        let full = format!("{DYNAMIC_PREFIX}{path}");
        let raw: PluginHandler = Arc::new(move |ctx| Box::pin(handler(ctx)));
        // Compose the interceptor chain NOW — lookups hand back a
        // ready-to-run handler with zero per-request composition cost.
        let h: PluginHandler = match self.globals.get() {
            Some(globals) => crate::web::interceptors::compose_chain(globals, raw),
            None => raw,
        };
        let key = (method, full);
        let mut replaced = None;
        self.table.rcu(|cur| {
            let mut next: Table = (**cur).clone();
            replaced = next.insert(key.clone(), h.clone());
            next
        });
        replaced
    }

    /// Remove a previously mounted route. Returns `true` if it existed.
    pub fn unmount(&self, method: Method, path: &str) -> bool {
        let key = (method, format!("{DYNAMIC_PREFIX}{path}"));
        let mut removed = false;
        self.table.rcu(|cur| {
            let mut next: Table = (**cur).clone();
            removed = next.remove(&key).is_some();
            next
        });
        removed
    }

    /// Lock-free lookup: one atomic load + one hash probe.
    fn lookup(&self, method: &Method, path: &str) -> Option<PluginHandler> {
        self.table
            .load()
            .get(&(method.clone(), path.to_owned()))
            .cloned()
    }
}

/// Build the `/_plugins/*rest` catch-all. Shares the exact request pipeline
/// (boundary filters → body cap → trace → auth) with every other route.
pub(crate) fn dynamic_dispatch_route(
    container: &'static FrozenDiContainer,
    filters: &'static [&'static dyn BoundaryFilter],
) -> axum::routing::MethodRouter {
    let handler = move |req: axum::extract::Request| async move {
        let (parts, body) = req.into_parts();
        let table = container.get::<DynamicRouteTable>();
        let Some(h) = table.lookup(&parts.method, parts.uri.path()) else {
            return Response::builder()
                .status(404)
                .body(axum::body::Body::from("dynamic route not found"))
                .expect("static 404");
        };
        // Label metrics by the namespace catch-all, not the raw path —
        // dynamic paths are unbounded, the pattern keeps cardinality safe.
        crate::web::boundary::run_entry(
            parts,
            body,
            Default::default(),
            container,
            "/_plugins/*",
            None,
            filters,
            &h,
        )
        .await
    };

    any(handler)
}