Skip to main content

arcly_http_core/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: Arc<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    /// The peer IP the request arrived from (from the connection, or a trusted
51    /// `x-forwarded-for`). `None` when the server was not started with connection
52    /// info. Use for IP allowlists / per-request geo/rate decisions.
53    client_ip: Option<std::net::IpAddr>,
54}
55
56impl RequestContext {
57    #[inline]
58    pub fn method(&self) -> &Method {
59        &self.method
60    }
61    #[inline]
62    pub fn path(&self) -> &str {
63        &self.raw_path
64    }
65
66    #[inline]
67    pub fn query_string(&self) -> Option<&str> {
68        if self.query.is_empty() {
69            None
70        } else {
71            Some(&self.query)
72        }
73    }
74
75    #[inline]
76    pub fn body(&self) -> &Bytes {
77        &self.body
78    }
79    #[inline]
80    pub fn trace_id(&self) -> [u8; 16] {
81        self.trace_id
82    }
83
84    /// This server hop's span ID (64-bit, unique per request).
85    #[inline]
86    pub fn span_id(&self) -> [u8; 8] {
87        self.span_id
88    }
89
90    /// The incoming caller's span ID, or `None` for root (origin) spans.
91    #[inline]
92    pub fn parent_span_id(&self) -> Option<[u8; 8]> {
93        if self.parent_span_id == [0u8; 8] {
94            None
95        } else {
96            Some(self.parent_span_id)
97        }
98    }
99
100    #[inline]
101    pub fn claims(&self) -> Option<&Claims> {
102        self.claims.as_deref()
103    }
104
105    /// The server-side session loaded for this request, if any.
106    ///
107    /// `Some` only when `SessionManager` is provided in the DI container and
108    /// the session-ID cookie was present, valid, and found in the store.
109    #[inline]
110    pub fn session(&self) -> Option<&Arc<Session>> {
111        self.session.as_ref()
112    }
113
114    /// The tenant resolved for this request, if any.
115    ///
116    /// `Some` only when a `TenantRegistry` is provided in the DI container and
117    /// the configured strategy (header / subdomain) matched a known tenant
118    /// (or a fallback tenant is configured). Enforce presence + JWT-claim
119    /// consistency with `web::tenant::TENANT.check(&ctx)?`.
120    #[inline]
121    pub fn tenant(&self) -> Option<&TenantConfig> {
122        self.tenant.as_deref()
123    }
124
125    #[inline]
126    pub fn header(&self, key: &str) -> Option<&str> {
127        self.headers.get(key).and_then(|v| v.to_str().ok())
128    }
129
130    #[inline]
131    pub fn param(&self, name: &str) -> Option<&str> {
132        self.params
133            .iter()
134            .find(|(k, _)| k == name)
135            .map(|(_, v)| v.as_str())
136    }
137
138    /// Resolve a singleton service. O(1), no locks, no allocation.
139    /// The borrow is tied to this context; for an owned handle that can be
140    /// moved into a spawned task, use [`inject_arc`](Self::inject_arc).
141    /// Panics if `T` was not provided into the DI container.
142    #[inline]
143    pub fn inject<T: Send + Sync + 'static>(&self) -> &T {
144        self.container.get::<T>()
145    }
146
147    /// Resolve a singleton as an owned `Arc<T>` (one atomic refcount bump,
148    /// no lock, no allocation). Backs `Inject<T>` construction at request
149    /// entry. Panics if `T` was not provided into the DI container.
150    #[inline]
151    pub fn inject_arc<T: Send + Sync + 'static>(&self) -> std::sync::Arc<T> {
152        self.container.get_arc::<T>()
153    }
154
155    /// Non-panicking variant of [`Self::inject`]. Returns `None` when `T` is not
156    /// in the DI container. Used by optional guards and middleware.
157    #[inline]
158    pub fn try_inject<T: Send + Sync + 'static>(&self) -> Option<&T> {
159        self.container.try_get::<T>()
160    }
161
162    /// Matched route pattern (e.g. `/users/:id`). Use this — not `path()` — as a
163    /// metrics/trace label to prevent high-cardinality from path parameters.
164    #[inline]
165    pub fn route(&self) -> &'static str {
166        self.route_pattern
167    }
168
169    /// Static route metadata for the matched route, if any.
170    #[inline]
171    pub fn route_spec(&self) -> Option<&'static RouteSpec> {
172        self.route_spec
173    }
174
175    /// Generate the W3C `traceparent` header value suitable for forwarding to
176    /// downstream HTTP services. Uses this hop's `span_id` as the `parent-id`
177    /// field so the downstream span correctly chains to this one.
178    ///
179    /// Format: `00-{trace_id_hex}-{span_id_hex}-01`
180    pub fn traceparent(&self) -> String {
181        format!(
182            "00-{}-{}-01",
183            hex_encode_16(&self.trace_id),
184            hex_encode_8(&self.span_id)
185        )
186    }
187
188    /// The trace ID as a lowercase hex string — for log/audit correlation.
189    /// Replaces app-level calls to `lean_telemetry::hex_encode(&ctx.trace_id())`.
190    pub fn trace_id_hex(&self) -> String {
191        hex_encode_16(&self.trace_id)
192    }
193
194    /// Attach decoded JWT claims to a freshly constructed context.
195    #[doc(hidden)]
196    #[inline]
197    #[doc(hidden)]
198    pub fn __with_claims(mut self, claims: Option<Arc<Claims>>) -> Self {
199        self.claims = claims;
200        self
201    }
202
203    /// Attach a loaded server-side session to a freshly constructed context.
204    #[doc(hidden)]
205    #[inline]
206    #[doc(hidden)]
207    pub fn __with_session(mut self, session: Option<Arc<Session>>) -> Self {
208        self.session = session;
209        self
210    }
211
212    /// Attach the resolved tenant to a freshly constructed context.
213    #[doc(hidden)]
214    #[inline]
215    #[doc(hidden)]
216    pub fn __with_tenant(mut self, tenant: Option<std::sync::Arc<TenantConfig>>) -> Self {
217        self.tenant = tenant;
218        self
219    }
220
221    /// Boundary constructor. Not part of the public API.
222    #[doc(hidden)]
223    #[allow(clippy::too_many_arguments)]
224    pub fn __new(
225        method: Method,
226        raw_path: SmolStr,
227        query: SmolStr,
228        params: SmallVec<[(SmolStr, SmolStr); 4]>,
229        headers: HeaderMap,
230        body: Bytes,
231        trace_id: [u8; 16],
232        span_id: [u8; 8],
233        parent_span_id: [u8; 8],
234        container: Arc<FrozenDiContainer>,
235        route_pattern: &'static str,
236        route_spec: Option<&'static RouteSpec>,
237    ) -> Self {
238        Self {
239            method,
240            raw_path,
241            query,
242            params,
243            headers,
244            body,
245            claims: None,
246            session: None,
247            tenant: None,
248            trace_id,
249            span_id,
250            parent_span_id,
251            container,
252            route_pattern,
253            route_spec,
254            extensions: axum::http::Extensions::new(),
255            client_ip: None,
256        }
257    }
258
259    /// Attach the peer IP the request came from. See [`client_ip`](Self::client_ip).
260    #[doc(hidden)]
261    pub fn __with_client_ip(mut self, ip: Option<std::net::IpAddr>) -> Self {
262        self.client_ip = ip;
263        self
264    }
265
266    /// The peer IP the request arrived from, when known.
267    #[inline]
268    pub fn client_ip(&self) -> Option<std::net::IpAddr> {
269        self.client_ip
270    }
271
272    /// Per-request typed storage (request-scoped "DI"). Values live for this
273    /// request only; the frozen process-wide container is untouched.
274    #[inline]
275    pub fn extensions(&self) -> &axum::http::Extensions {
276        &self.extensions
277    }
278
279    /// Mutable access to per-request typed storage. Typical flow: an
280    /// interceptor inserts a value, the handler reads it via
281    /// [`extensions`](Self::extensions).
282    #[inline]
283    pub fn extensions_mut(&mut self) -> &mut axum::http::Extensions {
284        &mut self.extensions
285    }
286}
287
288#[inline]
289pub(crate) fn hex_encode_16(b: &[u8; 16]) -> String {
290    b.iter().map(|x| format!("{x:02x}")).collect()
291}
292
293#[inline]
294pub(crate) fn hex_encode_8(b: &[u8; 8]) -> String {
295    b.iter().map(|x| format!("{x:02x}")).collect()
296}