arcly-http 0.3.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
//! Third-party plugin ecosystem.
//!
//! A plugin owns a corner of the framework's behaviour without forking the
//! core crate: register global routes, mutate the OpenAPI spec, attach
//! global interceptors, spawn background tasks under graceful shutdown.
//!
//! ## Object safety
//!
//! `ArclyPlugin` uses the *erased-type* pattern: each lifecycle hook returns
//! a `BoxFuture` instead of `async fn`, so the trait stays object-safe and
//! we can hold `Vec<Box<dyn ArclyPlugin>>`.
//!
//! ## Opaque context
//!
//! `ArclyPluginContext` exposes a small, framework-typed API for the things
//! plugins legitimately need to do. Axum / Tower types are private; the
//! public methods all speak in arcly types (`RequestContext`, `Response`).
//!
//! ## Deferred construction
//!
//! Plugins register *handler factories* rather than routes directly. At
//! launch time, once the frozen DI container exists, `App::launch` invokes
//! each factory with the real `&'static FrozenDiContainer` and mounts the
//! resulting axum route. This sidesteps any unsoundness from materialising
//! the container before it's built.

use std::fmt;

use futures::future::BoxFuture;

use crate::http::Response;

use crate::core::engine::{FrozenDiContainer, HttpMethod};
use crate::web::context::RequestContext;

// ─── Errors ─────────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct PluginError {
    pub plugin: &'static str,
    pub stage: PluginStage,
    pub source: Box<dyn std::error::Error + Send + Sync>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginStage {
    Init,
    Start,
    Shutdown,
}

impl PluginError {
    pub fn new<E: Into<Box<dyn std::error::Error + Send + Sync>>>(
        plugin: &'static str,
        stage: PluginStage,
        source: E,
    ) -> Self {
        Self {
            plugin,
            stage,
            source: source.into(),
        }
    }
}

impl fmt::Display for PluginError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "plugin `{}` failed at {:?}: {}",
            self.plugin, self.stage, self.source
        )
    }
}
impl std::error::Error for PluginError {}

// ─── The trait ──────────────────────────────────────────────────────────

/// Plugin lifecycle.
///
/// All three hooks receive the framework's frozen DI container as a
/// `&'static` ref (after init completes), so background tasks and shutdown
/// drain routines can resolve injected services with zero locks. The init
/// hook receives the *mutable* `ArclyPluginContext`, through which the
/// plugin can `provide<T>` new singletons into the container before it
/// freezes.
pub trait ArclyPlugin: Send + Sync + 'static {
    fn name(&self) -> &'static str;

    /// **Pre-bind.** Register providers, routes, interceptors, openapi mutators.
    /// The container is built AFTER every plugin's `on_init` returns.
    fn on_init<'a>(
        &'a mut self,
        ctx: &'a mut ArclyPluginContext,
    ) -> BoxFuture<'a, Result<(), PluginError>> {
        let _ = ctx;
        Box::pin(async { Ok(()) })
    }

    /// **Drain started.** Fired as soon as a shutdown signal is received,
    /// *concurrently* with the HTTP in-flight drain (it does not delay the
    /// listener closing). Stop accepting new work here — pause message-queue
    /// consumers, schedulers, pollers — so plugin workloads quiesce while
    /// HTTP connections drain. Cleanup itself belongs in `on_shutdown`.
    fn on_draining<'a>(
        &'a self,
        container: &'static FrozenDiContainer,
    ) -> BoxFuture<'a, Result<(), PluginError>> {
        let _ = container;
        Box::pin(async { Ok(()) })
    }

    /// **Post-bind.** Listener is live and accepting. Background tasks spawn
    /// here. `container` resolves any provider — including ones the plugin
    /// itself registered in `on_init`.
    fn on_start<'a>(
        &'a self,
        container: &'static FrozenDiContainer,
    ) -> BoxFuture<'a, Result<(), PluginError>> {
        let _ = container;
        Box::pin(async { Ok(()) })
    }

    /// **Graceful shutdown.** Invoked **after** the HTTP server has stopped
    /// accepting new connections and all in-flight requests have drained.
    /// Wrapped by `App` in a per-plugin timeout (default 5s, configurable
    /// via `LaunchConfig::drain_budget`) so a hung plugin can never wedge
    /// the process or starve other plugins' shutdown.
    ///
    /// **Do not block.** The timeout can only cancel the future at an
    /// `.await` point — a synchronous spin or blocking syscall here pins a
    /// runtime worker thread until it returns. Use `spawn_blocking` for
    /// unavoidable blocking cleanup.
    fn on_shutdown<'a>(
        &'a self,
        container: &'static FrozenDiContainer,
    ) -> BoxFuture<'a, Result<(), PluginError>> {
        let _ = container;
        Box::pin(async { Ok(()) })
    }
}

// ─── Handler factory + context ─────────────────────────────────────────

/// A typed handler usable from plugin-registered routes. Takes a
/// `RequestContext`, returns a `Response`.
pub type PluginHandler =
    std::sync::Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync>;

#[doc(hidden)]
pub struct PluginRoute {
    pub method: HttpMethod,
    pub path: &'static str,
    pub handler: PluginHandler,
    /// Name of the plugin that registered this route. Filled in by the
    /// launch path after each `on_init` returns; used in collision errors.
    pub plugin: &'static str,
}

/// Mutable handle plugins receive during `on_init`.
///
/// `provide<T>` queues a singleton for the DI container, applied just before
/// the container freezes. Once frozen the container is `&'static` and
/// reads are lock-free for the lifetime of the process.
pub(crate) type OpenApiMutator = Box<dyn FnOnce(&mut serde_json::Value) + Send + Sync>;
pub(crate) type PendingProvider =
    Box<dyn FnOnce(&mut crate::core::engine::DiContainerBuilder) + Send>;

pub struct ArclyPluginContext {
    pub(crate) extra_routes: Vec<PluginRoute>,
    pub(crate) openapi_mutators: Vec<OpenApiMutator>,
    pub(crate) global_interceptors: Vec<&'static dyn crate::web::interceptors::Interceptor>,
    pub(crate) boundary_filters: Vec<&'static dyn crate::web::boundary::BoundaryFilter>,
    pub(crate) pending_providers: Vec<PendingProvider>,
    /// Name of the plugin whose `on_init` is currently running. Set by the
    /// launch loop so errors raised through the context name the right plugin.
    pub(crate) current_plugin: &'static str,
}

impl ArclyPluginContext {
    #[doc(hidden)]
    pub fn new() -> Self {
        Self {
            extra_routes: Vec::new(),
            openapi_mutators: Vec::new(),
            global_interceptors: Vec::new(),
            boundary_filters: Vec::new(),
            pending_providers: Vec::new(),
            current_plugin: "ArclyPluginContext",
        }
    }

    /// Inject a singleton of type `T` into the DI container. Resolves later
    /// via `Inject<T>` in any controller / service / interceptor.
    ///
    /// Order: every plugin's `on_init` runs first, then all `provide<T>`
    /// closures are applied in declaration order, *then* the container
    /// freezes. So one plugin can read another's provision in `on_start`
    /// but not in `on_init`.
    ///
    /// If `T` already has an `#[Injectable]` provider, launch fails loudly —
    /// use [`override_provider`](Self::override_provider) to replace it
    /// intentionally.
    pub fn provide<T: Send + Sync + 'static>(&mut self, value: T) {
        self.pending_providers.push(Box::new(move |b| {
            b.register(value);
        }));
    }

    /// Replace a core `#[Injectable]` provider with this instance. The
    /// descriptor is discarded; every `Inject<T>` in the app resolves to the
    /// plugin's value. Happens before the container freezes, so the swap is
    /// race-free and the hot path stays lock-free.
    pub fn override_provider<T: Send + Sync + 'static>(&mut self, value: T) {
        self.pending_providers.push(Box::new(move |b| {
            b.register_override(value);
        }));
    }

    /// Attach a boundary filter that runs on **every** request *before the
    /// body is read* — the cheap early-reject point for signature checks,
    /// IP allowlists, and rate limits. Returning `Break(resp)` short-circuits
    /// without paying for body buffering or context assembly.
    /// Ergonomic twin of [`Self::register_global_interceptor`]: takes ownership
    /// and leaks internally (interceptors are process-lifetime objects) —
    /// no `Box::leak` ceremony in plugin code.
    pub fn add_interceptor(&mut self, ic: impl crate::web::interceptors::Interceptor) {
        self.global_interceptors.push(Box::leak(Box::new(ic)));
    }

    /// Ergonomic twin of [`Self::register_boundary_filter`]: takes ownership and
    /// leaks internally.
    pub fn add_boundary_filter(&mut self, f: impl crate::web::boundary::BoundaryFilter) {
        self.boundary_filters.push(Box::leak(Box::new(f)));
    }

    pub fn register_boundary_filter(
        &mut self,
        f: &'static dyn crate::web::boundary::BoundaryFilter,
    ) {
        self.boundary_filters.push(f);
    }

    /// Register a route. The handler receives a fully-built `RequestContext`
    /// — including DI access via `ctx.inject::<T>()` — and returns a
    /// `Response`. The path is mounted verbatim under the application root.
    ///
    /// The path may be built at runtime (e.g. a config-driven prefix); it is
    /// leaked to `&'static str`, which is fine because routes live for the
    /// whole process anyway.
    pub fn add_route<F, Fut>(&mut self, method: HttpMethod, path: impl Into<String>, handler: F)
    where
        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
        Fut: std::future::Future<Output = Response> + Send + 'static,
    {
        let arc: PluginHandler = std::sync::Arc::new(move |ctx| Box::pin(handler(ctx)));
        self.extra_routes.push(PluginRoute {
            method,
            path: Box::leak(path.into().into_boxed_str()),
            handler: arc,
            plugin: self.current_plugin,
        });
    }

    /// Shortcut: register a GET.
    pub fn add_get<F, Fut>(&mut self, path: impl Into<String>, handler: F)
    where
        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
        Fut: std::future::Future<Output = Response> + Send + 'static,
    {
        self.add_route(HttpMethod::GET, path, handler);
    }

    /// Mutate the assembled OpenAPI document at launch time.
    pub fn modify_openapi<F>(&mut self, f: F)
    where
        F: FnOnce(&mut serde_json::Value) + Send + Sync + 'static,
    {
        self.openapi_mutators.push(Box::new(f));
    }

    /// Read a required environment variable.
    ///
    /// Returns `Err(PluginError)` with stage `Init` if the variable is absent
    /// or contains non-UTF-8 bytes, so callers can propagate it cleanly with `?`.
    pub fn require_env(&self, key: &str) -> Result<String, PluginError> {
        std::env::var(key).map_err(|_| {
            PluginError::new(
                self.current_plugin,
                PluginStage::Init,
                format!("required env var `{key}` is missing or not valid UTF-8"),
            )
        })
    }

    /// Read an environment variable with a fallback default.
    pub fn env_or(&self, key: &str, default: impl Into<String>) -> String {
        std::env::var(key).unwrap_or_else(|_| default.into())
    }

    /// Attach an interceptor that fires on **every** mounted route — macro
    /// routes and plugin-registered routes alike. Global interceptors compose
    /// as the outermost layers, in registration order (first registered =
    /// outermost), around any per-route `#[UseInterceptors]` chain.
    pub fn register_global_interceptor(
        &mut self,
        ic: &'static dyn crate::web::interceptors::Interceptor,
    ) {
        self.global_interceptors.push(ic);
    }
}

impl Default for ArclyPluginContext {
    fn default() -> Self {
        Self::new()
    }
}

// Route mounting moved to `crate::web::plugin_routes` so this module stays
// HTTP-agnostic. Re-exported here to keep the existing import path working.
pub use crate::web::plugin_routes::build_plugin_route;