arcly-http 0.2.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
//! The single opaque request boundary. Handlers receive `RequestContext` --
//! axum/tower primitives are private to this module.

use std::sync::Arc;

use axum::http::{HeaderMap, Method};
use bytes::Bytes;
use smallvec::SmallVec;
use smol_str::SmolStr;

use crate::core::engine::{FrozenDiContainer, RouteSpec};
use crate::session::Session;
use crate::web::tenant::TenantConfig;

/// Opaque authenticated-principal claims.
pub type Claims = serde_json::Map<String, serde_json::Value>;

/// The one and only context passed to every handler.
///
/// Carries a full W3C distributed-tracing context:
/// - `trace_id` (16 bytes) -- preserved across the entire distributed call chain.
/// - `span_id`  (8 bytes)  -- this server hop's span; becomes `parent-id` downstream.
/// - `parent_span_id` -- the upstream caller's span (all-zeros = root span).
///
/// Use [`traceparent`](Self::traceparent) to generate the header for outgoing calls.
#[derive(Clone)]
pub struct RequestContext {
    method: Method,
    raw_path: SmolStr,
    query: SmolStr,
    params: SmallVec<[(SmolStr, SmolStr); 4]>,
    headers: HeaderMap,
    body: Bytes,
    claims: Option<Arc<Claims>>,
    session: Option<Arc<Session>>,
    tenant: Option<std::sync::Arc<TenantConfig>>,
    trace_id: [u8; 16],
    span_id: [u8; 8],
    parent_span_id: [u8; 8],
    container: &'static FrozenDiContainer,
    /// Matched route pattern (e.g. `/users/:id`). Empty string for plugin routes.
    /// Used for cardinality-safe metrics labels — never use raw `path` for labels.
    route_pattern: &'static str,
    /// `Some` for macro-registered routes; `None` for plugin-registered routes.
    route_spec: Option<&'static RouteSpec>,
    /// Per-request typed storage. Scoped DI without touching the frozen
    /// container: interceptors / boundary filters deposit request-scoped
    /// values here, handlers read them back. Dropped with the request.
    extensions: axum::http::Extensions,
}

impl RequestContext {
    #[inline]
    pub fn method(&self) -> &Method {
        &self.method
    }
    #[inline]
    pub fn path(&self) -> &str {
        &self.raw_path
    }

    #[inline]
    pub fn query_string(&self) -> Option<&str> {
        if self.query.is_empty() {
            None
        } else {
            Some(&self.query)
        }
    }

    #[inline]
    pub fn body(&self) -> &Bytes {
        &self.body
    }
    #[inline]
    pub fn trace_id(&self) -> [u8; 16] {
        self.trace_id
    }

    /// This server hop's span ID (64-bit, unique per request).
    #[inline]
    pub fn span_id(&self) -> [u8; 8] {
        self.span_id
    }

    /// The incoming caller's span ID, or `None` for root (origin) spans.
    #[inline]
    pub fn parent_span_id(&self) -> Option<[u8; 8]> {
        if self.parent_span_id == [0u8; 8] {
            None
        } else {
            Some(self.parent_span_id)
        }
    }

    #[inline]
    pub fn claims(&self) -> Option<&Claims> {
        self.claims.as_deref()
    }

    /// The server-side session loaded for this request, if any.
    ///
    /// `Some` only when `SessionManager` is provided in the DI container and
    /// the session-ID cookie was present, valid, and found in the store.
    #[inline]
    pub fn session(&self) -> Option<&Arc<Session>> {
        self.session.as_ref()
    }

    /// The tenant resolved for this request, if any.
    ///
    /// `Some` only when a `TenantRegistry` is provided in the DI container and
    /// the configured strategy (header / subdomain) matched a known tenant
    /// (or a fallback tenant is configured). Enforce presence + JWT-claim
    /// consistency with `web::tenant::TENANT.check(&ctx)?`.
    #[inline]
    pub fn tenant(&self) -> Option<&TenantConfig> {
        self.tenant.as_deref()
    }

    #[inline]
    pub fn header(&self, key: &str) -> Option<&str> {
        self.headers.get(key).and_then(|v| v.to_str().ok())
    }

    #[inline]
    pub fn param(&self, name: &str) -> Option<&str> {
        self.params
            .iter()
            .find(|(k, _)| k == name)
            .map(|(_, v)| v.as_str())
    }

    /// Resolve a singleton service. O(1), no locks, no allocation.
    /// Panics if `T` was not provided into the DI container.
    #[inline]
    pub fn inject<T: Send + Sync + 'static>(&self) -> &'static T {
        self.container.get::<T>()
    }

    /// Non-panicking variant of [`Self::inject`]. Returns `None` when `T` is not
    /// in the DI container. Used by optional guards and middleware.
    #[inline]
    pub fn try_inject<T: Send + Sync + 'static>(&self) -> Option<&'static T> {
        self.container.try_get::<T>()
    }

    /// Matched route pattern (e.g. `/users/:id`). Use this — not `path()` — as a
    /// metrics/trace label to prevent high-cardinality from path parameters.
    #[inline]
    pub fn route(&self) -> &'static str {
        self.route_pattern
    }

    /// Static route metadata for the matched route, if any.
    #[inline]
    pub fn route_spec(&self) -> Option<&'static RouteSpec> {
        self.route_spec
    }

    /// Generate the W3C `traceparent` header value suitable for forwarding to
    /// downstream HTTP services. Uses this hop's `span_id` as the `parent-id`
    /// field so the downstream span correctly chains to this one.
    ///
    /// Format: `00-{trace_id_hex}-{span_id_hex}-01`
    pub fn traceparent(&self) -> String {
        format!(
            "00-{}-{}-01",
            hex_encode_16(&self.trace_id),
            hex_encode_8(&self.span_id)
        )
    }

    /// The trace ID as a lowercase hex string — for log/audit correlation.
    /// Replaces app-level calls to `lean_telemetry::hex_encode(&ctx.trace_id())`.
    pub fn trace_id_hex(&self) -> String {
        hex_encode_16(&self.trace_id)
    }

    /// Attach decoded JWT claims to a freshly constructed context.
    #[doc(hidden)]
    #[inline]
    pub(crate) fn __with_claims(mut self, claims: Option<Arc<Claims>>) -> Self {
        self.claims = claims;
        self
    }

    /// Attach a loaded server-side session to a freshly constructed context.
    #[doc(hidden)]
    #[inline]
    pub(crate) fn __with_session(mut self, session: Option<Arc<Session>>) -> Self {
        self.session = session;
        self
    }

    /// Attach the resolved tenant to a freshly constructed context.
    #[doc(hidden)]
    #[inline]
    pub(crate) fn __with_tenant(mut self, tenant: Option<std::sync::Arc<TenantConfig>>) -> Self {
        self.tenant = tenant;
        self
    }

    /// Boundary constructor. Not part of the public API.
    #[doc(hidden)]
    #[allow(clippy::too_many_arguments)]
    pub fn __new(
        method: Method,
        raw_path: SmolStr,
        query: SmolStr,
        params: SmallVec<[(SmolStr, SmolStr); 4]>,
        headers: HeaderMap,
        body: Bytes,
        trace_id: [u8; 16],
        span_id: [u8; 8],
        parent_span_id: [u8; 8],
        container: &'static FrozenDiContainer,
        route_pattern: &'static str,
        route_spec: Option<&'static RouteSpec>,
    ) -> Self {
        Self {
            method,
            raw_path,
            query,
            params,
            headers,
            body,
            claims: None,
            session: None,
            tenant: None,
            trace_id,
            span_id,
            parent_span_id,
            container,
            route_pattern,
            route_spec,
            extensions: axum::http::Extensions::new(),
        }
    }

    /// Per-request typed storage (request-scoped "DI"). Values live for this
    /// request only; the frozen process-wide container is untouched.
    #[inline]
    pub fn extensions(&self) -> &axum::http::Extensions {
        &self.extensions
    }

    /// Mutable access to per-request typed storage. Typical flow: an
    /// interceptor inserts a value, the handler reads it via
    /// [`extensions`](Self::extensions).
    #[inline]
    pub fn extensions_mut(&mut self) -> &mut axum::http::Extensions {
        &mut self.extensions
    }
}

#[inline]
pub(crate) fn hex_encode_16(b: &[u8; 16]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

#[inline]
pub(crate) fn hex_encode_8(b: &[u8; 8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}