Skip to main content

api_bones/
request_id.rs

1//! Standalone `RequestId` newtype for tracing HTTP requests end-to-end.
2//!
3//! `RequestId` is a UUID v4 wrapper that surfaces the `X-Request-Id` header
4//! convention used across many API frameworks and proxies. It is reusable in
5//! both [`crate::error::ApiError`] and [`crate::response::ResponseMeta`].
6//!
7//! # Example
8//!
9//! ```rust
10//! use api_bones::request_id::RequestId;
11//!
12//! let id = RequestId::new();
13//! assert_eq!(id.header_name(), "X-Request-Id");
14//! assert!(!id.to_string().is_empty());
15//! ```
16
17#[cfg(all(not(feature = "std"), feature = "alloc"))]
18use alloc::string::{String, ToString};
19use core::fmt;
20use core::str::FromStr;
21#[cfg(feature = "serde")]
22use serde::{Deserialize, Deserializer, Serialize};
23
24// ---------------------------------------------------------------------------
25// RequestIdError
26// ---------------------------------------------------------------------------
27
28/// Error returned when parsing a [`RequestId`] from a string fails.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum RequestIdError {
31    /// The string is not a valid UUID.
32    InvalidUuid(uuid::Error),
33}
34
35impl fmt::Display for RequestIdError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::InvalidUuid(e) => write!(f, "invalid request ID: {e}"),
39        }
40    }
41}
42
43#[cfg(feature = "std")]
44impl std::error::Error for RequestIdError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            Self::InvalidUuid(e) => Some(e),
48        }
49    }
50}
51
52/// Backwards-compatible alias — prefer [`RequestIdError`].
53pub type RequestIdParseError = RequestIdError;
54
55// ---------------------------------------------------------------------------
56// RequestId
57// ---------------------------------------------------------------------------
58
59/// A UUID v4 request identifier, typically propagated via the `X-Request-Id`
60/// HTTP header.
61///
62/// Use [`RequestId::new`] to generate a fresh identifier, or
63/// [`RequestId::from_str`] / [`TryFrom`] to parse one from an incoming header.
64///
65/// The `Display` implementation produces the canonical hyphenated UUID string
66/// (e.g. `550e8400-e29b-41d4-a716-446655440000`).
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68#[cfg_attr(feature = "serde", derive(Serialize))]
69#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
70#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
71pub struct RequestId(uuid::Uuid);
72
73impl RequestId {
74    /// Generate a new random `RequestId` (UUID v4).
75    ///
76    /// ```rust
77    /// use api_bones::request_id::RequestId;
78    ///
79    /// let id = RequestId::new();
80    /// assert_eq!(id.as_uuid().get_version_num(), 4);
81    /// ```
82    #[must_use]
83    pub fn new() -> Self {
84        Self(uuid::Uuid::new_v4())
85    }
86
87    /// Wrap an existing [`uuid::Uuid`] as a `RequestId`.
88    ///
89    /// ```rust
90    /// use api_bones::request_id::RequestId;
91    ///
92    /// let id = RequestId::from_uuid(uuid::Uuid::nil());
93    /// assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
94    /// ```
95    #[must_use]
96    pub fn from_uuid(id: uuid::Uuid) -> Self {
97        Self(id)
98    }
99
100    /// Return the inner [`uuid::Uuid`].
101    #[must_use]
102    pub fn as_uuid(&self) -> uuid::Uuid {
103        self.0
104    }
105
106    /// The canonical HTTP header name for this identifier: `X-Request-Id`.
107    #[must_use]
108    pub fn header_name(&self) -> &'static str {
109        "X-Request-Id"
110    }
111
112    /// Return the hyphenated UUID string representation.
113    #[must_use]
114    pub fn as_str(&self) -> String {
115        self.0.to_string()
116    }
117}
118
119// ---------------------------------------------------------------------------
120// HeaderId trait impl
121// ---------------------------------------------------------------------------
122
123#[cfg(feature = "std")]
124impl crate::header_id::HeaderId for RequestId {
125    const HEADER_NAME: &'static str = "X-Request-Id";
126
127    fn as_str(&self) -> std::borrow::Cow<'_, str> {
128        std::borrow::Cow::Owned(self.0.to_string())
129    }
130}
131
132#[cfg(all(not(feature = "std"), feature = "alloc"))]
133impl crate::header_id::HeaderId for RequestId {
134    const HEADER_NAME: &'static str = "X-Request-Id";
135
136    fn as_str(&self) -> alloc::borrow::Cow<'_, str> {
137        alloc::borrow::Cow::Owned(self.0.to_string())
138    }
139}
140
141// ---------------------------------------------------------------------------
142
143impl Default for RequestId {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl fmt::Display for RequestId {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        fmt::Display::fmt(&self.0, f)
152    }
153}
154
155impl From<uuid::Uuid> for RequestId {
156    fn from(id: uuid::Uuid) -> Self {
157        Self(id)
158    }
159}
160
161impl From<RequestId> for uuid::Uuid {
162    fn from(r: RequestId) -> Self {
163        r.0
164    }
165}
166
167impl FromStr for RequestId {
168    type Err = RequestIdError;
169
170    fn from_str(s: &str) -> Result<Self, Self::Err> {
171        uuid::Uuid::parse_str(s)
172            .map(Self)
173            .map_err(RequestIdError::InvalidUuid)
174    }
175}
176
177impl TryFrom<&str> for RequestId {
178    type Error = RequestIdError;
179
180    fn try_from(s: &str) -> Result<Self, Self::Error> {
181        s.parse()
182    }
183}
184
185impl TryFrom<String> for RequestId {
186    type Error = RequestIdError;
187
188    fn try_from(s: String) -> Result<Self, Self::Error> {
189        s.parse()
190    }
191}
192
193// ---------------------------------------------------------------------------
194// Serde
195// ---------------------------------------------------------------------------
196
197#[cfg(feature = "serde")]
198impl<'de> Deserialize<'de> for RequestId {
199    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
200        let s = String::deserialize(deserializer)?;
201        s.parse::<Self>().map_err(serde::de::Error::custom)
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Axum extractor
207// ---------------------------------------------------------------------------
208
209#[cfg(feature = "axum")]
210impl<S: Send + Sync> axum::extract::FromRequestParts<S> for RequestId {
211    type Rejection = crate::error::ApiError;
212
213    async fn from_request_parts(
214        parts: &mut axum::http::request::Parts,
215        _state: &S,
216    ) -> Result<Self, Self::Rejection> {
217        let raw = parts
218            .headers
219            .get("x-request-id")
220            .ok_or_else(|| {
221                crate::error::ApiError::bad_request("missing required header: x-request-id")
222            })?
223            .to_str()
224            .map_err(|_| {
225                crate::error::ApiError::bad_request("header x-request-id contains non-UTF-8 bytes")
226            })?;
227        raw.parse::<Self>()
228            .map_err(|e| crate::error::ApiError::bad_request(format!("invalid X-Request-Id: {e}")))
229    }
230}
231
232// ---------------------------------------------------------------------------
233// Tests
234// ---------------------------------------------------------------------------
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn new_generates_v4() {
242        let id = RequestId::new();
243        assert_eq!(id.as_uuid().get_version_num(), 4);
244    }
245
246    #[test]
247    fn from_uuid_roundtrip() {
248        let uuid = uuid::Uuid::nil();
249        let id = RequestId::from_uuid(uuid);
250        assert_eq!(id.as_uuid(), uuid);
251    }
252
253    #[test]
254    fn display_is_hyphenated_uuid() {
255        let id = RequestId::from_uuid(uuid::Uuid::nil());
256        assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
257    }
258
259    #[test]
260    fn header_name() {
261        let id = RequestId::new();
262        assert_eq!(id.header_name(), "X-Request-Id");
263    }
264
265    #[test]
266    fn from_str_valid() {
267        let s = "550e8400-e29b-41d4-a716-446655440000";
268        let id: RequestId = s.parse().unwrap();
269        assert_eq!(id.to_string(), s);
270    }
271
272    #[test]
273    fn from_str_invalid() {
274        assert!("not-a-uuid".parse::<RequestId>().is_err());
275    }
276
277    #[test]
278    fn try_from_str() {
279        let s = "00000000-0000-0000-0000-000000000000";
280        let id = RequestId::try_from(s).unwrap();
281        assert_eq!(id.to_string(), s);
282    }
283
284    #[test]
285    fn from_into_uuid() {
286        let uuid = uuid::Uuid::new_v4();
287        let id = RequestId::from(uuid);
288        let back: uuid::Uuid = id.into();
289        assert_eq!(back, uuid);
290    }
291
292    #[test]
293    fn default_generates_new() {
294        let id = RequestId::default();
295        assert_eq!(id.as_uuid().get_version_num(), 4);
296    }
297
298    #[cfg(feature = "serde")]
299    #[test]
300    fn serde_roundtrip() {
301        let id = RequestId::from_uuid(uuid::Uuid::nil());
302        let json = serde_json::to_string(&id).unwrap();
303        assert_eq!(json, r#""00000000-0000-0000-0000-000000000000""#);
304        let back: RequestId = serde_json::from_str(&json).unwrap();
305        assert_eq!(back, id);
306    }
307
308    #[cfg(feature = "serde")]
309    #[test]
310    fn serde_deserialize_invalid_rejects() {
311        let result: Result<RequestId, _> = serde_json::from_str(r#""not-a-uuid""#);
312        assert!(result.is_err());
313    }
314
315    // -----------------------------------------------------------------------
316    // Coverage gaps: RequestIdError Display, source, RequestId::as_str
317    // -----------------------------------------------------------------------
318
319    #[test]
320    fn request_id_as_str() {
321        let id = RequestId::from_uuid(uuid::Uuid::nil());
322        assert_eq!(id.as_str(), "00000000-0000-0000-0000-000000000000");
323    }
324
325    #[test]
326    fn parse_error_display() {
327        let err = "not-a-uuid".parse::<RequestId>().unwrap_err();
328        let s = err.to_string();
329        assert!(s.contains("invalid request ID"));
330    }
331
332    #[cfg(feature = "std")]
333    #[test]
334    fn parse_error_source() {
335        use std::error::Error as _;
336        let err = "not-a-uuid".parse::<RequestId>().unwrap_err();
337        assert!(err.source().is_some());
338    }
339
340    #[test]
341    fn try_from_string_valid() {
342        let s = "550e8400-e29b-41d4-a716-446655440000".to_owned();
343        let id = RequestId::try_from(s).unwrap();
344        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
345    }
346}