Skip to main content

arcly_http/web/
context.rs

1//! The single opaque request boundary. Handlers receive `RequestContext` --
2//! axum/tower primitives are private to this module.
3
4use std::sync::Arc;
5
6use axum::http::{HeaderMap, Method};
7use bytes::Bytes;
8use smallvec::SmallVec;
9use smol_str::SmolStr;
10
11use crate::core::engine::{FrozenDiContainer, RouteSpec};
12use crate::session::Session;
13use crate::web::tenant::TenantConfig;
14
15/// Opaque authenticated-principal claims.
16pub type Claims = serde_json::Map<String, serde_json::Value>;
17
18/// The one and only context passed to every handler.
19///
20/// Carries a full W3C distributed-tracing context:
21/// - `trace_id` (16 bytes) -- preserved across the entire distributed call chain.
22/// - `span_id`  (8 bytes)  -- this server hop's span; becomes `parent-id` downstream.
23/// - `parent_span_id` -- the upstream caller's span (all-zeros = root span).
24///
25/// Use [`traceparent`](Self::traceparent) to generate the header for outgoing calls.
26#[derive(Clone)]
27pub struct RequestContext {
28    method: Method,
29    raw_path: SmolStr,
30    query: SmolStr,
31    params: SmallVec<[(SmolStr, SmolStr); 4]>,
32    headers: HeaderMap,
33    body: Bytes,
34    claims: Option<Arc<Claims>>,
35    session: Option<Arc<Session>>,
36    tenant: Option<std::sync::Arc<TenantConfig>>,
37    trace_id: [u8; 16],
38    span_id: [u8; 8],
39    parent_span_id: [u8; 8],
40    container: &'static FrozenDiContainer,
41    /// Matched route pattern (e.g. `/users/:id`). Empty string for plugin routes.
42    /// Used for cardinality-safe metrics labels — never use raw `path` for labels.
43    route_pattern: &'static str,
44    /// `Some` for macro-registered routes; `None` for plugin-registered routes.
45    route_spec: Option<&'static RouteSpec>,
46    /// Per-request typed storage. Scoped DI without touching the frozen
47    /// container: interceptors / boundary filters deposit request-scoped
48    /// values here, handlers read them back. Dropped with the request.
49    extensions: axum::http::Extensions,
50}
51
52impl RequestContext {
53    #[inline]
54    pub fn method(&self) -> &Method {
55        &self.method
56    }
57    #[inline]
58    pub fn path(&self) -> &str {
59        &self.raw_path
60    }
61
62    #[inline]
63    pub fn query_string(&self) -> Option<&str> {
64        if self.query.is_empty() {
65            None
66        } else {
67            Some(&self.query)
68        }
69    }
70
71    #[inline]
72    pub fn body(&self) -> &Bytes {
73        &self.body
74    }
75    #[inline]
76    pub fn trace_id(&self) -> [u8; 16] {
77        self.trace_id
78    }
79
80    /// This server hop's span ID (64-bit, unique per request).
81    #[inline]
82    pub fn span_id(&self) -> [u8; 8] {
83        self.span_id
84    }
85
86    /// The incoming caller's span ID, or `None` for root (origin) spans.
87    #[inline]
88    pub fn parent_span_id(&self) -> Option<[u8; 8]> {
89        if self.parent_span_id == [0u8; 8] {
90            None
91        } else {
92            Some(self.parent_span_id)
93        }
94    }
95
96    #[inline]
97    pub fn claims(&self) -> Option<&Claims> {
98        self.claims.as_deref()
99    }
100
101    /// The server-side session loaded for this request, if any.
102    ///
103    /// `Some` only when `SessionManager` is provided in the DI container and
104    /// the session-ID cookie was present, valid, and found in the store.
105    #[inline]
106    pub fn session(&self) -> Option<&Arc<Session>> {
107        self.session.as_ref()
108    }
109
110    /// The tenant resolved for this request, if any.
111    ///
112    /// `Some` only when a `TenantRegistry` is provided in the DI container and
113    /// the configured strategy (header / subdomain) matched a known tenant
114    /// (or a fallback tenant is configured). Enforce presence + JWT-claim
115    /// consistency with `web::tenant::TENANT.check(&ctx)?`.
116    #[inline]
117    pub fn tenant(&self) -> Option<&TenantConfig> {
118        self.tenant.as_deref()
119    }
120
121    #[inline]
122    pub fn header(&self, key: &str) -> Option<&str> {
123        self.headers.get(key).and_then(|v| v.to_str().ok())
124    }
125
126    #[inline]
127    pub fn param(&self, name: &str) -> Option<&str> {
128        self.params
129            .iter()
130            .find(|(k, _)| k == name)
131            .map(|(_, v)| v.as_str())
132    }
133
134    /// Resolve a singleton service. O(1), no locks, no allocation.
135    /// Panics if `T` was not provided into the DI container.
136    #[inline]
137    pub fn inject<T: Send + Sync + 'static>(&self) -> &'static T {
138        self.container.get::<T>()
139    }
140
141    /// Non-panicking variant of [`Self::inject`]. Returns `None` when `T` is not
142    /// in the DI container. Used by optional guards and middleware.
143    #[inline]
144    pub fn try_inject<T: Send + Sync + 'static>(&self) -> Option<&'static T> {
145        self.container.try_get::<T>()
146    }
147
148    /// Matched route pattern (e.g. `/users/:id`). Use this — not `path()` — as a
149    /// metrics/trace label to prevent high-cardinality from path parameters.
150    #[inline]
151    pub fn route(&self) -> &'static str {
152        self.route_pattern
153    }
154
155    /// Static route metadata for the matched route, if any.
156    #[inline]
157    pub fn route_spec(&self) -> Option<&'static RouteSpec> {
158        self.route_spec
159    }
160
161    /// Generate the W3C `traceparent` header value suitable for forwarding to
162    /// downstream HTTP services. Uses this hop's `span_id` as the `parent-id`
163    /// field so the downstream span correctly chains to this one.
164    ///
165    /// Format: `00-{trace_id_hex}-{span_id_hex}-01`
166    pub fn traceparent(&self) -> String {
167        format!(
168            "00-{}-{}-01",
169            hex_encode_16(&self.trace_id),
170            hex_encode_8(&self.span_id)
171        )
172    }
173
174    /// The trace ID as a lowercase hex string — for log/audit correlation.
175    /// Replaces app-level calls to `lean_telemetry::hex_encode(&ctx.trace_id())`.
176    pub fn trace_id_hex(&self) -> String {
177        hex_encode_16(&self.trace_id)
178    }
179
180    /// Attach decoded JWT claims to a freshly constructed context.
181    #[doc(hidden)]
182    #[inline]
183    pub(crate) fn __with_claims(mut self, claims: Option<Arc<Claims>>) -> Self {
184        self.claims = claims;
185        self
186    }
187
188    /// Attach a loaded server-side session to a freshly constructed context.
189    #[doc(hidden)]
190    #[inline]
191    pub(crate) fn __with_session(mut self, session: Option<Arc<Session>>) -> Self {
192        self.session = session;
193        self
194    }
195
196    /// Attach the resolved tenant to a freshly constructed context.
197    #[doc(hidden)]
198    #[inline]
199    pub(crate) fn __with_tenant(mut self, tenant: Option<std::sync::Arc<TenantConfig>>) -> Self {
200        self.tenant = tenant;
201        self
202    }
203
204    /// Boundary constructor. Not part of the public API.
205    #[doc(hidden)]
206    #[allow(clippy::too_many_arguments)]
207    pub fn __new(
208        method: Method,
209        raw_path: SmolStr,
210        query: SmolStr,
211        params: SmallVec<[(SmolStr, SmolStr); 4]>,
212        headers: HeaderMap,
213        body: Bytes,
214        trace_id: [u8; 16],
215        span_id: [u8; 8],
216        parent_span_id: [u8; 8],
217        container: &'static FrozenDiContainer,
218        route_pattern: &'static str,
219        route_spec: Option<&'static RouteSpec>,
220    ) -> Self {
221        Self {
222            method,
223            raw_path,
224            query,
225            params,
226            headers,
227            body,
228            claims: None,
229            session: None,
230            tenant: None,
231            trace_id,
232            span_id,
233            parent_span_id,
234            container,
235            route_pattern,
236            route_spec,
237            extensions: axum::http::Extensions::new(),
238        }
239    }
240
241    /// Per-request typed storage (request-scoped "DI"). Values live for this
242    /// request only; the frozen process-wide container is untouched.
243    #[inline]
244    pub fn extensions(&self) -> &axum::http::Extensions {
245        &self.extensions
246    }
247
248    /// Mutable access to per-request typed storage. Typical flow: an
249    /// interceptor inserts a value, the handler reads it via
250    /// [`extensions`](Self::extensions).
251    #[inline]
252    pub fn extensions_mut(&mut self) -> &mut axum::http::Extensions {
253        &mut self.extensions
254    }
255}
256
257#[inline]
258pub(crate) fn hex_encode_16(b: &[u8; 16]) -> String {
259    b.iter().map(|x| format!("{x:02x}")).collect()
260}
261
262#[inline]
263pub(crate) fn hex_encode_8(b: &[u8; 8]) -> String {
264    b.iter().map(|x| format!("{x:02x}")).collect()
265}