Skip to main content

api_bones/
header_id.rs

1//! Shared [`HeaderId`] trait for newtype wrappers that are transported via a
2//! dedicated HTTP header.
3//!
4//! All ID newtypes in this crate — [`crate::request_id::RequestId`],
5//! [`crate::correlation_id::CorrelationId`],
6//! [`crate::idempotency::IdempotencyKey`], and
7//! [`crate::traceparent::TraceContext`] — follow the same pattern:
8//!
9//! - They carry a fixed, well-known header name.
10//! - They expose their value as a string.
11//!
12//! `HeaderId` makes that pattern explicit and enables generic middleware or
13//! helper utilities that work over any of these types.
14//!
15//! # Example
16//!
17//! ```rust
18//! use api_bones::header_id::HeaderId;
19//! use api_bones::request_id::RequestId;
20//! use api_bones::correlation_id::CorrelationId;
21//! use api_bones::idempotency::IdempotencyKey;
22//! use api_bones::traceparent::TraceContext;
23//!
24//! fn header_name_of<T: HeaderId>() -> &'static str {
25//!     T::HEADER_NAME
26//! }
27//!
28//! assert_eq!(header_name_of::<RequestId>(), "X-Request-Id");
29//! assert_eq!(header_name_of::<CorrelationId>(), "X-Correlation-Id");
30//! assert_eq!(header_name_of::<IdempotencyKey>(), "Idempotency-Key");
31//! assert_eq!(header_name_of::<TraceContext>(), "traceparent");
32//! ```
33
34#[cfg(all(not(feature = "std"), feature = "alloc"))]
35use alloc::borrow::Cow;
36#[cfg(feature = "std")]
37use std::borrow::Cow;
38
39// ---------------------------------------------------------------------------
40// HeaderId trait
41// ---------------------------------------------------------------------------
42
43/// A type that is transported as a single HTTP header value.
44///
45/// Implementors provide:
46/// - [`HEADER_NAME`](HeaderId::HEADER_NAME) — the canonical header name
47///   exactly as it appears in an HTTP request or response, e.g.
48///   `"X-Request-Id"`.
49/// - [`as_str`](HeaderId::as_str) — the string representation of the value,
50///   borrowed when the value is already stored as a `String`, or owned when
51///   derived on the fly (e.g. for UUID-backed or structured types).
52///
53/// # Generic middleware
54///
55/// The trait enables writing helpers that work uniformly over any header-ID
56/// type without duplicating the header-lookup logic:
57///
58/// ```rust
59/// use api_bones::header_id::HeaderId;
60///
61/// fn log_header<T: HeaderId>(value: &T) {
62///     println!("{}: {}", T::HEADER_NAME, value.as_str());
63/// }
64/// ```
65///
66/// In axum middleware you can use `T::HEADER_NAME` to look up the right header
67/// for any `T: HeaderId + FromStr`:
68///
69/// ```rust,ignore
70/// fn extract_header<T>(parts: &axum::http::request::Parts)
71///     -> Result<T, api_bones::ApiError>
72/// where
73///     T: HeaderId + core::str::FromStr,
74///     T::Err: core::fmt::Display,
75/// {
76///     let raw = parts
77///         .headers
78///         .get(T::HEADER_NAME)
79///         .ok_or_else(|| api_bones::ApiError::bad_request(
80///             format!("missing required header: {}", T::HEADER_NAME)
81///         ))?
82///         .to_str()
83///         .map_err(|_| api_bones::ApiError::bad_request(
84///             format!("header {} contains non-UTF-8 bytes", T::HEADER_NAME)
85///         ))?;
86///     raw.parse::<T>()
87///         .map_err(|e| api_bones::ApiError::bad_request(
88///             format!("invalid {}: {e}", T::HEADER_NAME)
89///         ))
90/// }
91/// ```
92#[cfg(any(feature = "std", feature = "alloc"))]
93pub trait HeaderId {
94    /// The canonical HTTP header name for this type.
95    ///
96    /// Examples: `"X-Request-Id"`, `"X-Correlation-Id"`,
97    /// `"Idempotency-Key"`, `"traceparent"`.
98    const HEADER_NAME: &'static str;
99
100    /// Return the string representation of this header value.
101    ///
102    /// Returns a [`Cow::Borrowed`] slice when the value is already stored as a
103    /// `String`, and a [`Cow::Owned`] string when the representation must be
104    /// computed (e.g. for UUID-backed or structured types).
105    fn as_str(&self) -> Cow<'_, str>;
106}
107
108// ---------------------------------------------------------------------------
109// Tests
110// ---------------------------------------------------------------------------
111
112#[cfg(all(test, feature = "uuid"))]
113mod tests {
114    use super::*;
115    use crate::correlation_id::CorrelationId;
116    use crate::idempotency::IdempotencyKey;
117    use crate::request_id::RequestId;
118    use crate::traceparent::TraceContext;
119
120    #[test]
121    fn header_names() {
122        assert_eq!(RequestId::HEADER_NAME, "X-Request-Id");
123        assert_eq!(CorrelationId::HEADER_NAME, "X-Correlation-Id");
124        assert_eq!(IdempotencyKey::HEADER_NAME, "Idempotency-Key");
125        assert_eq!(TraceContext::HEADER_NAME, "traceparent");
126    }
127
128    // Use a generic helper to force dispatch through the HeaderId trait,
129    // bypassing any inherent as_str() methods that would otherwise shadow it.
130    fn trait_as_str<T: HeaderId>(v: &T) -> Cow<'_, str> {
131        v.as_str()
132    }
133
134    #[test]
135    fn as_str_request_id() {
136        let id = RequestId::from_uuid(uuid::Uuid::nil());
137        assert_eq!(trait_as_str(&id), "00000000-0000-0000-0000-000000000000");
138    }
139
140    #[test]
141    fn as_str_correlation_id() {
142        let id = CorrelationId::new("corr-abc").unwrap();
143        assert_eq!(trait_as_str(&id), "corr-abc");
144    }
145
146    #[test]
147    fn as_str_idempotency_key() {
148        let key = IdempotencyKey::new("my-key").unwrap();
149        assert_eq!(trait_as_str(&key), "my-key");
150    }
151
152    #[test]
153    fn as_str_trace_context() {
154        let tc: TraceContext = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
155            .parse()
156            .unwrap();
157        assert_eq!(
158            trait_as_str(&tc),
159            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
160        );
161    }
162
163    #[test]
164    fn header_name_instance_methods() {
165        let key = IdempotencyKey::new("x").unwrap();
166        assert_eq!(key.header_name(), "Idempotency-Key");
167        let tc = TraceContext::new();
168        assert_eq!(tc.header_name(), "traceparent");
169    }
170
171    #[test]
172    fn generic_header_name_fn() {
173        fn header_name_of<T: HeaderId>() -> &'static str {
174            T::HEADER_NAME
175        }
176        assert_eq!(header_name_of::<RequestId>(), "X-Request-Id");
177        assert_eq!(header_name_of::<CorrelationId>(), "X-Correlation-Id");
178        assert_eq!(header_name_of::<IdempotencyKey>(), "Idempotency-Key");
179        assert_eq!(header_name_of::<TraceContext>(), "traceparent");
180    }
181}