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};
7
8use axum::extract::FromRequestParts;
9use http::{HeaderMap, Method, request::Parts};
10use serde::Serialize;
11
12/// Client classification inferred from request boundary headers.
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ClientKind {
16    /// Request uses an API key style credential.
17    ApiKey,
18    /// Request carries a bearer token or other authorization header.
19    Authenticated,
20    /// Request has no recognized application credential.
21    Anonymous,
22}
23
24impl ClientKind {
25    /// Returns the stable string label for this client kind.
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::ApiKey => "api_key",
29            Self::Authenticated => "authenticated",
30            Self::Anonymous => "anonymous",
31        }
32    }
33}
34
35/// Request/correlation context attached to request extensions.
36///
37/// `RequestContext` is available to handlers when the router uses
38/// [`crate::middleware::validated_request_id_layer`] plus
39/// [`crate::middleware::request_context_layer`], or when it is wrapped by
40/// [`crate::middleware::ApiDefaults::production`]. Extracting it without those
41/// extensions rejects the request with `500 Internal Server Error`.
42///
43/// Fields are inferred from request headers and Axum extensions:
44/// - `request_id`: the final validated/generated `x-request-id`
45/// - `correlation_id`: `x-correlation-id`, falling back to the request ID
46/// - `trace_id`: the trace-id segment from `traceparent`
47/// - `client_kind`: `x-api-key` means API key, otherwise `Authorization` means
48///   authenticated, otherwise anonymous
49/// - `route`: Axum's [`axum::extract::MatchedPath`] when it is available at the
50///   point the context layer runs
51///
52/// ```
53/// use nidus_http::{Json, context::RequestContext};
54///
55/// async fn handler(context: RequestContext) -> Json<serde_json::Value> {
56///     Json(serde_json::json!({
57///         "requestId": context.request_id(),
58///         "correlationId": context.correlation_id(),
59///     }))
60/// }
61/// ```
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct RequestContext {
64    request_id: String,
65    correlation_id: Option<String>,
66    method: Method,
67    route: Option<String>,
68    path: String,
69    trace_id: Option<String>,
70    span_id: Option<String>,
71    client_kind: ClientKind,
72    user_id: Option<String>,
73    tenant_id: Option<String>,
74    session_id: Option<String>,
75}
76
77impl RequestContext {
78    /// Creates a context for the current request boundary.
79    ///
80    /// This constructor is useful in tests or custom middleware. It does not
81    /// inspect headers, so optional correlation, trace, route, and client fields
82    /// remain empty/default until set explicitly or built via [`Self::from_parts`].
83    pub fn new(request_id: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
84        Self {
85            request_id: request_id.into(),
86            correlation_id: None,
87            method,
88            route: None,
89            path: path.into(),
90            trace_id: None,
91            span_id: None,
92            client_kind: ClientKind::Anonymous,
93            user_id: None,
94            tenant_id: None,
95            session_id: None,
96        }
97    }
98
99    /// Creates a context from request parts.
100    ///
101    /// This reads `x-correlation-id`, `traceparent`, `x-api-key`,
102    /// `Authorization`, and [`axum::extract::MatchedPath`] from the request
103    /// boundary. The supplied `request_id` is expected to be the final ID chosen
104    /// by request ID middleware.
105    pub fn from_parts(parts: &Parts, request_id: impl Into<String>) -> Self {
106        let request_id = request_id.into();
107        let correlation_id = header_to_string(&parts.headers, "x-correlation-id")
108            .or_else(|| (!request_id.is_empty()).then(|| request_id.clone()));
109        let mut context = Self::new(request_id, parts.method.clone(), parts.uri.path());
110        context.correlation_id = correlation_id;
111        context.route = parts
112            .extensions
113            .get::<axum::extract::MatchedPath>()
114            .map(|path| path.as_str().to_owned());
115        context.client_kind = infer_client_kind(&parts.headers);
116        context.trace_id = parts
117            .headers
118            .get("traceparent")
119            .and_then(|value| value.to_str().ok())
120            .filter(|value| !value.is_empty())
121            .and_then(|value| value.split('-').nth(1))
122            .map(str::to_owned);
123        context
124    }
125
126    /// Returns the final request id.
127    ///
128    /// With [`crate::middleware::validated_request_id_layer`], this is either a
129    /// valid inbound UUID v4 or a generated ID.
130    pub fn request_id(&self) -> &str {
131        &self.request_id
132    }
133
134    pub(crate) fn into_request_id(self) -> String {
135        self.request_id
136    }
137
138    /// Returns the correlation id when available.
139    ///
140    /// [`Self::from_parts`] prefers `x-correlation-id` and falls back to the
141    /// request ID when no correlation header is present.
142    pub fn correlation_id(&self) -> Option<&str> {
143        self.correlation_id.as_deref()
144    }
145
146    /// Returns the request method.
147    pub const fn method(&self) -> &Method {
148        &self.method
149    }
150
151    /// Returns the stable matched route pattern when available.
152    ///
153    /// This depends on Axum's [`axum::extract::MatchedPath`] extension being
154    /// present before the context is built. Layer placement can affect whether
155    /// this is available for a given router shape.
156    pub fn route(&self) -> Option<&str> {
157        self.route.as_deref()
158    }
159
160    /// Returns the raw request path.
161    pub fn path(&self) -> &str {
162        &self.path
163    }
164
165    /// Returns the trace id when available.
166    ///
167    /// The value is extracted from the second segment of the W3C `traceparent`
168    /// header. Use [`crate::otel::extract_trace_context`] when the `otel`
169    /// feature is enabled and you need full trace/span validation.
170    pub fn trace_id(&self) -> Option<&str> {
171        self.trace_id.as_deref()
172    }
173
174    /// Returns the span id when available.
175    pub fn span_id(&self) -> Option<&str> {
176        self.span_id.as_deref()
177    }
178
179    /// Returns the inferred client kind.
180    ///
181    /// `x-api-key` takes precedence over `Authorization`; otherwise the request
182    /// is classified as anonymous.
183    pub const fn client_kind(&self) -> ClientKind {
184        self.client_kind
185    }
186
187    /// Returns the optional application user id.
188    pub fn user_id(&self) -> Option<&str> {
189        self.user_id.as_deref()
190    }
191
192    /// Returns the optional application tenant id.
193    pub fn tenant_id(&self) -> Option<&str> {
194        self.tenant_id.as_deref()
195    }
196
197    /// Returns the optional application session id.
198    pub fn session_id(&self) -> Option<&str> {
199        self.session_id.as_deref()
200    }
201
202    /// Sets the stable matched route pattern.
203    pub fn with_route(mut self, route: impl Into<String>) -> Self {
204        self.route = Some(route.into());
205        self
206    }
207
208    /// Sets an application user id.
209    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
210        self.user_id = Some(user_id.into());
211        self
212    }
213
214    /// Sets an application tenant id.
215    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
216        self.tenant_id = Some(tenant_id.into());
217        self
218    }
219
220    /// Sets an application session id.
221    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
222        self.session_id = Some(session_id.into());
223        self
224    }
225}
226
227impl<S> FromRequestParts<S> for RequestContext
228where
229    S: Send + Sync,
230{
231    type Rejection = axum::http::StatusCode;
232
233    fn from_request_parts(
234        parts: &mut Parts,
235        _state: &S,
236    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
237        let context = parts.extensions.get::<Self>().cloned();
238        async move { context.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR) }
239    }
240}
241
242/// Request identity used by rate limiters and observers.
243#[derive(Clone, Debug, Eq, Hash, PartialEq)]
244pub struct RequestIdentity(String);
245
246impl RequestIdentity {
247    /// Creates a request identity from a stable label.
248    pub fn new(value: impl Into<String>) -> Self {
249        Self(value.into())
250    }
251
252    /// Returns the identity label.
253    pub fn as_str(&self) -> &str {
254        &self.0
255    }
256}
257
258/// Extracts a rate-limit identity from request parts.
259pub trait IdentityExtractor: Clone + Send + Sync + 'static {
260    /// Returns the identity for this request.
261    fn extract(&self, parts: &Parts) -> Option<RequestIdentity>;
262}
263
264impl<F> IdentityExtractor for F
265where
266    F: Fn(&Parts) -> Option<RequestIdentity> + Clone + Send + Sync + 'static,
267{
268    fn extract(&self, parts: &Parts) -> Option<RequestIdentity> {
269        self(parts)
270    }
271}
272
273/// Builds an identity extractor that prefers user/tenant/API key context fields.
274pub fn context_identity() -> impl IdentityExtractor {
275    |parts: &Parts| {
276        if let Some(context) = parts.extensions.get::<RequestContext>()
277            && let Some(value) = context.user_id().or_else(|| context.tenant_id())
278        {
279            return Some(RequestIdentity::new(value.to_owned()));
280        }
281        header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
282    }
283}
284
285/// Builds an identity extractor from API key headers.
286pub fn api_key_identity() -> impl IdentityExtractor {
287    |parts: &Parts| header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
288}
289
290/// Builds an identity extractor from the connected client IP address.
291///
292/// This extractor uses Axum's [`axum::extract::ConnectInfo<SocketAddr>`]
293/// extension and ignores `X-Forwarded-For`. Nidus serving helpers populate
294/// `ConnectInfo` on the normal `listen`/`serve` path. If a router is exercised
295/// without peer information, the identity falls back to `"anonymous"`.
296pub fn client_ip_identity() -> impl IdentityExtractor {
297    |parts: &Parts| {
298        peer_ip(parts)
299            .map(|ip| RequestIdentity::new(ip.to_string()))
300            .or_else(|| Some(RequestIdentity::new("anonymous")))
301    }
302}
303
304/// Builds an identity extractor that trusts `X-Forwarded-For` only from known proxies.
305///
306/// Use this when Nidus runs behind a reverse proxy that rewrites or appends
307/// `X-Forwarded-For` and the direct peer address is one of the configured
308/// trusted proxy IPs. Requests from untrusted peers ignore `X-Forwarded-For`
309/// and use the direct peer IP. Requests without peer information fall back to
310/// `"anonymous"`.
311pub fn trusted_proxy_client_ip_identity(
312    trusted_proxies: impl IntoIterator<Item = IpAddr>,
313) -> impl IdentityExtractor {
314    let trusted_proxies = trusted_proxies.into_iter().collect::<Vec<_>>();
315    move |parts: &Parts| {
316        peer_ip(parts)
317            .map(|peer| {
318                if trusted_proxies.contains(&peer)
319                    && let Some(forwarded_ip) = forwarded_for_ip(&parts.headers)
320                {
321                    RequestIdentity::new(forwarded_ip.to_string())
322                } else {
323                    RequestIdentity::new(peer.to_string())
324                }
325            })
326            .or_else(|| Some(RequestIdentity::new("anonymous")))
327    }
328}
329
330fn peer_ip(parts: &Parts) -> Option<IpAddr> {
331    parts
332        .extensions
333        .get::<axum::extract::ConnectInfo<SocketAddr>>()
334        .map(|connect| connect.0.ip())
335}
336
337fn forwarded_for_ip(headers: &HeaderMap) -> Option<IpAddr> {
338    header_to_string(headers, "x-forwarded-for").and_then(|value| {
339        value
340            .split(',')
341            .next()
342            .map(str::trim)
343            .filter(|value| !value.is_empty())
344            .and_then(|value| value.parse().ok())
345    })
346}
347
348pub(crate) fn header_to_string(headers: &HeaderMap, name: &'static str) -> Option<String> {
349    headers
350        .get(name)
351        .and_then(|value| value.to_str().ok())
352        .filter(|value| !value.is_empty())
353        .map(str::to_owned)
354}
355
356fn infer_client_kind(headers: &HeaderMap) -> ClientKind {
357    if headers.contains_key("x-api-key") {
358        ClientKind::ApiKey
359    } else if headers.contains_key(http::header::AUTHORIZATION) {
360        ClientKind::Authenticated
361    } else {
362        ClientKind::Anonymous
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use http::Request;
370
371    #[test]
372    fn request_context_can_consume_request_id() {
373        let context = RequestContext::new("req-123", Method::GET, "/users");
374
375        assert_eq!(context.into_request_id(), "req-123");
376    }
377
378    #[test]
379    fn client_ip_identity_ignores_forwarded_headers_without_peer_info() {
380        let parts = request_parts(None, Some("203.0.113.10"));
381        let identity = client_ip_identity().extract(&parts).unwrap();
382
383        assert_eq!(identity.as_str(), "anonymous");
384    }
385
386    #[test]
387    fn trusted_proxy_client_ip_identity_uses_forwarded_header_from_trusted_peer() {
388        let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10, 10.0.0.5"));
389        let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
390        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
391            .extract(&parts)
392            .unwrap();
393
394        assert_eq!(identity.as_str(), "203.0.113.10");
395    }
396
397    #[test]
398    fn trusted_proxy_client_ip_identity_ignores_forwarded_header_from_untrusted_peer() {
399        let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10"));
400        let trusted_proxy = "10.0.0.1".parse::<IpAddr>().unwrap();
401        let identity = trusted_proxy_client_ip_identity([trusted_proxy])
402            .extract(&parts)
403            .unwrap();
404
405        assert_eq!(identity.as_str(), "127.0.0.1");
406    }
407
408    fn request_parts(peer: Option<&str>, forwarded_for: Option<&str>) -> Parts {
409        let mut builder = Request::builder().uri("/");
410        if let Some(forwarded_for) = forwarded_for {
411            builder = builder.header("x-forwarded-for", forwarded_for);
412        }
413        let (mut parts, ()) = builder.body(()).unwrap().into_parts();
414        if let Some(peer) = peer {
415            parts.extensions.insert(axum::extract::ConnectInfo(
416                peer.parse::<SocketAddr>().unwrap(),
417            ));
418        }
419        parts
420    }
421}