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: CorrelationId,
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
87#[derive(Clone, Debug, Eq, PartialEq)]
88enum CorrelationId {
89    None,
90    RequestId,
91    Explicit(String),
92}
93
94impl CorrelationId {
95    fn as_str<'a>(&'a self, request_id: &'a str) -> Option<&'a str> {
96        match self {
97            Self::None => None,
98            Self::RequestId => Some(request_id),
99            Self::Explicit(value) => Some(value),
100        }
101    }
102}
103
104impl RequestContext {
105    /// Creates a context for the current request boundary.
106    ///
107    /// This constructor is useful in tests or custom middleware. It does not
108    /// inspect headers, so optional correlation, trace, route, and client fields
109    /// remain empty/default until set explicitly or built via [`Self::from_parts`].
110    pub fn new(request_id: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
111        Self {
112            inner: Arc::new(RequestContextInner {
113                request_id: request_id.into(),
114                correlation_id: CorrelationId::None,
115                route: None,
116                path: path.into(),
117                trace_id: None,
118                span_id: None,
119                user_id: None,
120                tenant_id: None,
121                session_id: None,
122            }),
123            method,
124            client_kind: ClientKind::Anonymous,
125        }
126    }
127
128    /// Creates a context from request parts.
129    ///
130    /// This reads `x-correlation-id`, `traceparent`, `x-api-key`,
131    /// `Authorization`, and [`axum::extract::MatchedPath`] from the request
132    /// boundary. The supplied `request_id` is expected to be the final ID chosen
133    /// by request ID middleware.
134    pub fn from_parts(parts: &Parts, request_id: impl Into<String>) -> Self {
135        let request_id = request_id.into();
136        let correlation_id = match header_to_string(&parts.headers, "x-correlation-id") {
137            Some(value) => CorrelationId::Explicit(value),
138            None if request_id.is_empty() => CorrelationId::None,
139            None => CorrelationId::RequestId,
140        };
141        let mut context = Self::new(request_id, parts.method.clone(), parts.uri.path());
142        let inner = Arc::make_mut(&mut context.inner);
143        inner.correlation_id = correlation_id;
144        inner.route = parts
145            .extensions
146            .get::<axum::extract::MatchedPath>()
147            .map(|path| path.as_str().to_owned());
148        context.client_kind = infer_client_kind(&parts.headers);
149        if let Some(trace_context) = parts
150            .headers
151            .get("traceparent")
152            .and_then(|value| value.to_str().ok())
153            .and_then(parse_traceparent)
154        {
155            inner.trace_id = Some(trace_context.trace_id.to_owned());
156            inner.span_id = Some(trace_context.parent_id.to_owned());
157        }
158        context
159    }
160
161    /// Returns the final request id.
162    ///
163    /// With [`crate::middleware::validated_request_id_layer`], this is either a
164    /// valid inbound UUID v4 or a generated ID.
165    pub fn request_id(&self) -> &str {
166        &self.inner.request_id
167    }
168
169    pub(crate) fn into_request_id(self) -> String {
170        match Arc::try_unwrap(self.inner) {
171            Ok(inner) => inner.request_id,
172            Err(inner) => inner.request_id.clone(),
173        }
174    }
175
176    /// Returns the correlation id when available.
177    ///
178    /// [`Self::from_parts`] prefers `x-correlation-id` and falls back to the
179    /// request ID when no correlation header is present.
180    pub fn correlation_id(&self) -> Option<&str> {
181        self.inner.correlation_id.as_str(&self.inner.request_id)
182    }
183
184    /// Returns the request method.
185    pub const fn method(&self) -> &Method {
186        &self.method
187    }
188
189    /// Returns the stable matched route pattern when available.
190    ///
191    /// This depends on Axum's [`axum::extract::MatchedPath`] extension being
192    /// present before the context is built. Layer placement can affect whether
193    /// this is available for a given router shape.
194    pub fn route(&self) -> Option<&str> {
195        self.inner.route.as_deref()
196    }
197
198    /// Returns the raw request path.
199    pub fn path(&self) -> &str {
200        &self.inner.path
201    }
202
203    /// Returns the trace id when available.
204    ///
205    /// The value is extracted only when the W3C `traceparent` header is valid.
206    pub fn trace_id(&self) -> Option<&str> {
207        self.inner.trace_id.as_deref()
208    }
209
210    /// Returns the validated parent span id from `traceparent` when available.
211    pub fn span_id(&self) -> Option<&str> {
212        self.inner.span_id.as_deref()
213    }
214
215    /// Returns the inferred client kind.
216    ///
217    /// `x-api-key` takes precedence over `Authorization`; otherwise the request
218    /// is classified as anonymous.
219    pub const fn client_kind(&self) -> ClientKind {
220        self.client_kind
221    }
222
223    /// Returns the optional application user id.
224    pub fn user_id(&self) -> Option<&str> {
225        self.inner.user_id.as_deref()
226    }
227
228    /// Returns the optional application tenant id.
229    pub fn tenant_id(&self) -> Option<&str> {
230        self.inner.tenant_id.as_deref()
231    }
232
233    /// Returns the optional application session id.
234    pub fn session_id(&self) -> Option<&str> {
235        self.inner.session_id.as_deref()
236    }
237
238    /// Sets the stable matched route pattern.
239    pub fn with_route(mut self, route: impl Into<String>) -> Self {
240        Arc::make_mut(&mut self.inner).route = Some(route.into());
241        self
242    }
243
244    /// Sets an application user id.
245    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
246        Arc::make_mut(&mut self.inner).user_id = Some(user_id.into());
247        self
248    }
249
250    /// Sets an application tenant id.
251    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
252        Arc::make_mut(&mut self.inner).tenant_id = Some(tenant_id.into());
253        self
254    }
255
256    /// Sets an application session id.
257    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
258        Arc::make_mut(&mut self.inner).session_id = Some(session_id.into());
259        self
260    }
261}
262
263impl<S> FromRequestParts<S> for RequestContext
264where
265    S: Send + Sync,
266{
267    type Rejection = axum::http::StatusCode;
268
269    fn from_request_parts(
270        parts: &mut Parts,
271        _state: &S,
272    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
273        let context = parts.extensions.get::<Self>().cloned();
274        async move { context.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR) }
275    }
276}
277
278/// Request identity used by rate limiters and observers.
279#[derive(Clone, Debug, Eq, Hash, PartialEq)]
280pub struct RequestIdentity(String);
281
282impl RequestIdentity {
283    /// Creates a request identity from a stable label.
284    pub fn new(value: impl Into<String>) -> Self {
285        Self(value.into())
286    }
287
288    /// Returns the identity label.
289    pub fn as_str(&self) -> &str {
290        &self.0
291    }
292}
293
294/// Extracts a rate-limit identity from request parts.
295pub trait IdentityExtractor: Clone + Send + Sync + 'static {
296    /// Returns the identity for this request.
297    fn extract(&self, parts: &Parts) -> Option<RequestIdentity>;
298}
299
300impl<F> IdentityExtractor for F
301where
302    F: Fn(&Parts) -> Option<RequestIdentity> + Clone + Send + Sync + 'static,
303{
304    fn extract(&self, parts: &Parts) -> Option<RequestIdentity> {
305        self(parts)
306    }
307}
308
309/// Builds an identity extractor that prefers user/tenant/API key context fields.
310pub fn context_identity() -> impl IdentityExtractor {
311    |parts: &Parts| {
312        if let Some(context) = parts.extensions.get::<RequestContext>()
313            && let Some(value) = context.user_id().or_else(|| context.tenant_id())
314        {
315            return Some(RequestIdentity::new(value.to_owned()));
316        }
317        header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
318    }
319}
320
321/// Builds an identity extractor from API key headers.
322pub fn api_key_identity() -> impl IdentityExtractor {
323    |parts: &Parts| header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
324}
325
326/// Builds an identity extractor from the connected client IP address.
327///
328/// This extractor uses Axum's [`axum::extract::ConnectInfo<SocketAddr>`]
329/// extension and ignores `X-Forwarded-For`. Nidus serving helpers populate
330/// `ConnectInfo` on the normal `listen`/`serve` path. If a router is exercised
331/// without peer information, the identity falls back to `"anonymous"`.
332pub fn client_ip_identity() -> impl IdentityExtractor {
333    |parts: &Parts| {
334        peer_ip(parts)
335            .map(|ip| RequestIdentity::new(ip.to_string()))
336            .or_else(|| Some(RequestIdentity::new("anonymous")))
337    }
338}
339
340/// Builds an identity extractor that trusts `X-Forwarded-For` only from known proxies.
341///
342/// Use this when Nidus runs behind a reverse proxy that rewrites or appends
343/// `X-Forwarded-For` and the direct peer address is one of the configured
344/// trusted proxy IPs. Requests from untrusted peers ignore `X-Forwarded-For`
345/// and use the direct peer IP. Requests without peer information fall back to
346/// `"anonymous"`.
347///
348/// Forwarded chains are evaluated from right to left across every header value.
349/// Traversal continues only while the current hop is trusted and stops at the
350/// first untrusted or malformed address. Configure every proxy that is allowed
351/// to extend the chain; untrusted prefixes cannot select the returned identity.
352pub fn trusted_proxy_client_ip_identity(
353    trusted_proxies: impl IntoIterator<Item = IpAddr>,
354) -> impl IdentityExtractor {
355    let trusted_proxies: Arc<[IpAddr]> = trusted_proxies.into_iter().collect::<Vec<_>>().into();
356    move |parts: &Parts| {
357        peer_ip(parts)
358            .map(|peer| {
359                let client_ip =
360                    trusted_forwarded_client_ip(&parts.headers, peer, trusted_proxies.as_ref());
361                RequestIdentity::new(client_ip.to_string())
362            })
363            .or_else(|| Some(RequestIdentity::new("anonymous")))
364    }
365}
366
367fn peer_ip(parts: &Parts) -> Option<IpAddr> {
368    parts
369        .extensions
370        .get::<axum::extract::ConnectInfo<SocketAddr>>()
371        .map(|connect| connect.0.ip())
372}
373
374fn trusted_forwarded_client_ip(
375    headers: &HeaderMap,
376    peer: IpAddr,
377    trusted_proxies: &[IpAddr],
378) -> IpAddr {
379    let mut client_ip = peer;
380    'chain: for value in headers.get_all("x-forwarded-for").iter().rev() {
381        let Ok(value) = value.to_str() else {
382            break;
383        };
384        for hop in value.rsplit(',') {
385            if !trusted_proxies.contains(&client_ip) {
386                break 'chain;
387            }
388            let Ok(hop) = hop.trim().parse() else {
389                break 'chain;
390            };
391            client_ip = hop;
392        }
393    }
394    client_ip
395}
396
397pub(crate) fn header_to_string(headers: &HeaderMap, name: &'static str) -> Option<String> {
398    header_to_str(headers, name).map(str::to_owned)
399}
400
401pub(crate) fn header_to_str<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
402    headers
403        .get(name)
404        .and_then(|value| value.to_str().ok())
405        .filter(|value| !value.is_empty())
406}
407
408#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub(crate) struct ParsedTraceParent<'a> {
410    pub(crate) trace_id: &'a str,
411    pub(crate) parent_id: &'a str,
412    pub(crate) flags: u8,
413}
414
415pub(crate) fn parse_traceparent(value: &str) -> Option<ParsedTraceParent<'_>> {
416    let bytes = value.as_bytes();
417    if bytes.len() < 55 || bytes[2] != b'-' || bytes[35] != b'-' || bytes[52] != b'-' {
418        return None;
419    }
420
421    let version = &bytes[..2];
422    let trace_id = &bytes[3..35];
423    let parent_id = &bytes[36..52];
424    let flags = &bytes[53..55];
425    if !is_lower_hex(version)
426        || version == b"ff"
427        || !is_valid_id(trace_id)
428        || !is_valid_id(parent_id)
429        || !is_lower_hex(flags)
430        || (version == b"00" && bytes.len() != 55)
431        || (bytes.len() > 55 && bytes[55] != b'-')
432    {
433        return None;
434    }
435
436    Some(ParsedTraceParent {
437        trace_id: &value[3..35],
438        parent_id: &value[36..52],
439        flags: u8::from_str_radix(&value[53..55], 16).ok()?,
440    })
441}
442
443fn is_valid_id(value: &[u8]) -> bool {
444    is_lower_hex(value) && value.iter().any(|byte| *byte != b'0')
445}
446
447fn is_lower_hex(value: &[u8]) -> bool {
448    value
449        .iter()
450        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
451}
452
453fn infer_client_kind(headers: &HeaderMap) -> ClientKind {
454    if headers.contains_key("x-api-key") {
455        ClientKind::ApiKey
456    } else if headers.contains_key(http::header::AUTHORIZATION) {
457        ClientKind::Authenticated
458    } else {
459        ClientKind::Anonymous
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use http::Request;
467
468    #[test]
469    fn request_context_can_consume_request_id() {
470        let context = RequestContext::new("req-123", Method::GET, "/users");
471
472        assert_eq!(context.into_request_id(), "req-123");
473    }
474
475    #[test]
476    fn request_context_new_does_not_implicitly_set_a_correlation_id() {
477        let context = RequestContext::new("req-123", Method::GET, "/users");
478
479        assert_eq!(context.correlation_id(), None);
480        assert_eq!(context.inner.correlation_id, CorrelationId::None);
481    }
482
483    #[test]
484    fn request_context_from_parts_reuses_request_id_as_correlation_fallback() {
485        let (parts, ()) = Request::builder()
486            .uri("/users")
487            .body(())
488            .unwrap()
489            .into_parts();
490
491        let context = RequestContext::from_parts(&parts, "req-123");
492
493        assert_eq!(context.correlation_id(), Some("req-123"));
494        assert_eq!(context.inner.correlation_id, CorrelationId::RequestId);
495    }
496
497    #[test]
498    fn request_context_from_parts_preserves_explicit_correlation_id() {
499        let (parts, ()) = Request::builder()
500            .uri("/users")
501            .header("x-correlation-id", "corr-456")
502            .body(())
503            .unwrap()
504            .into_parts();
505
506        let context = RequestContext::from_parts(&parts, "req-123");
507
508        assert_eq!(context.correlation_id(), Some("corr-456"));
509        assert_eq!(
510            context.inner.correlation_id,
511            CorrelationId::Explicit("corr-456".to_owned())
512        );
513    }
514
515    #[test]
516    fn request_context_from_parts_keeps_empty_ids_absent() {
517        let (parts, ()) = Request::builder()
518            .uri("/users")
519            .body(())
520            .unwrap()
521            .into_parts();
522
523        let context = RequestContext::from_parts(&parts, "");
524
525        assert_eq!(context.correlation_id(), None);
526        assert_eq!(context.inner.correlation_id, CorrelationId::None);
527    }
528
529    #[test]
530    fn cloned_request_context_uses_copy_on_write_for_enrichment() {
531        let original = RequestContext::new("req-123", Method::GET, "/users");
532        let shared = original.clone();
533        assert!(Arc::ptr_eq(&original.inner, &shared.inner));
534
535        let enriched = shared
536            .with_route("/users/{id}")
537            .with_user_id("user-42")
538            .with_tenant_id("tenant-7")
539            .with_session_id("session-9");
540
541        assert!(!Arc::ptr_eq(&original.inner, &enriched.inner));
542        assert_eq!(original.route(), None);
543        assert_eq!(original.user_id(), None);
544        assert_eq!(enriched.route(), Some("/users/{id}"));
545        assert_eq!(enriched.user_id(), Some("user-42"));
546        assert_eq!(enriched.tenant_id(), Some("tenant-7"));
547        assert_eq!(enriched.session_id(), Some("session-9"));
548    }
549
550    #[test]
551    fn request_context_extracts_valid_trace_and_parent_span_ids() {
552        let (parts, ()) = Request::builder()
553            .uri("/users/42")
554            .header(
555                "traceparent",
556                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
557            )
558            .body(())
559            .unwrap()
560            .into_parts();
561
562        let context = RequestContext::from_parts(&parts, "request-1");
563
564        assert_eq!(context.trace_id(), Some("4bf92f3577b34da6a3ce929d0e0e4736"));
565        assert_eq!(context.span_id(), Some("00f067aa0ba902b7"));
566    }
567
568    #[test]
569    fn request_context_ignores_invalid_traceparent_values() {
570        for traceparent in [
571            "not-a-traceparent",
572            "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
573            "00-00000000000000000000000000000000-00f067aa0ba902b7-01",
574            "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01",
575            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra",
576        ] {
577            let (parts, ()) = Request::builder()
578                .uri("/")
579                .header("traceparent", traceparent)
580                .body(())
581                .unwrap()
582                .into_parts();
583            let context = RequestContext::from_parts(&parts, "request-1");
584
585            assert_eq!(context.trace_id(), None, "{traceparent}");
586            assert_eq!(context.span_id(), None, "{traceparent}");
587        }
588    }
589
590    #[test]
591    fn client_ip_identity_ignores_forwarded_headers_without_peer_info() {
592        let parts = request_parts(None, Some("203.0.113.10"));
593        let identity = client_ip_identity().extract(&parts).unwrap();
594
595        assert_eq!(identity.as_str(), "anonymous");
596    }
597
598    #[test]
599    fn trusted_proxy_client_ip_identity_uses_forwarded_header_from_trusted_peer() {
600        let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10, 10.0.0.5"));
601        let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
602        let intermediate_proxy = "10.0.0.5".parse::<IpAddr>().unwrap();
603        let identity = trusted_proxy_client_ip_identity([trusted_proxy, intermediate_proxy])
604            .extract(&parts)
605            .unwrap();
606
607        assert_eq!(identity.as_str(), "203.0.113.10");
608    }
609
610    #[test]
611    fn trusted_proxy_client_ip_identity_rejects_untrusted_forwarded_prefixes() {
612        let parts = request_parts(Some("127.0.0.1:5000"), Some("198.51.100.200, 203.0.113.10"));
613        let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
614        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
615            .extract(&parts)
616            .unwrap();
617
618        assert_eq!(identity.as_str(), "203.0.113.10");
619    }
620
621    #[test]
622    fn trusted_proxy_client_ip_identity_scans_all_forwarded_header_values() {
623        let mut parts = request_parts(Some("127.0.0.1:5000"), Some("198.51.100.200"));
624        parts
625            .headers
626            .append("x-forwarded-for", "203.0.113.10".parse().unwrap());
627        let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
628        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
629            .extract(&parts)
630            .unwrap();
631
632        assert_eq!(identity.as_str(), "203.0.113.10");
633    }
634
635    #[test]
636    fn trusted_proxy_client_ip_identity_stops_at_malformed_forwarded_hop() {
637        let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10, not-an-ip"));
638        let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
639        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
640            .extract(&parts)
641            .unwrap();
642
643        assert_eq!(identity.as_str(), "127.0.0.1");
644    }
645
646    #[test]
647    fn trusted_proxy_client_ip_identity_ignores_forwarded_header_from_untrusted_peer() {
648        let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10"));
649        let trusted_proxy = "10.0.0.1".parse::<IpAddr>().unwrap();
650        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
651            .extract(&parts)
652            .unwrap();
653
654        assert_eq!(identity.as_str(), "127.0.0.1");
655    }
656
657    fn request_parts(peer: Option<&str>, forwarded_for: Option<&str>) -> Parts {
658        let mut builder = Request::builder().uri("/");
659        if let Some(forwarded_for) = forwarded_for {
660            builder = builder.header("x-forwarded-for", forwarded_for);
661        }
662        let (mut parts, ()) = builder.body(()).unwrap().into_parts();
663        if let Some(peer) = peer {
664            parts.extensions.insert(axum::extract::ConnectInfo(
665                peer.parse::<SocketAddr>().unwrap(),
666            ));
667        }
668        parts
669    }
670}