Skip to main content

nidus_http/
context.rs

1//! Request context primitives shared by middleware, handlers, and observers.
2
3use std::{
4    future::Future,
5    net::{IpAddr, SocketAddr},
6    sync::Arc,
7};
8
9use axum::extract::FromRequestParts;
10use http::{HeaderMap, Method, request::Parts};
11use serde::Serialize;
12
13/// Client classification inferred from request boundary headers.
14#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ClientKind {
17    /// Request uses an API key style credential.
18    ApiKey,
19    /// Request carries a bearer token or other authorization header.
20    Authenticated,
21    /// Request has no recognized application credential.
22    Anonymous,
23}
24
25impl ClientKind {
26    /// Returns the stable string label for this client kind.
27    pub const fn as_str(self) -> &'static str {
28        match self {
29            Self::ApiKey => "api_key",
30            Self::Authenticated => "authenticated",
31            Self::Anonymous => "anonymous",
32        }
33    }
34}
35
36/// Request/correlation context attached to request extensions.
37///
38/// `RequestContext` is available to handlers when the router uses
39/// [`crate::middleware::validated_request_id_layer`] plus
40/// [`crate::middleware::request_context_layer`], or when it is wrapped by
41/// [`crate::middleware::ApiDefaults::production`]. Extracting it without those
42/// extensions rejects the request with `500 Internal Server Error`.
43///
44/// Fields are inferred from request headers and Axum extensions:
45/// - `request_id`: the final validated/generated `x-request-id`
46/// - `correlation_id`: `x-correlation-id`, falling back to the request ID
47/// - `trace_id` / `span_id`: validated IDs from `traceparent`
48/// - `client_kind`: `x-api-key` means API key, otherwise `Authorization` means
49///   authenticated, otherwise anonymous
50/// - `route`: Axum's [`axum::extract::MatchedPath`] when it is available at the
51///   point the context layer runs
52///
53/// Clones share the immutable string metadata. Consuming enrichment methods
54/// such as [`Self::with_user_id`] use copy-on-write, so changing a clone does
55/// not change the original context.
56///
57/// ```
58/// use nidus_http::{Json, context::RequestContext};
59///
60/// async fn handler(context: RequestContext) -> Json<serde_json::Value> {
61///     Json(serde_json::json!({
62///         "requestId": context.request_id(),
63///         "correlationId": context.correlation_id(),
64///     }))
65/// }
66/// ```
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct RequestContext {
69    inner: Arc<RequestContextInner>,
70    method: Method,
71    client_kind: ClientKind,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
75struct RequestContextInner {
76    request_id: String,
77    correlation_id: Option<String>,
78    route: Option<String>,
79    path: String,
80    trace_id: Option<String>,
81    span_id: Option<String>,
82    user_id: Option<String>,
83    tenant_id: Option<String>,
84    session_id: Option<String>,
85}
86
87impl RequestContext {
88    /// Creates a context for the current request boundary.
89    ///
90    /// This constructor is useful in tests or custom middleware. It does not
91    /// inspect headers, so optional correlation, trace, route, and client fields
92    /// remain empty/default until set explicitly or built via [`Self::from_parts`].
93    pub fn new(request_id: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
94        Self {
95            inner: Arc::new(RequestContextInner {
96                request_id: request_id.into(),
97                correlation_id: None,
98                route: None,
99                path: path.into(),
100                trace_id: None,
101                span_id: None,
102                user_id: None,
103                tenant_id: None,
104                session_id: None,
105            }),
106            method,
107            client_kind: ClientKind::Anonymous,
108        }
109    }
110
111    /// Creates a context from request parts.
112    ///
113    /// This reads `x-correlation-id`, `traceparent`, `x-api-key`,
114    /// `Authorization`, and [`axum::extract::MatchedPath`] from the request
115    /// boundary. The supplied `request_id` is expected to be the final ID chosen
116    /// by request ID middleware.
117    pub fn from_parts(parts: &Parts, request_id: impl Into<String>) -> Self {
118        let request_id = request_id.into();
119        let correlation_id = header_to_string(&parts.headers, "x-correlation-id")
120            .or_else(|| (!request_id.is_empty()).then(|| request_id.clone()));
121        let mut context = Self::new(request_id, parts.method.clone(), parts.uri.path());
122        let inner = Arc::make_mut(&mut context.inner);
123        inner.correlation_id = correlation_id;
124        inner.route = parts
125            .extensions
126            .get::<axum::extract::MatchedPath>()
127            .map(|path| path.as_str().to_owned());
128        context.client_kind = infer_client_kind(&parts.headers);
129        if let Some(trace_context) = parts
130            .headers
131            .get("traceparent")
132            .and_then(|value| value.to_str().ok())
133            .and_then(parse_traceparent)
134        {
135            inner.trace_id = Some(trace_context.trace_id.to_owned());
136            inner.span_id = Some(trace_context.parent_id.to_owned());
137        }
138        context
139    }
140
141    /// Returns the final request id.
142    ///
143    /// With [`crate::middleware::validated_request_id_layer`], this is either a
144    /// valid inbound UUID v4 or a generated ID.
145    pub fn request_id(&self) -> &str {
146        &self.inner.request_id
147    }
148
149    pub(crate) fn into_request_id(self) -> String {
150        match Arc::try_unwrap(self.inner) {
151            Ok(inner) => inner.request_id,
152            Err(inner) => inner.request_id.clone(),
153        }
154    }
155
156    /// Returns the correlation id when available.
157    ///
158    /// [`Self::from_parts`] prefers `x-correlation-id` and falls back to the
159    /// request ID when no correlation header is present.
160    pub fn correlation_id(&self) -> Option<&str> {
161        self.inner.correlation_id.as_deref()
162    }
163
164    /// Returns the request method.
165    pub const fn method(&self) -> &Method {
166        &self.method
167    }
168
169    /// Returns the stable matched route pattern when available.
170    ///
171    /// This depends on Axum's [`axum::extract::MatchedPath`] extension being
172    /// present before the context is built. Layer placement can affect whether
173    /// this is available for a given router shape.
174    pub fn route(&self) -> Option<&str> {
175        self.inner.route.as_deref()
176    }
177
178    /// Returns the raw request path.
179    pub fn path(&self) -> &str {
180        &self.inner.path
181    }
182
183    /// Returns the trace id when available.
184    ///
185    /// The value is extracted only when the W3C `traceparent` header is valid.
186    pub fn trace_id(&self) -> Option<&str> {
187        self.inner.trace_id.as_deref()
188    }
189
190    /// Returns the validated parent span id from `traceparent` when available.
191    pub fn span_id(&self) -> Option<&str> {
192        self.inner.span_id.as_deref()
193    }
194
195    /// Returns the inferred client kind.
196    ///
197    /// `x-api-key` takes precedence over `Authorization`; otherwise the request
198    /// is classified as anonymous.
199    pub const fn client_kind(&self) -> ClientKind {
200        self.client_kind
201    }
202
203    /// Returns the optional application user id.
204    pub fn user_id(&self) -> Option<&str> {
205        self.inner.user_id.as_deref()
206    }
207
208    /// Returns the optional application tenant id.
209    pub fn tenant_id(&self) -> Option<&str> {
210        self.inner.tenant_id.as_deref()
211    }
212
213    /// Returns the optional application session id.
214    pub fn session_id(&self) -> Option<&str> {
215        self.inner.session_id.as_deref()
216    }
217
218    /// Sets the stable matched route pattern.
219    pub fn with_route(mut self, route: impl Into<String>) -> Self {
220        Arc::make_mut(&mut self.inner).route = Some(route.into());
221        self
222    }
223
224    /// Sets an application user id.
225    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
226        Arc::make_mut(&mut self.inner).user_id = Some(user_id.into());
227        self
228    }
229
230    /// Sets an application tenant id.
231    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
232        Arc::make_mut(&mut self.inner).tenant_id = Some(tenant_id.into());
233        self
234    }
235
236    /// Sets an application session id.
237    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
238        Arc::make_mut(&mut self.inner).session_id = Some(session_id.into());
239        self
240    }
241}
242
243impl<S> FromRequestParts<S> for RequestContext
244where
245    S: Send + Sync,
246{
247    type Rejection = axum::http::StatusCode;
248
249    fn from_request_parts(
250        parts: &mut Parts,
251        _state: &S,
252    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
253        let context = parts.extensions.get::<Self>().cloned();
254        async move { context.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR) }
255    }
256}
257
258/// Request identity used by rate limiters and observers.
259#[derive(Clone, Debug, Eq, Hash, PartialEq)]
260pub struct RequestIdentity(String);
261
262impl RequestIdentity {
263    /// Creates a request identity from a stable label.
264    pub fn new(value: impl Into<String>) -> Self {
265        Self(value.into())
266    }
267
268    /// Returns the identity label.
269    pub fn as_str(&self) -> &str {
270        &self.0
271    }
272}
273
274/// Extracts a rate-limit identity from request parts.
275pub trait IdentityExtractor: Clone + Send + Sync + 'static {
276    /// Returns the identity for this request.
277    fn extract(&self, parts: &Parts) -> Option<RequestIdentity>;
278}
279
280impl<F> IdentityExtractor for F
281where
282    F: Fn(&Parts) -> Option<RequestIdentity> + Clone + Send + Sync + 'static,
283{
284    fn extract(&self, parts: &Parts) -> Option<RequestIdentity> {
285        self(parts)
286    }
287}
288
289/// Builds an identity extractor that prefers user/tenant/API key context fields.
290pub fn context_identity() -> impl IdentityExtractor {
291    |parts: &Parts| {
292        if let Some(context) = parts.extensions.get::<RequestContext>()
293            && let Some(value) = context.user_id().or_else(|| context.tenant_id())
294        {
295            return Some(RequestIdentity::new(value.to_owned()));
296        }
297        header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
298    }
299}
300
301/// Builds an identity extractor from API key headers.
302pub fn api_key_identity() -> impl IdentityExtractor {
303    |parts: &Parts| header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
304}
305
306/// Builds an identity extractor from the connected client IP address.
307///
308/// This extractor uses Axum's [`axum::extract::ConnectInfo<SocketAddr>`]
309/// extension and ignores `X-Forwarded-For`. Nidus serving helpers populate
310/// `ConnectInfo` on the normal `listen`/`serve` path. If a router is exercised
311/// without peer information, the identity falls back to `"anonymous"`.
312pub fn client_ip_identity() -> impl IdentityExtractor {
313    |parts: &Parts| {
314        peer_ip(parts)
315            .map(|ip| RequestIdentity::new(ip.to_string()))
316            .or_else(|| Some(RequestIdentity::new("anonymous")))
317    }
318}
319
320/// Builds an identity extractor that trusts `X-Forwarded-For` only from known proxies.
321///
322/// Use this when Nidus runs behind a reverse proxy that rewrites or appends
323/// `X-Forwarded-For` and the direct peer address is one of the configured
324/// trusted proxy IPs. Requests from untrusted peers ignore `X-Forwarded-For`
325/// and use the direct peer IP. Requests without peer information fall back to
326/// `"anonymous"`.
327pub fn trusted_proxy_client_ip_identity(
328    trusted_proxies: impl IntoIterator<Item = IpAddr>,
329) -> impl IdentityExtractor {
330    let trusted_proxies = trusted_proxies.into_iter().collect::<Vec<_>>();
331    move |parts: &Parts| {
332        peer_ip(parts)
333            .map(|peer| {
334                if trusted_proxies.contains(&peer)
335                    && let Some(forwarded_ip) = forwarded_for_ip(&parts.headers)
336                {
337                    RequestIdentity::new(forwarded_ip.to_string())
338                } else {
339                    RequestIdentity::new(peer.to_string())
340                }
341            })
342            .or_else(|| Some(RequestIdentity::new("anonymous")))
343    }
344}
345
346fn peer_ip(parts: &Parts) -> Option<IpAddr> {
347    parts
348        .extensions
349        .get::<axum::extract::ConnectInfo<SocketAddr>>()
350        .map(|connect| connect.0.ip())
351}
352
353fn forwarded_for_ip(headers: &HeaderMap) -> Option<IpAddr> {
354    header_to_string(headers, "x-forwarded-for").and_then(|value| {
355        value
356            .split(',')
357            .next()
358            .map(str::trim)
359            .filter(|value| !value.is_empty())
360            .and_then(|value| value.parse().ok())
361    })
362}
363
364pub(crate) fn header_to_string(headers: &HeaderMap, name: &'static str) -> Option<String> {
365    header_to_str(headers, name).map(str::to_owned)
366}
367
368pub(crate) fn header_to_str<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
369    headers
370        .get(name)
371        .and_then(|value| value.to_str().ok())
372        .filter(|value| !value.is_empty())
373}
374
375#[derive(Clone, Copy, Debug, Eq, PartialEq)]
376pub(crate) struct ParsedTraceParent<'a> {
377    pub(crate) trace_id: &'a str,
378    pub(crate) parent_id: &'a str,
379    pub(crate) flags: u8,
380}
381
382pub(crate) fn parse_traceparent(value: &str) -> Option<ParsedTraceParent<'_>> {
383    let bytes = value.as_bytes();
384    if bytes.len() < 55 || bytes[2] != b'-' || bytes[35] != b'-' || bytes[52] != b'-' {
385        return None;
386    }
387
388    let version = &bytes[..2];
389    let trace_id = &bytes[3..35];
390    let parent_id = &bytes[36..52];
391    let flags = &bytes[53..55];
392    if !is_lower_hex(version)
393        || version == b"ff"
394        || !is_valid_id(trace_id)
395        || !is_valid_id(parent_id)
396        || !is_lower_hex(flags)
397        || (version == b"00" && bytes.len() != 55)
398        || (bytes.len() > 55 && bytes[55] != b'-')
399    {
400        return None;
401    }
402
403    Some(ParsedTraceParent {
404        trace_id: &value[3..35],
405        parent_id: &value[36..52],
406        flags: u8::from_str_radix(&value[53..55], 16).ok()?,
407    })
408}
409
410fn is_valid_id(value: &[u8]) -> bool {
411    is_lower_hex(value) && value.iter().any(|byte| *byte != b'0')
412}
413
414fn is_lower_hex(value: &[u8]) -> bool {
415    value
416        .iter()
417        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
418}
419
420fn infer_client_kind(headers: &HeaderMap) -> ClientKind {
421    if headers.contains_key("x-api-key") {
422        ClientKind::ApiKey
423    } else if headers.contains_key(http::header::AUTHORIZATION) {
424        ClientKind::Authenticated
425    } else {
426        ClientKind::Anonymous
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use http::Request;
434
435    #[test]
436    fn request_context_can_consume_request_id() {
437        let context = RequestContext::new("req-123", Method::GET, "/users");
438
439        assert_eq!(context.into_request_id(), "req-123");
440    }
441
442    #[test]
443    fn cloned_request_context_uses_copy_on_write_for_enrichment() {
444        let original = RequestContext::new("req-123", Method::GET, "/users");
445        let shared = original.clone();
446        assert!(Arc::ptr_eq(&original.inner, &shared.inner));
447
448        let enriched = shared
449            .with_route("/users/{id}")
450            .with_user_id("user-42")
451            .with_tenant_id("tenant-7")
452            .with_session_id("session-9");
453
454        assert!(!Arc::ptr_eq(&original.inner, &enriched.inner));
455        assert_eq!(original.route(), None);
456        assert_eq!(original.user_id(), None);
457        assert_eq!(enriched.route(), Some("/users/{id}"));
458        assert_eq!(enriched.user_id(), Some("user-42"));
459        assert_eq!(enriched.tenant_id(), Some("tenant-7"));
460        assert_eq!(enriched.session_id(), Some("session-9"));
461    }
462
463    #[test]
464    fn request_context_extracts_valid_trace_and_parent_span_ids() {
465        let (parts, ()) = Request::builder()
466            .uri("/users/42")
467            .header(
468                "traceparent",
469                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
470            )
471            .body(())
472            .unwrap()
473            .into_parts();
474
475        let context = RequestContext::from_parts(&parts, "request-1");
476
477        assert_eq!(context.trace_id(), Some("4bf92f3577b34da6a3ce929d0e0e4736"));
478        assert_eq!(context.span_id(), Some("00f067aa0ba902b7"));
479    }
480
481    #[test]
482    fn request_context_ignores_invalid_traceparent_values() {
483        for traceparent in [
484            "not-a-traceparent",
485            "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
486            "00-00000000000000000000000000000000-00f067aa0ba902b7-01",
487            "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01",
488            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra",
489        ] {
490            let (parts, ()) = Request::builder()
491                .uri("/")
492                .header("traceparent", traceparent)
493                .body(())
494                .unwrap()
495                .into_parts();
496            let context = RequestContext::from_parts(&parts, "request-1");
497
498            assert_eq!(context.trace_id(), None, "{traceparent}");
499            assert_eq!(context.span_id(), None, "{traceparent}");
500        }
501    }
502
503    #[test]
504    fn client_ip_identity_ignores_forwarded_headers_without_peer_info() {
505        let parts = request_parts(None, Some("203.0.113.10"));
506        let identity = client_ip_identity().extract(&parts).unwrap();
507
508        assert_eq!(identity.as_str(), "anonymous");
509    }
510
511    #[test]
512    fn trusted_proxy_client_ip_identity_uses_forwarded_header_from_trusted_peer() {
513        let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10, 10.0.0.5"));
514        let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
515        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
516            .extract(&parts)
517            .unwrap();
518
519        assert_eq!(identity.as_str(), "203.0.113.10");
520    }
521
522    #[test]
523    fn trusted_proxy_client_ip_identity_ignores_forwarded_header_from_untrusted_peer() {
524        let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10"));
525        let trusted_proxy = "10.0.0.1".parse::<IpAddr>().unwrap();
526        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
527            .extract(&parts)
528            .unwrap();
529
530        assert_eq!(identity.as_str(), "127.0.0.1");
531    }
532
533    fn request_parts(peer: Option<&str>, forwarded_for: Option<&str>) -> Parts {
534        let mut builder = Request::builder().uri("/");
535        if let Some(forwarded_for) = forwarded_for {
536            builder = builder.header("x-forwarded-for", forwarded_for);
537        }
538        let (mut parts, ()) = builder.body(()).unwrap().into_parts();
539        if let Some(peer) = peer {
540            parts.extensions.insert(axum::extract::ConnectInfo(
541                peer.parse::<SocketAddr>().unwrap(),
542            ));
543        }
544        parts
545    }
546}