Skip to main content

api_bones/
idempotency.rs

1//! `IdempotencyKey` newtype for safe retry of non-idempotent HTTP methods.
2//!
3//! An idempotency key is an opaque string (or UUID) that a client sends once
4//! per logical operation. The server uses it to detect duplicate requests and
5//! return the cached outcome instead of re-executing the operation.
6//!
7//! # Constraints
8//!
9//! - Length: 1–255 characters (inclusive).
10//! - Characters: printable ASCII only (`0x20`–`0x7E`), i.e. no control
11//!   characters or non-ASCII bytes.
12//!
13//! # Example
14//!
15//! ```rust
16//! use api_bones::idempotency::IdempotencyKey;
17//!
18//! // From an arbitrary string
19//! let key = IdempotencyKey::new("my-op-abc123").unwrap();
20//! assert_eq!(key.as_str(), "my-op-abc123");
21//!
22//! // From a freshly generated UUID
23//! let key = IdempotencyKey::from_uuid();
24//! assert!(!key.as_str().is_empty());
25//! ```
26
27#[cfg(all(not(feature = "std"), feature = "alloc"))]
28use alloc::{borrow::ToOwned, string::String, string::ToString};
29use core::{fmt, ops::Deref};
30#[cfg(feature = "serde")]
31use serde::{Deserialize, Deserializer, Serialize};
32use thiserror::Error;
33
34// ---------------------------------------------------------------------------
35// IdempotencyKeyError
36// ---------------------------------------------------------------------------
37
38/// Errors that can occur when constructing an [`IdempotencyKey`].
39#[derive(Debug, Clone, PartialEq, Eq, Error)]
40pub enum IdempotencyKeyError {
41    /// The input was empty.
42    #[error("idempotency key must not be empty")]
43    Empty,
44    /// The input exceeds 255 characters.
45    #[error("idempotency key must not exceed 255 characters")]
46    TooLong,
47    /// The input contains non-printable or non-ASCII characters.
48    #[error("idempotency key may only contain printable ASCII characters (0x20–0x7E)")]
49    InvalidChars,
50}
51
52// ---------------------------------------------------------------------------
53// IdempotencyKey
54// ---------------------------------------------------------------------------
55
56/// A validated idempotency key for safe POST/PATCH retry semantics.
57///
58/// See the [module-level documentation](self) for the full invariant set.
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
60#[cfg_attr(feature = "serde", derive(Serialize))]
61#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
62#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
63pub struct IdempotencyKey(String);
64
65impl IdempotencyKey {
66    /// Construct an `IdempotencyKey` from any string, returning an error if the
67    /// value violates any constraint.
68    ///
69    /// # Errors
70    ///
71    /// Returns an [`IdempotencyKeyError`] variant that describes which
72    /// constraint failed.
73    ///
74    /// ```rust
75    /// use api_bones::idempotency::{IdempotencyKey, IdempotencyKeyError};
76    ///
77    /// assert!(IdempotencyKey::new("abc-123").is_ok());
78    /// assert_eq!(IdempotencyKey::new(""), Err(IdempotencyKeyError::Empty));
79    /// assert!(IdempotencyKey::new("a\x00b").is_err());
80    /// ```
81    pub fn new(s: impl AsRef<str>) -> Result<Self, IdempotencyKeyError> {
82        let s = s.as_ref();
83        if s.is_empty() {
84            return Err(IdempotencyKeyError::Empty);
85        }
86        if s.len() > 255 {
87            return Err(IdempotencyKeyError::TooLong);
88        }
89        if !s.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
90            return Err(IdempotencyKeyError::InvalidChars);
91        }
92        Ok(Self(s.to_owned()))
93    }
94
95    /// Generate a fresh `IdempotencyKey` backed by a UUID v4.
96    ///
97    /// The resulting key is the standard hyphenated UUID string, e.g.
98    /// `"550e8400-e29b-41d4-a716-446655440000"`.
99    ///
100    /// ```rust
101    /// use api_bones::idempotency::IdempotencyKey;
102    ///
103    /// let key = IdempotencyKey::from_uuid();
104    /// assert_eq!(key.as_str().len(), 36);
105    /// ```
106    #[must_use]
107    pub fn from_uuid() -> Self {
108        // SAFETY: UUID hyphenated string is always 36 printable ASCII chars.
109        Self(uuid::Uuid::new_v4().to_string())
110    }
111
112    /// Return the inner string slice.
113    ///
114    /// ```rust
115    /// use api_bones::idempotency::IdempotencyKey;
116    ///
117    /// let key = IdempotencyKey::new("abc").unwrap();
118    /// assert_eq!(key.as_str(), "abc");
119    /// ```
120    #[must_use]
121    pub fn as_str(&self) -> &str {
122        &self.0
123    }
124
125    /// Consume the key and return the underlying `String`.
126    ///
127    /// ```rust
128    /// use api_bones::idempotency::IdempotencyKey;
129    ///
130    /// let key = IdempotencyKey::new("abc").unwrap();
131    /// assert_eq!(key.into_string(), "abc");
132    /// ```
133    #[must_use]
134    pub fn into_string(self) -> String {
135        self.0
136    }
137
138    /// The canonical HTTP header name: `Idempotency-Key`.
139    ///
140    /// ```rust
141    /// use api_bones::idempotency::IdempotencyKey;
142    ///
143    /// let key = IdempotencyKey::new("abc").unwrap();
144    /// assert_eq!(key.header_name(), "Idempotency-Key");
145    /// ```
146    #[must_use]
147    pub fn header_name(&self) -> &'static str {
148        "Idempotency-Key"
149    }
150}
151
152// ---------------------------------------------------------------------------
153// HeaderId trait impl
154// ---------------------------------------------------------------------------
155
156#[cfg(feature = "std")]
157impl crate::header_id::HeaderId for IdempotencyKey {
158    const HEADER_NAME: &'static str = "Idempotency-Key";
159
160    fn as_str(&self) -> std::borrow::Cow<'_, str> {
161        std::borrow::Cow::Borrowed(&self.0)
162    }
163}
164
165#[cfg(all(not(feature = "std"), feature = "alloc"))]
166impl crate::header_id::HeaderId for IdempotencyKey {
167    const HEADER_NAME: &'static str = "Idempotency-Key";
168
169    fn as_str(&self) -> alloc::borrow::Cow<'_, str> {
170        alloc::borrow::Cow::Borrowed(&self.0)
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Standard trait impls
176// ---------------------------------------------------------------------------
177
178impl Deref for IdempotencyKey {
179    type Target = str;
180
181    fn deref(&self) -> &str {
182        &self.0
183    }
184}
185
186impl AsRef<str> for IdempotencyKey {
187    fn as_ref(&self) -> &str {
188        &self.0
189    }
190}
191
192impl fmt::Display for IdempotencyKey {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.write_str(&self.0)
195    }
196}
197
198impl TryFrom<String> for IdempotencyKey {
199    type Error = IdempotencyKeyError;
200
201    fn try_from(s: String) -> Result<Self, Self::Error> {
202        Self::new(s)
203    }
204}
205
206impl TryFrom<&str> for IdempotencyKey {
207    type Error = IdempotencyKeyError;
208
209    fn try_from(s: &str) -> Result<Self, Self::Error> {
210        Self::new(s)
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Serde
216// ---------------------------------------------------------------------------
217
218#[cfg(feature = "serde")]
219impl<'de> Deserialize<'de> for IdempotencyKey {
220    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
221        let s = String::deserialize(deserializer)?;
222        Self::new(&s).map_err(serde::de::Error::custom)
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Axum extractor
228// ---------------------------------------------------------------------------
229
230#[cfg(feature = "axum")]
231impl<S: Send + Sync> axum::extract::FromRequestParts<S> for IdempotencyKey {
232    type Rejection = crate::error::ApiError;
233
234    async fn from_request_parts(
235        parts: &mut axum::http::request::Parts,
236        _state: &S,
237    ) -> Result<Self, Self::Rejection> {
238        let raw = parts
239            .headers
240            .get("idempotency-key")
241            .ok_or_else(|| {
242                crate::error::ApiError::bad_request("missing required header: idempotency-key")
243            })?
244            .to_str()
245            .map_err(|_| {
246                crate::error::ApiError::bad_request(
247                    "header idempotency-key contains non-UTF-8 bytes",
248                )
249            })?;
250        Self::new(raw).map_err(|e| {
251            crate::error::ApiError::bad_request(format!("invalid Idempotency-Key: {e}"))
252        })
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Tests
258// ---------------------------------------------------------------------------
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn valid_key_is_accepted() {
266        assert!(IdempotencyKey::new("abc-123").is_ok());
267        assert!(IdempotencyKey::new("x").is_ok());
268        assert!(IdempotencyKey::new("Hello World!").is_ok());
269        // printable ASCII boundary chars
270        assert!(IdempotencyKey::new(" ").is_ok()); // 0x20
271        assert!(IdempotencyKey::new("~").is_ok()); // 0x7E
272    }
273
274    #[test]
275    fn empty_is_rejected() {
276        assert_eq!(IdempotencyKey::new(""), Err(IdempotencyKeyError::Empty));
277    }
278
279    #[test]
280    fn too_long_is_rejected() {
281        let s: String = "a".repeat(256);
282        assert_eq!(IdempotencyKey::new(&s), Err(IdempotencyKeyError::TooLong));
283    }
284
285    #[test]
286    fn exactly_255_chars_is_accepted() {
287        let s: String = "a".repeat(255);
288        assert!(IdempotencyKey::new(&s).is_ok());
289    }
290
291    #[test]
292    fn control_char_is_rejected() {
293        assert_eq!(
294            IdempotencyKey::new("ab\x00cd"),
295            Err(IdempotencyKeyError::InvalidChars)
296        );
297        assert_eq!(
298            IdempotencyKey::new("ab\ncd"),
299            Err(IdempotencyKeyError::InvalidChars)
300        );
301    }
302
303    #[test]
304    fn non_ascii_is_rejected() {
305        assert_eq!(
306            IdempotencyKey::new("héllo"),
307            Err(IdempotencyKeyError::InvalidChars)
308        );
309    }
310
311    #[test]
312    fn from_uuid_produces_valid_key() {
313        let key = IdempotencyKey::from_uuid();
314        // UUID v4 hyphenated = 36 chars, all printable ASCII
315        assert_eq!(key.as_str().len(), 36);
316        assert!(IdempotencyKey::new(key.as_str()).is_ok());
317    }
318
319    #[test]
320    fn deref_to_str() {
321        let key = IdempotencyKey::new("hello").unwrap();
322        let s: &str = &key;
323        assert_eq!(s, "hello");
324    }
325
326    #[test]
327    fn display() {
328        let key = IdempotencyKey::new("test-key").unwrap();
329        assert_eq!(format!("{key}"), "test-key");
330    }
331
332    #[test]
333    fn try_from_str() {
334        assert!(IdempotencyKey::try_from("valid").is_ok());
335        assert!(IdempotencyKey::try_from("").is_err());
336    }
337
338    #[test]
339    fn try_from_string() {
340        assert!(IdempotencyKey::try_from("valid".to_owned()).is_ok());
341    }
342
343    #[test]
344    fn into_string() {
345        let key = IdempotencyKey::new("abc").unwrap();
346        assert_eq!(key.into_string(), "abc");
347    }
348
349    #[cfg(feature = "serde")]
350    #[test]
351    fn serde_roundtrip() {
352        let key = IdempotencyKey::new("my-key-123").unwrap();
353        let json = serde_json::to_string(&key).unwrap();
354        assert_eq!(json, r#""my-key-123""#);
355        let back: IdempotencyKey = serde_json::from_str(&json).unwrap();
356        assert_eq!(back, key);
357    }
358
359    #[cfg(feature = "serde")]
360    #[test]
361    fn serde_deserialize_invalid_rejects() {
362        let result: Result<IdempotencyKey, _> = serde_json::from_str(r#""""#);
363        assert!(result.is_err());
364    }
365
366    // -----------------------------------------------------------------------
367    // Coverage gap: AsRef<str> impl
368    // -----------------------------------------------------------------------
369
370    #[test]
371    fn as_ref_str() {
372        let key = IdempotencyKey::new("my-key").unwrap();
373        let s: &str = key.as_ref();
374        assert_eq!(s, "my-key");
375    }
376
377    // Coverage gap: IdempotencyKeyError Display variants
378    #[test]
379    fn error_display_all_variants() {
380        assert!(!IdempotencyKeyError::Empty.to_string().is_empty());
381        assert!(!IdempotencyKeyError::TooLong.to_string().is_empty());
382        assert!(!IdempotencyKeyError::InvalidChars.to_string().is_empty());
383    }
384}