1#[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#[derive(Debug, Clone, PartialEq, Eq, Error)]
40pub enum IdempotencyKeyError {
41 #[error("idempotency key must not be empty")]
43 Empty,
44 #[error("idempotency key must not exceed 255 characters")]
46 TooLong,
47 #[error("idempotency key may only contain printable ASCII characters (0x20–0x7E)")]
49 InvalidChars,
50}
51
52#[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 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 #[must_use]
107 pub fn from_uuid() -> Self {
108 Self(uuid::Uuid::new_v4().to_string())
110 }
111
112 #[must_use]
121 pub fn as_str(&self) -> &str {
122 &self.0
123 }
124
125 #[must_use]
134 pub fn into_string(self) -> String {
135 self.0
136 }
137
138 #[must_use]
147 pub fn header_name(&self) -> &'static str {
148 "Idempotency-Key"
149 }
150}
151
152#[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
174impl 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#[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#[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#[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 assert!(IdempotencyKey::new(" ").is_ok()); assert!(IdempotencyKey::new("~").is_ok()); }
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 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 #[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 #[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}