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}