Skip to main content

shared_framework/logging/
correlation.rs

1//! Per-request correlation context.
2//!
3//! [`CorrelationContext`] carries request-scoped state — correlation and request IDs,
4//! flow marker, user ID, raw body, headers, query parameters, decoded multipart
5//! payload, arbitrary key/value data, and pagination state — through handlers.
6//! It is cheaply clonable via `Arc` and can also be propagated via the
7//! [`CORRELATION_CTX`] task-local.
8//!
9//! ```ignore
10//! let ctx = CorrelationContext::new();
11//! let page: Option<String> = ctx.query_param("page");
12//! ```
13
14use crate::ErrorResult;
15use crate::doc::DocumentableDTO;
16use crate::utils::request_parser::{MultipartBody, UploadedFile};
17use crate::validation::Validate;
18use serde_json::Value;
19use std::any::Any;
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22use uuid::Uuid;
23
24/// Lifecycle marker for a correlated unit of work.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CorrelationFlow {
27    /// A single standalone unit of work.
28    Once,
29    /// The start of a multi-step unit of work.
30    Start,
31    /// A middle step of a multi-step unit of work.
32    Continue,
33    /// The final step of a multi-step unit of work.
34    End,
35}
36
37impl std::str::FromStr for CorrelationFlow {
38    type Err = String;
39    fn from_str(s: &str) -> Result<Self, Self::Err> {
40        match s.trim().to_uppercase().as_str() {
41            "ONCE" => Ok(Self::Once),
42            "START" => Ok(Self::Start),
43            "CONTINUE" => Ok(Self::Continue),
44            "END" => Ok(Self::End),
45            other => Err(format!("unknown flow {other}")),
46        }
47    }
48}
49
50impl std::fmt::Display for CorrelationFlow {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        let s = match self {
53            Self::Once => "ONCE",
54            Self::Start => "START",
55            Self::Continue => "CONTINUE",
56            Self::End => "END",
57        };
58        write!(f, "{s}")
59    }
60}
61
62/// Per-request correlation context. Cheaply clonable via `Arc`.
63/// Shares the underlying request state; mutating one clone is visible to the others.
64#[derive(Debug, Clone)]
65pub struct CorrelationContext {
66    inner: Arc<Mutex<Inner>>,
67}
68
69#[derive(Debug)]
70struct Inner {
71    correlation_id: String,
72    request_id: String,
73    flow: CorrelationFlow,
74    user_id: Option<String>,
75    body: Option<Vec<u8>>,
76    headers: http::HeaderMap,
77    query_params: HashMap<String, String>,
78    multipart: Option<MultipartBody>,
79    data: HashMap<String, Box<dyn Any + Send + Sync>>,
80    path_params: HashMap<String, String>,
81    // pagination
82    pagination_cursor: Option<String>,
83    pagination_limit: usize,
84}
85
86impl CorrelationContext {
87    /// Creates a context with fresh random correlation and request IDs,
88    /// `Once` flow, no user, and a default pagination limit of 15.
89    pub fn new() -> Self {
90        let corr = Uuid::new_v4().to_string();
91        let req = hex::encode(rand::random::<[u8; 8]>());
92        Self {
93            inner: Arc::new(Mutex::new(Inner {
94                correlation_id: corr,
95                request_id: req,
96                flow: CorrelationFlow::Once,
97                user_id: None,
98                body: None,
99                headers: http::HeaderMap::new(),
100                query_params: HashMap::new(),
101                path_params: HashMap::new(),
102                multipart: None,
103                data: HashMap::new(),
104                pagination_cursor: None,
105                pagination_limit: 15,
106            })),
107        }
108    }
109
110    /// Creates a context with the given correlation and request IDs.
111    /// Other state matches [`CorrelationContext::new`].
112    pub fn with_ids(correlation_id: &str, request_id: &str) -> Self {
113        let ctx = Self::new();
114        {
115            let mut inner = ctx.inner.lock().unwrap();
116            inner.correlation_id = correlation_id.to_string();
117            inner.request_id = request_id.to_string();
118        }
119        ctx
120    }
121
122    pub(crate) fn set_body(&self, body: Vec<u8>) {
123        self.inner.lock().unwrap().body = Some(body);
124    }
125
126    pub(crate) fn set_headers(&self, headers: http::HeaderMap) {
127        self.inner.lock().unwrap().headers = headers;
128    }
129
130    pub(crate) fn set_params(&self, params: HashMap<String, String>) {
131        self.inner.lock().unwrap().path_params = params;
132    }
133
134    pub(crate) fn set_query_params(&self, params: HashMap<String, String>) {
135        self.inner.lock().unwrap().query_params = params;
136    }
137
138    pub(crate) fn set_multipart(&self, multipart: MultipartBody) {
139        self.inner.lock().unwrap().multipart = Some(multipart);
140    }
141
142    /// Whether the request arrived as `multipart/form-data`.
143    pub fn is_multipart(&self) -> bool {
144        self.inner.lock().unwrap().multipart.is_some()
145    }
146
147    /// Decoded multipart payload, if the request was multipart.
148    pub fn multipart(&self) -> Option<MultipartBody> {
149        self.inner.lock().unwrap().multipart.clone()
150    }
151
152    /// First value of a multipart text field, if the request was multipart.
153    pub fn form_field(&self, name: &str) -> Option<String> {
154        self.inner
155            .lock()
156            .unwrap()
157            .multipart
158            .as_ref()
159            .and_then(|mp| mp.field(name))
160            .map(|s| s.to_string())
161    }
162
163    /// All values of a repeated multipart text field.
164    pub fn form_field_all(&self, name: &str) -> Vec<String> {
165        self.inner
166            .lock()
167            .unwrap()
168            .multipart
169            .as_ref()
170            .and_then(|mp| mp.fields.get(name).cloned())
171            .unwrap_or_default()
172    }
173
174    /// Uploaded files in part order. Returns an empty vector when not multipart.
175    pub fn files(&self) -> Vec<UploadedFile> {
176        self.inner
177            .lock()
178            .unwrap()
179            .multipart
180            .as_ref()
181            .map(|mp| mp.files.clone())
182            .unwrap_or_default()
183    }
184
185    /// Uploaded files for one form field.
186    pub fn files_for(&self, field: &str) -> Vec<UploadedFile> {
187        self.files()
188            .into_iter()
189            .filter(|f| f.field_name == field)
190            .collect()
191    }
192
193    /// Raw request body bytes, if any.
194    pub fn body_bytes(&self) -> Option<Vec<u8>> {
195        self.inner.lock().unwrap().body.clone()
196    }
197
198    /// Raw request body as a string.
199    /// For multipart requests the `body` form field wins when present;
200    /// otherwise the raw payload is decoded as UTF-8.
201    /// Returns a `400` error when there is no request body, or it is not valid UTF-8.
202    /// No validation is applied to unstructured payloads.
203    pub fn body_string(&self) -> Result<String, ErrorResult> {
204        if let Some(payload) = self.form_field("body") {
205            return Ok(payload);
206        }
207        let body_bytes_opt = self.inner.lock().unwrap().body.clone();
208        let Some(body_bytes) = body_bytes_opt else {
209            return Err(ErrorResult::bad_request("no body"));
210        };
211        String::from_utf8(body_bytes).map_err(|_| ErrorResult::bad_request("invalid body"))
212    }
213
214    /// Deserializes and validates the request body as `T`.
215    ///
216    /// Multipart requests decode the DTO from the `body` form field when present,
217    /// otherwise from the merged text fields. Non-multipart bodies accept JSON
218    /// with a form-urlencoded fallback. Returns a `400` error when the body is
219    /// missing, cannot be deserialized, or fails [`Validate`](Validate).
220    ///
221    /// ```ignore
222    /// let dto: MyDto = ctx.body::<MyDto>()?;
223    /// ```
224    pub fn body<T>(&self) -> Result<T, ErrorResult>
225    where
226        T: DocumentableDTO + Validate,
227    {
228        // Multipart requests carry the DTO separately from the files — decode it
229        // in place instead of delegating parsing to the caller.
230        if let Some(mp) = self.inner.lock().unwrap().multipart.clone() {
231            return self.multipart_body(&mp);
232        }
233
234        // 1. Extract and clone the optional byte vector
235        let body_bytes_opt = self.inner.lock().unwrap().body.clone();
236        let Some(body_bytes) = body_bytes_opt else {
237            return Err(ErrorResult::bad_request("no body"));
238        };
239
240        // 2. Deserialize from the bytes. JSON is tried first; form-urlencoded
241        // is accepted as a fallback.
242        let parsed_body: Option<T> = serde_json::from_slice(&body_bytes).ok().or_else(|| {
243            serde_urlencoded::from_bytes::<Value>(&body_bytes)
244                .ok()
245                .and_then(|v| serde_json::from_value(v).ok())
246        });
247        let Some(parsed_body) = parsed_body else {
248            return Err(ErrorResult::bad_request("invalid body"));
249        };
250
251        // 3. Validate the deserialized struct
252        if let Err(validated) = parsed_body.validate() {
253            return Err(ErrorResult::new(
254                validated.message,
255                Some(Value::String(validated.field)),
256                400,
257            ));
258        }
259
260        // 4. Return the validated body (requires T to implement Clone)
261        Ok(parsed_body.clone())
262    }
263
264    /// Deserialize a DTO from a decoded multipart payload.
265    fn multipart_body<T>(&self, mp: &MultipartBody) -> Result<T, ErrorResult>
266    where
267        T: DocumentableDTO + Validate,
268    {
269        // Multipart requests encode the actual JSON payload into a single
270        // `body` form field.
271        if let Some(payload) = mp.field("body") {
272            let parsed: T = serde_json::from_str(payload)
273                .map_err(|_| ErrorResult::bad_request("invalid body"))?;
274            return self.validated(parsed);
275        }
276        // Otherwise merge the text fields into an object (single values as
277        // strings, repeated names as arrays) and deserialize from that.
278        let mut map = serde_json::Map::new();
279        for (k, vs) in &mp.fields {
280            let v = if vs.len() == 1 {
281                serde_json::Value::String(vs[0].clone())
282            } else {
283                serde_json::Value::Array(
284                    vs.iter().cloned().map(serde_json::Value::String).collect(),
285                )
286            };
287            map.insert(k.clone(), v);
288        }
289        if map.is_empty() {
290            return Err(ErrorResult::bad_request("no body"));
291        }
292        let parsed: T = serde_json::from_value(serde_json::Value::Object(map))
293            .map_err(|_| ErrorResult::bad_request("invalid body"))?;
294        self.validated(parsed)
295    }
296
297    fn validated<T>(&self, parsed: T) -> Result<T, ErrorResult>
298    where
299        T: DocumentableDTO + Validate,
300    {
301        if let Err(e) = parsed.validate() {
302            return Err(ErrorResult::bad_request(e.message));
303        }
304        Ok(parsed)
305    }
306
307    /// All request headers attached at dispatch.
308    pub fn headers(&self) -> http::HeaderMap {
309        self.inner.lock().unwrap().headers.clone()
310    }
311
312    /// A single request header value, if present.
313    pub fn header(&self, name: &str) -> Option<String> {
314        self.inner
315            .lock()
316            .unwrap()
317            .headers
318            .get(name)
319            .and_then(|v| v.to_str().ok())
320            .map(|s| s.to_string())
321    }
322
323    /// All query parameters attached at dispatch.
324    pub fn query_params(&self) -> HashMap<String, String> {
325        self.inner.lock().unwrap().query_params.clone()
326    }
327
328    /// A single query parameter value, if present.
329    pub fn query_param(&self, name: &str) -> Option<String> {
330        self.inner.lock().unwrap().query_params.get(name).cloned()
331    }
332
333    /// A query parameter value, or `default` when absent.
334    pub fn query_param_or(&self, name: &str, default: &str) -> String {
335        self.query_param(name)
336            .unwrap_or_else(|| default.to_string())
337    }
338
339    /// All path parameters attached at dispatch.
340    pub fn path_params(&self) -> HashMap<String, String> {
341        self.inner.lock().unwrap().path_params.clone()
342    }
343
344    /// A single path parameter value, if present.
345    pub fn path_param(&self, name: &str) -> Option<String> {
346        self.inner.lock().unwrap().path_params.get(name).cloned()
347    }
348
349    /// A path parameter value, or `default` when absent.
350    pub fn path_param_or(&self, name: &str, default: &str) -> String {
351        self.query_param(name)
352            .unwrap_or_else(|| default.to_string())
353    }
354
355    /// Returns the correlation ID shared across related requests.
356    pub fn correlation_id(&self) -> String {
357        self.inner.lock().unwrap().correlation_id.clone()
358    }
359
360    /// Returns the ID unique to this request.
361    pub fn request_id(&self) -> String {
362        self.inner.lock().unwrap().request_id.clone()
363    }
364
365    /// Replaces the request ID.
366    pub fn set_request_id(&self, id: &str) {
367        self.inner.lock().unwrap().request_id = id.to_string();
368    }
369
370    /// Replaces the correlation ID.
371    pub fn set_correlation_id(&self, id: &str) {
372        self.inner.lock().unwrap().correlation_id = id.to_string();
373    }
374
375    /// Returns the current lifecycle flow marker.
376    pub fn flow(&self) -> CorrelationFlow {
377        self.inner.lock().unwrap().flow
378    }
379
380    /// Replaces the lifecycle flow marker.
381    pub fn set_flow(&self, flow: CorrelationFlow) {
382        self.inner.lock().unwrap().flow = flow;
383    }
384
385    /// Sets the lifecycle flow marker and returns the context for chaining.
386    pub fn with_flow(self, flow: CorrelationFlow) -> Self {
387        self.set_flow(flow);
388        self
389    }
390
391    /// Sets the correlation ID and returns the context for chaining.
392    pub fn with_correlation_id(self, id: &str) -> Self {
393        self.set_correlation_id(id);
394        self
395    }
396
397    /// Returns the authenticated user ID, if one was attached.
398    pub fn user_id(&self) -> Option<String> {
399        self.inner.lock().unwrap().user_id.clone()
400    }
401
402    /// Sets or clears the authenticated user ID.
403    pub fn set_user_id(&self, id: Option<String>) {
404        self.inner.lock().unwrap().user_id = id;
405    }
406
407    /// Stores an arbitrary typed value under `key`.
408    pub fn set<T>(&self, key: &str, value: T)
409    where
410        T: Any + Send + Sync,
411    {
412        self.inner
413            .lock()
414            .unwrap()
415            .data
416            .insert(key.to_string(), Box::new(value));
417    }
418
419    /// Returns a cloned value of type `T` stored under `key`, if the key exists
420    /// and its value has that type.
421    pub fn get<T>(&self, key: &str) -> Option<T>
422    where
423        T: Any + Clone,
424    {
425        self.inner
426            .lock()
427            .unwrap()
428            .data
429            .get(key)
430            .and_then(|value| value.downcast_ref::<T>())
431            .cloned()
432    }
433
434    /// Stores a string value under `key`.
435    pub fn set_string(&self, key: &str, value: impl Into<String>) {
436        self.set(key, value.into());
437    }
438
439    /// Returns a cloned string value stored under `key`, if any.
440    pub fn get_string(&self, key: &str) -> Option<String> {
441        self.get(key)
442    }
443
444    /// Stores a boolean value under `key`.
445    pub fn set_bool(&self, key: &str, value: bool) {
446        self.set(key, value);
447    }
448
449    /// Returns a boolean value stored under `key`, if any.
450    pub fn get_bool(&self, key: &str) -> Option<bool> {
451        self.get(key)
452    }
453
454    /// Stores a numeric `f64` value under `key`.
455    pub fn set_number(&self, key: &str, value: f64) {
456        self.set(key, value);
457    }
458
459    /// Returns a numeric `f64` value stored under `key`, if any.
460    pub fn get_number(&self, key: &str) -> Option<f64> {
461        self.get(key)
462    }
463
464    /// Returns the pagination cursor, if one was attached.
465    pub fn pagination_cursor(&self) -> Option<String> {
466        self.inner.lock().unwrap().pagination_cursor.clone()
467    }
468
469    /// Returns the pagination limit (defaults to 15).
470    pub fn pagination_limit(&self) -> usize {
471        self.inner.lock().unwrap().pagination_limit
472    }
473
474    /// Sets the pagination cursor and limit.
475    pub fn set_pagination(&self, cursor: Option<String>, limit: usize) {
476        let mut inner = self.inner.lock().unwrap();
477        inner.pagination_cursor = cursor;
478        inner.pagination_limit = limit;
479    }
480
481    /// Extracts the client IP, preferring `x-forwarded-for` (first entry),
482    /// then `x-real-ip`, then the socket address, else `"unknown"`.
483    pub fn client_ip(
484        headers: &http::HeaderMap,
485        remote_addr: Option<std::net::SocketAddr>,
486    ) -> String {
487        if let Some(v) = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok()) {
488            if let Some(first) = v.split(',').next() {
489                let ip = first.trim();
490                if !ip.is_empty() {
491                    return ip.to_string();
492                }
493            }
494        }
495        if let Some(v) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) {
496            return v.to_string();
497        }
498        remote_addr
499            .map(|a| a.ip().to_string())
500            .unwrap_or_else(|| "unknown".to_string())
501    }
502}
503
504impl Default for CorrelationContext {
505    fn default() -> Self {
506        Self::new()
507    }
508}
509
510tokio::task_local! {
511    /// Task-local [`CorrelationContext`] for the current async task, when set by the dispatcher.
512    pub static CORRELATION_CTX: CorrelationContext;
513}
514
515#[cfg(test)]
516mod tests {
517    use super::CorrelationContext;
518
519    #[test]
520    fn stores_and_returns_owned_typed_values() {
521        let context = CorrelationContext::new();
522        context.set("count", 42_u32);
523
524        assert_eq!(context.get::<u32>("count"), Some(42));
525        assert_eq!(context.get::<String>("count"), None);
526        assert_eq!(context.get::<u32>("missing"), None);
527    }
528
529    #[test]
530    fn convenience_accessors_store_and_return_values() {
531        let context = CorrelationContext::new();
532        context.set_string("name", "Ada");
533        context.set_bool("enabled", true);
534        context.set_number("ratio", 1.5);
535
536        assert_eq!(context.get_string("name"), Some("Ada".to_string()));
537        assert_eq!(context.get_bool("enabled"), Some(true));
538        assert_eq!(context.get_number("ratio"), Some(1.5));
539    }
540}