Skip to main content

api_bones/
correlation_id.rs

1//! `CorrelationId` newtype for cross-service request correlation.
2//!
3//! A `CorrelationId` is distinct from a [`crate::request_id::RequestId`]:
4//! - `RequestId` identifies a single HTTP request at the edge.
5//! - `CorrelationId` groups related requests across multiple services
6//!   (e.g. an entire user-initiated action that fans out to N microservices).
7//!
8//! The value is an opaque string transported in the `X-Correlation-Id`
9//! HTTP header. UUID v4 generation is provided for convenience.
10//!
11//! # Example
12//!
13//! ```rust
14//! use api_bones::correlation_id::CorrelationId;
15//!
16//! let id = CorrelationId::new_uuid();
17//! assert_eq!(id.header_name(), "X-Correlation-Id");
18//!
19//! let parsed: CorrelationId = "my-correlation-123".parse().unwrap();
20//! assert_eq!(parsed.as_str(), "my-correlation-123");
21//! ```
22
23#[cfg(all(not(feature = "std"), feature = "alloc"))]
24use alloc::{
25    borrow::ToOwned,
26    string::{String, ToString},
27};
28use core::{fmt, ops::Deref, str::FromStr};
29#[cfg(feature = "serde")]
30use serde::{Deserialize, Deserializer, Serialize};
31use thiserror::Error;
32
33// ---------------------------------------------------------------------------
34// CorrelationIdError
35// ---------------------------------------------------------------------------
36
37/// Error returned when constructing a [`CorrelationId`] from a string fails.
38#[derive(Debug, Clone, PartialEq, Eq, Error)]
39pub enum CorrelationIdError {
40    /// The input was empty.
41    #[error("correlation ID must not be empty")]
42    Empty,
43    /// The input exceeds 255 characters.
44    #[error("correlation ID must not exceed 255 characters")]
45    TooLong,
46    /// The input contains non-printable or non-ASCII characters.
47    #[error("correlation ID may only contain printable ASCII characters (0x20–0x7E)")]
48    InvalidChars,
49}
50
51// ---------------------------------------------------------------------------
52// CorrelationId
53// ---------------------------------------------------------------------------
54
55/// An opaque cross-service correlation identifier, transported via
56/// `X-Correlation-Id`.
57///
58/// # Constraints
59///
60/// - Length: 1–255 characters.
61/// - Characters: printable ASCII only (`0x20`–`0x7E`).
62///
63/// See the [module-level documentation](self) for the distinction between this
64/// type and [`crate::request_id::RequestId`].
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66#[cfg_attr(feature = "serde", derive(Serialize))]
67#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
68#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
69pub struct CorrelationId(String);
70
71impl CorrelationId {
72    /// Construct a `CorrelationId` from any printable-ASCII string.
73    ///
74    /// # Errors
75    ///
76    /// Returns a [`CorrelationIdError`] variant that describes which constraint
77    /// failed.
78    ///
79    /// ```rust
80    /// use api_bones::correlation_id::{CorrelationId, CorrelationIdError};
81    ///
82    /// assert!(CorrelationId::new("flow-abc-123").is_ok());
83    /// assert_eq!(CorrelationId::new(""), Err(CorrelationIdError::Empty));
84    /// ```
85    pub fn new(s: impl AsRef<str>) -> Result<Self, CorrelationIdError> {
86        let s = s.as_ref();
87        if s.is_empty() {
88            return Err(CorrelationIdError::Empty);
89        }
90        if s.len() > 255 {
91            return Err(CorrelationIdError::TooLong);
92        }
93        if !s.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
94            return Err(CorrelationIdError::InvalidChars);
95        }
96        Ok(Self(s.to_owned()))
97    }
98
99    /// Generate a fresh `CorrelationId` backed by a UUID v4.
100    ///
101    /// ```rust
102    /// use api_bones::correlation_id::CorrelationId;
103    ///
104    /// let id = CorrelationId::new_uuid();
105    /// assert_eq!(id.as_str().len(), 36);
106    /// ```
107    #[must_use]
108    pub fn new_uuid() -> Self {
109        // UUID hyphenated string is always 36 printable ASCII chars — always valid.
110        Self(uuid::Uuid::new_v4().to_string())
111    }
112
113    /// Return the inner string slice.
114    ///
115    /// ```rust
116    /// use api_bones::correlation_id::CorrelationId;
117    ///
118    /// let id = CorrelationId::new("abc").unwrap();
119    /// assert_eq!(id.as_str(), "abc");
120    /// ```
121    #[must_use]
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125
126    /// Consume and return the underlying `String`.
127    ///
128    /// ```rust
129    /// use api_bones::correlation_id::CorrelationId;
130    ///
131    /// let id = CorrelationId::new("abc").unwrap();
132    /// assert_eq!(id.into_string(), "abc");
133    /// ```
134    #[must_use]
135    pub fn into_string(self) -> String {
136        self.0
137    }
138
139    /// The canonical HTTP header name: `X-Correlation-Id`.
140    ///
141    /// ```rust
142    /// use api_bones::correlation_id::CorrelationId;
143    ///
144    /// let id = CorrelationId::new("x").unwrap();
145    /// assert_eq!(id.header_name(), "X-Correlation-Id");
146    /// ```
147    #[must_use]
148    pub fn header_name(&self) -> &'static str {
149        "X-Correlation-Id"
150    }
151}
152
153// ---------------------------------------------------------------------------
154// HeaderId trait impl
155// ---------------------------------------------------------------------------
156
157#[cfg(feature = "std")]
158impl crate::header_id::HeaderId for CorrelationId {
159    const HEADER_NAME: &'static str = "X-Correlation-Id";
160
161    fn as_str(&self) -> std::borrow::Cow<'_, str> {
162        std::borrow::Cow::Borrowed(&self.0)
163    }
164}
165
166#[cfg(all(not(feature = "std"), feature = "alloc"))]
167impl crate::header_id::HeaderId for CorrelationId {
168    const HEADER_NAME: &'static str = "X-Correlation-Id";
169
170    fn as_str(&self) -> alloc::borrow::Cow<'_, str> {
171        alloc::borrow::Cow::Borrowed(&self.0)
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Standard trait impls
177// ---------------------------------------------------------------------------
178
179impl Deref for CorrelationId {
180    type Target = str;
181
182    fn deref(&self) -> &str {
183        &self.0
184    }
185}
186
187impl AsRef<str> for CorrelationId {
188    fn as_ref(&self) -> &str {
189        &self.0
190    }
191}
192
193impl fmt::Display for CorrelationId {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        f.write_str(&self.0)
196    }
197}
198
199impl FromStr for CorrelationId {
200    type Err = CorrelationIdError;
201
202    fn from_str(s: &str) -> Result<Self, Self::Err> {
203        Self::new(s)
204    }
205}
206
207impl TryFrom<String> for CorrelationId {
208    type Error = CorrelationIdError;
209
210    fn try_from(s: String) -> Result<Self, Self::Error> {
211        Self::new(s)
212    }
213}
214
215impl TryFrom<&str> for CorrelationId {
216    type Error = CorrelationIdError;
217
218    fn try_from(s: &str) -> Result<Self, Self::Error> {
219        Self::new(s)
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Serde
225// ---------------------------------------------------------------------------
226
227#[cfg(feature = "serde")]
228impl<'de> Deserialize<'de> for CorrelationId {
229    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
230        let s = String::deserialize(deserializer)?;
231        Self::new(&s).map_err(serde::de::Error::custom)
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Tests
237// ---------------------------------------------------------------------------
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn valid_id_is_accepted() {
245        assert!(CorrelationId::new("flow-abc").is_ok());
246        assert!(CorrelationId::new("x").is_ok());
247        assert!(CorrelationId::new("abc 123").is_ok()); // space is 0x20 = valid
248    }
249
250    #[test]
251    fn empty_is_rejected() {
252        assert_eq!(CorrelationId::new(""), Err(CorrelationIdError::Empty));
253    }
254
255    #[test]
256    fn too_long_is_rejected() {
257        let s: String = "a".repeat(256);
258        assert_eq!(CorrelationId::new(&s), Err(CorrelationIdError::TooLong));
259    }
260
261    #[test]
262    fn exactly_255_chars_is_accepted() {
263        let s: String = "a".repeat(255);
264        assert!(CorrelationId::new(&s).is_ok());
265    }
266
267    #[test]
268    fn control_char_is_rejected() {
269        assert_eq!(
270            CorrelationId::new("ab\x00c"),
271            Err(CorrelationIdError::InvalidChars)
272        );
273    }
274
275    #[test]
276    fn non_ascii_is_rejected() {
277        assert_eq!(
278            CorrelationId::new("héllo"),
279            Err(CorrelationIdError::InvalidChars)
280        );
281    }
282
283    #[test]
284    fn new_uuid_produces_valid_id() {
285        let id = CorrelationId::new_uuid();
286        assert_eq!(id.as_str().len(), 36);
287        assert!(CorrelationId::new(id.as_str()).is_ok());
288    }
289
290    #[test]
291    fn header_name() {
292        let id = CorrelationId::new("x").unwrap();
293        assert_eq!(id.header_name(), "X-Correlation-Id");
294    }
295
296    #[test]
297    fn display() {
298        let id = CorrelationId::new("corr-01").unwrap();
299        assert_eq!(format!("{id}"), "corr-01");
300    }
301
302    #[test]
303    fn deref_to_str() {
304        let id = CorrelationId::new("abc").unwrap();
305        let s: &str = &id;
306        assert_eq!(s, "abc");
307    }
308
309    #[test]
310    fn from_str() {
311        let id: CorrelationId = "corr-abc".parse().unwrap();
312        assert_eq!(id.as_str(), "corr-abc");
313    }
314
315    #[test]
316    fn try_from_str() {
317        assert!(CorrelationId::try_from("valid").is_ok());
318        assert!(CorrelationId::try_from("").is_err());
319    }
320
321    #[test]
322    fn try_from_string() {
323        assert!(CorrelationId::try_from("valid".to_owned()).is_ok());
324    }
325
326    #[test]
327    fn into_string() {
328        let id = CorrelationId::new("abc").unwrap();
329        assert_eq!(id.into_string(), "abc");
330    }
331
332    #[cfg(feature = "serde")]
333    #[test]
334    fn serde_roundtrip() {
335        let id = CorrelationId::new("corr-xyz-789").unwrap();
336        let json = serde_json::to_string(&id).unwrap();
337        assert_eq!(json, r#""corr-xyz-789""#);
338        let back: CorrelationId = serde_json::from_str(&json).unwrap();
339        assert_eq!(back, id);
340    }
341
342    #[cfg(feature = "serde")]
343    #[test]
344    fn serde_deserialize_invalid_rejects() {
345        let result: Result<CorrelationId, _> = serde_json::from_str(r#""""#);
346        assert!(result.is_err());
347    }
348
349    #[test]
350    fn as_ref_str() {
351        let id = CorrelationId::new("corr-ref").unwrap();
352        let s: &str = id.as_ref();
353        assert_eq!(s, "corr-ref");
354    }
355
356    // Coverage gap: CorrelationIdError Display variants
357    #[test]
358    fn error_display_all_variants() {
359        assert!(!CorrelationIdError::Empty.to_string().is_empty());
360        assert!(!CorrelationIdError::TooLong.to_string().is_empty());
361        assert!(!CorrelationIdError::InvalidChars.to_string().is_empty());
362    }
363}