arcly-http 0.1.2

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
//! Lock-free DI engine + macro registration descriptors.
//!
//! Two-phase lifecycle:
//!
//! 1. **Boot.** `DiContainerBuilder` collects [`ProviderDescriptor`]s emitted
//!    by `#[Injectable]` / `#[Module]`. It topologically sorts them by
//!    `TypeId` dependency edges, detects cycles deterministically, then
//!    invokes each provider's `build` function in dependency order. The
//!    build function receives a `Resolver` view of already-constructed
//!    singletons.
//!
//! 2. **Run.** The container is `Box::leak`-ed to `&'static FrozenDiContainer`.
//!    Every subsequent read is a single `HashMap::get` + downcast, both
//!    inlined. Zero locks, zero allocations on the request path.

use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::sync::Arc;

pub type AnyProvider = Arc<dyn Any + Send + Sync>;

/// Build-time resolver passed to each provider's `build` fn. Returns the
/// already-constructed dependency by `TypeId`. Panics if a dependency hasn't
/// been built yet — topological order guarantees this can't happen for a
/// well-formed graph.
pub struct Resolver<'a> {
    map: &'a HashMap<TypeId, AnyProvider>,
}

impl<'a> Resolver<'a> {
    /// Look up a raw provider by `TypeId`. Hidden from user-facing docs; prefer
    /// the typed `get<T>()` accessor.
    #[doc(hidden)]
    #[inline]
    pub fn get_any(&self, ty: TypeId) -> Option<&'a AnyProvider> {
        self.map.get(&ty)
    }

    /// Resolve a singleton by type, extending its lifetime to `'static`.
    ///
    /// # Safety (invariant that must hold at every call site)
    ///
    /// This method is only sound when the `FrozenDiContainer` that owns the
    /// backing `Arc<dyn Any>` allocations has been produced by `freeze()` **and
    /// then immediately `Box::leak`-ed to `&'static`**.  In that case all `Arc`
    /// allocations live for the entire process lifetime, making the lifetime
    /// extension a no-op in practice.
    ///
    /// Calling this through a `Resolver` obtained from a stack-allocated or
    /// `Arc`-wrapped `FrozenDiContainer` produces dangling `&'static T`
    /// references and is **undefined behaviour**.  The method is `#[doc(hidden)]`
    /// to prevent accidental use outside of macro-generated `__arcly_build` fns,
    /// which are the only legitimate callers.
    #[doc(hidden)]
    #[inline]
    pub fn get<T: Send + Sync + 'static>(&self) -> &'static T {
        let r: &T = self
            .map
            .get(&TypeId::of::<T>())
            .and_then(|a| a.downcast_ref::<T>())
            .expect("Arcly DI: dependency requested by a provider was not in the resolved set");
        // SAFETY: see doc comment above. Every singleton is Box::leak'd in
        // freeze() → the Arc allocation lives for the process lifetime → the
        // borrow extension cannot dangle.
        unsafe { std::mem::transmute::<&T, &'static T>(r) }
    }
}

/// One row in the dependency graph.
///
/// `deps_fn` returns the declared dependency `TypeId`s. (We use a function
/// pointer rather than a `&'static [TypeId]` because `TypeId::of` is not
/// const on stable.)
pub struct ProviderDescriptor {
    pub name: &'static str,
    pub type_id_fn: fn() -> TypeId,
    pub deps_fn: fn() -> Vec<TypeId>,
    pub build: fn(&Resolver<'_>) -> AnyProvider,
}

/// Emitted by `#[Module]`. Owns its providers, the controllers it exposes,
/// and the imports it pulls in. Used to walk the module DAG at boot.
pub struct ModuleDescriptor {
    pub name: &'static str,
    pub providers: &'static [&'static ProviderDescriptor],
    /// Names of controllers (type idents) belonging to this module. Routes
    /// emitted under these controllers are eligible to mount when this
    /// module is reachable from the root.
    pub controllers: &'static [&'static str],
    /// Submodules to walk transitively. Empty array means "leaf module".
    pub imports: &'static [fn() -> &'static ModuleDescriptor],
    /// Names of real-time gateways (type idents) belonging to this module.
    /// Gateways emitted under these names mount their WebSocket route only
    /// when the owning module is reachable from the root — same encapsulation
    /// rule as controllers.
    pub gateways: &'static [&'static str],
}

/// Implemented by every `#[Module]`-annotated struct so `App::launch::<RootMod>`
/// can find the entry point without a runtime registry lookup.
pub trait Module: 'static {
    fn descriptor() -> &'static ModuleDescriptor;
}

/// Mutable builder used only during boot.
#[derive(Default)]
pub struct DiContainerBuilder {
    providers: Vec<&'static ProviderDescriptor>,
    /// Direct-registered singletons (legacy / hand-wired). Always treated as
    /// dep-less and appended at the front of the build order.
    direct: HashMap<TypeId, AnyProvider>,
    /// Type names of direct registrations, for collision diagnostics.
    direct_names: HashMap<TypeId, &'static str>,
    /// Types whose `#[Injectable]` descriptor is deliberately replaced by a
    /// direct registration (`register_override`). Their descriptors are
    /// dropped before the topological sort.
    overridden: std::collections::HashSet<TypeId>,
}

impl DiContainerBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a fully-constructed singleton. Useful for primitives and
    /// configuration values that aren't `#[Injectable]`.
    ///
    /// If an `#[Injectable]` provider for the same type also exists, `freeze`
    /// panics — a silent winner would mask which instance the app runs with.
    /// Use [`register_override`](Self::register_override) to intentionally
    /// replace a descriptor-built provider.
    #[inline]
    pub fn register<T: Send + Sync + 'static>(&mut self, v: T) -> &mut Self {
        self.direct.insert(TypeId::of::<T>(), Arc::new(v));
        self.direct_names
            .insert(TypeId::of::<T>(), std::any::type_name::<T>());
        self
    }

    /// Register a singleton that *replaces* any `#[Injectable]` provider of
    /// the same type. The descriptor is discarded; everything that depends on
    /// `T` resolves to this instance. Intended for plugins swapping a core
    /// service (e.g. a custom `SessionStore`) — explicit, not accidental.
    #[inline]
    pub fn register_override<T: Send + Sync + 'static>(&mut self, v: T) -> &mut Self {
        self.overridden.insert(TypeId::of::<T>());
        self.register(v)
    }

    /// Register a provider descriptor (emitted by `#[Injectable]`).
    #[inline]
    pub fn add_provider(&mut self, d: &'static ProviderDescriptor) -> &mut Self {
        self.providers.push(d);
        self
    }

    /// Run topological sort, detect cycles, build providers in dependency
    /// order, then leak as `&'static`.
    ///
    /// Panics if a direct registration collides with an `#[Injectable]`
    /// descriptor without an explicit override — previously the descriptor
    /// silently won, which made plugin-side "overrides" no-ops.
    pub fn freeze(self) -> &'static FrozenDiContainer {
        let providers: Vec<&'static ProviderDescriptor> = self
            .providers
            .into_iter()
            .filter(|d| !self.overridden.contains(&(d.type_id_fn)()))
            .collect();

        for d in &providers {
            let ty = (d.type_id_fn)();
            if self.direct.contains_key(&ty) {
                let direct_name = self.direct_names.get(&ty).copied().unwrap_or("<unknown>");
                panic!(
                    "Arcly DI: `{}` is registered both directly (as `{direct_name}`) and \
                     via #[Injectable] `{}`. Use `register_override` (plugins: \
                     `ArclyPluginContext::override_provider`) to replace the injectable, \
                     or drop one of the registrations.",
                    d.name, d.name
                );
            }
        }

        let order = Self::topo_sort(&providers, &self.direct);

        let mut map: HashMap<TypeId, AnyProvider> = self.direct;
        for d in order {
            let resolver = Resolver { map: &map };
            let instance = (d.build)(&resolver);
            map.insert((d.type_id_fn)(), instance);
        }

        Box::leak(Box::new(FrozenDiContainer { map }))
    }

    /// Kahn's algorithm with deterministic ordering for stable build traces.
    fn topo_sort(
        providers: &[&'static ProviderDescriptor],
        direct: &HashMap<TypeId, AnyProvider>,
    ) -> Vec<&'static ProviderDescriptor> {
        use std::collections::VecDeque;

        // Index providers by their produced TypeId.
        let mut by_ty: HashMap<TypeId, &'static ProviderDescriptor> = HashMap::new();
        for p in providers {
            let prev = by_ty.insert((p.type_id_fn)(), *p);
            if prev.is_some() {
                panic!("Arcly DI: provider for `{}` registered twice", p.name);
            }
        }

        // Compute in-degree: number of deps that are themselves providers.
        // Deps satisfied by direct-registered singletons are free.
        let mut indeg: HashMap<TypeId, usize> = HashMap::new();
        let mut adj: HashMap<TypeId, Vec<TypeId>> = HashMap::new();
        for p in providers {
            let ty = (p.type_id_fn)();
            let mut d = 0usize;
            for dep in (p.deps_fn)() {
                if by_ty.contains_key(&dep) {
                    d += 1;
                    adj.entry(dep).or_default().push(ty);
                } else if direct.contains_key(&dep) {
                    // satisfied by direct singleton — no edge
                } else {
                    panic!(
                        "Arcly DI: provider `{}` depends on type `{:?}` which has no registered provider",
                        p.name, dep
                    );
                }
            }
            indeg.insert(ty, d);
        }

        let mut q: VecDeque<TypeId> = indeg
            .iter()
            .filter_map(|(k, &v)| (v == 0).then_some(*k))
            .collect();
        let mut order: Vec<&'static ProviderDescriptor> = Vec::with_capacity(providers.len());

        while let Some(ty) = q.pop_front() {
            order.push(by_ty[&ty]);
            if let Some(children) = adj.get(&ty) {
                for c in children {
                    let e = indeg.get_mut(c).unwrap();
                    *e -= 1;
                    if *e == 0 {
                        q.push_back(*c);
                    }
                }
            }
        }

        if order.len() != providers.len() {
            let unresolved: Vec<&'static str> = providers
                .iter()
                .filter(|p| !order.iter().any(|q| (q.type_id_fn)() == (p.type_id_fn)()))
                .map(|p| p.name)
                .collect();
            panic!("Arcly DI: dependency cycle detected. Unresolved providers: {unresolved:?}");
        }
        order
    }
}

/// Immutable, lock-free DI container. Reads are O(1) and inlined.
pub struct FrozenDiContainer {
    map: HashMap<TypeId, AnyProvider>,
}

impl FrozenDiContainer {
    #[inline(always)]
    pub fn get<T: Send + Sync + 'static>(&self) -> &T {
        self.map
            .get(&TypeId::of::<T>())
            .and_then(|a| a.downcast_ref::<T>())
            .expect("Arcly DI: missing provider")
    }

    /// Non-panicking variant. Returns `None` when the type has not been provided.
    /// Used in `boundary.rs` to skip JWT decoding when no `JwtService` is registered.
    #[inline]
    pub fn try_get<T: Send + Sync + 'static>(&self) -> Option<&T> {
        self.map
            .get(&TypeId::of::<T>())
            .and_then(|a| a.downcast_ref::<T>())
    }

    /// Produce a borrowing [`Resolver`] view over the frozen singleton set.
    ///
    /// # Safety (invariant)
    ///
    /// `Resolver::get<T>` extends borrows to `'static` under the assumption that
    /// `self` has been `Box::leak`-ed (i.e. produced by `freeze()` and never
    /// dropped).  Only macro-generated `__arcly_build` functions call this; it
    /// is `#[doc(hidden)]` to prevent accidental use from application code.
    #[doc(hidden)]
    #[inline]
    pub fn resolver(&self) -> Resolver<'_> {
        Resolver { map: &self.map }
    }
}

// ─── HTTP method + route descriptors (unchanged shape) ───────────────────

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum HttpMethod {
    GET,
    POST,
    PUT,
    DELETE,
    PATCH,
}

impl From<HttpMethod> for axum::http::Method {
    #[inline]
    fn from(m: HttpMethod) -> Self {
        match m {
            HttpMethod::GET => axum::http::Method::GET,
            HttpMethod::POST => axum::http::Method::POST,
            HttpMethod::PUT => axum::http::Method::PUT,
            HttpMethod::DELETE => axum::http::Method::DELETE,
            HttpMethod::PATCH => axum::http::Method::PATCH,
        }
    }
}

#[derive(Clone, Copy, Debug)]
pub enum ParamLoc {
    Path,
    Query,
    Header,
}

pub struct ParamSpec {
    pub name: &'static str,
    pub loc: ParamLoc,
    pub required: bool,
    pub schema: fn() -> serde_json::Value,
}

pub struct RouteSpec {
    pub summary: &'static str,
    pub description: &'static str,
    pub operation_id: &'static str,
    pub tags: &'static [&'static str],
    pub security: &'static [&'static str],
    pub status_code: Option<u16>,
    pub deprecated: bool,
    pub params: &'static [ParamSpec],
    pub has_body: bool,
    pub body_schema: Option<fn() -> serde_json::Value>,
    pub query_schema: Option<fn() -> serde_json::Value>,
    pub response_schema: Option<fn() -> serde_json::Value>,
    /// `#[CacheTTL(N)]` — seconds. 0/None disables the cache interceptor for
    /// this route regardless of `#[UseInterceptors(CacheInterceptor)]`.
    pub cache_ttl_secs: u64,
    /// `#[CacheKey("template")]` — empty string falls back to
    /// `method + ' ' + path + '?' + query`.
    pub cache_key: &'static str,
    /// `#[Version("v1")]` on the controller — empty string = unversioned.
    /// The version is also baked into the mounted path (`/v1/...`).
    pub api_version: &'static str,
    /// `#[Deprecated(sunset = "YYYY-MM-DD")]` — when non-empty the boundary
    /// adds RFC 8594 `Deprecation`/`Sunset` response headers.
    pub sunset: &'static str,
    /// `#[Idempotent(ttl = "…")]` — TTL seconds; 0 = not idempotent.
    /// Surfaces the `Idempotency-Key` header + 409 + replay header in OpenAPI.
    pub idempotent_ttl_secs: u64,
    /// `#[RequirePolicies("…")]` — ABAC actions gating this route.
    pub policies: &'static [&'static str],
    /// `#[AuditLog(action, resource)]` — empty action = no audit.
    pub audit_action: &'static str,
    pub audit_resource: &'static str,
    /// `#[Timeout("…")]` — milliseconds; 0 = no route deadline.
    pub timeout_ms: u64,
    /// `#[Transactional]` present on the handler.
    pub transactional: bool,
    /// `#[MaskFields("…")]` — route-local PII rules (surfaced in OpenAPI).
    pub mask_fields: &'static [&'static str],
}

pub struct RouteDescriptor {
    pub method: HttpMethod,
    pub path: &'static str,
    pub handler: fn(
        crate::web::context::RequestContext,
    ) -> futures::future::BoxFuture<'static, axum::response::Response>,
    pub spec: &'static RouteSpec,
    /// Owning controller's type name. Empty string for free-fn routes (those
    /// always mount). Routes whose controller is not in the reachable module
    /// DAG are skipped at launch — enforcing NestJS-style encapsulation.
    pub controller: &'static str,
}

inventory::collect!(&'static ModuleDescriptor);
inventory::collect!(&'static RouteDescriptor);