#[cfg(all(not(feature = "std"), feature = "alloc"))]
use alloc::{borrow::ToOwned, string::String, string::ToString};
use core::{fmt, ops::Deref};
#[cfg(feature = "serde")]
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum IdempotencyKeyError {
#[error("idempotency key must not be empty")]
Empty,
#[error("idempotency key must not exceed 255 characters")]
TooLong,
#[error("idempotency key may only contain printable ASCII characters (0x20–0x7E)")]
InvalidChars,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct IdempotencyKey(String);
impl IdempotencyKey {
pub fn new(s: impl AsRef<str>) -> Result<Self, IdempotencyKeyError> {
let s = s.as_ref();
if s.is_empty() {
return Err(IdempotencyKeyError::Empty);
}
if s.len() > 255 {
return Err(IdempotencyKeyError::TooLong);
}
if !s.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
return Err(IdempotencyKeyError::InvalidChars);
}
Ok(Self(s.to_owned()))
}
#[must_use]
pub fn from_uuid() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
#[must_use]
pub fn header_name(&self) -> &'static str {
"Idempotency-Key"
}
}
#[cfg(feature = "std")]
impl crate::header_id::HeaderId for IdempotencyKey {
const HEADER_NAME: &'static str = "Idempotency-Key";
fn as_str(&self) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(&self.0)
}
}
#[cfg(all(not(feature = "std"), feature = "alloc"))]
impl crate::header_id::HeaderId for IdempotencyKey {
const HEADER_NAME: &'static str = "Idempotency-Key";
fn as_str(&self) -> alloc::borrow::Cow<'_, str> {
alloc::borrow::Cow::Borrowed(&self.0)
}
}
impl Deref for IdempotencyKey {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for IdempotencyKey {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for IdempotencyKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for IdempotencyKey {
type Error = IdempotencyKeyError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for IdempotencyKey {
type Error = IdempotencyKeyError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for IdempotencyKey {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "axum")]
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for IdempotencyKey {
type Rejection = crate::error::ApiError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let raw = parts
.headers
.get("idempotency-key")
.ok_or_else(|| {
crate::error::ApiError::bad_request("missing required header: idempotency-key")
})?
.to_str()
.map_err(|_| {
crate::error::ApiError::bad_request(
"header idempotency-key contains non-UTF-8 bytes",
)
})?;
Self::new(raw).map_err(|e| {
crate::error::ApiError::bad_request(format!("invalid Idempotency-Key: {e}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_key_is_accepted() {
assert!(IdempotencyKey::new("abc-123").is_ok());
assert!(IdempotencyKey::new("x").is_ok());
assert!(IdempotencyKey::new("Hello World!").is_ok());
assert!(IdempotencyKey::new(" ").is_ok()); assert!(IdempotencyKey::new("~").is_ok()); }
#[test]
fn empty_is_rejected() {
assert_eq!(IdempotencyKey::new(""), Err(IdempotencyKeyError::Empty));
}
#[test]
fn too_long_is_rejected() {
let s: String = "a".repeat(256);
assert_eq!(IdempotencyKey::new(&s), Err(IdempotencyKeyError::TooLong));
}
#[test]
fn exactly_255_chars_is_accepted() {
let s: String = "a".repeat(255);
assert!(IdempotencyKey::new(&s).is_ok());
}
#[test]
fn control_char_is_rejected() {
assert_eq!(
IdempotencyKey::new("ab\x00cd"),
Err(IdempotencyKeyError::InvalidChars)
);
assert_eq!(
IdempotencyKey::new("ab\ncd"),
Err(IdempotencyKeyError::InvalidChars)
);
}
#[test]
fn non_ascii_is_rejected() {
assert_eq!(
IdempotencyKey::new("héllo"),
Err(IdempotencyKeyError::InvalidChars)
);
}
#[test]
fn from_uuid_produces_valid_key() {
let key = IdempotencyKey::from_uuid();
assert_eq!(key.as_str().len(), 36);
assert!(IdempotencyKey::new(key.as_str()).is_ok());
}
#[test]
fn deref_to_str() {
let key = IdempotencyKey::new("hello").unwrap();
let s: &str = &key;
assert_eq!(s, "hello");
}
#[test]
fn display() {
let key = IdempotencyKey::new("test-key").unwrap();
assert_eq!(format!("{key}"), "test-key");
}
#[test]
fn try_from_str() {
assert!(IdempotencyKey::try_from("valid").is_ok());
assert!(IdempotencyKey::try_from("").is_err());
}
#[test]
fn try_from_string() {
assert!(IdempotencyKey::try_from("valid".to_owned()).is_ok());
}
#[test]
fn into_string() {
let key = IdempotencyKey::new("abc").unwrap();
assert_eq!(key.into_string(), "abc");
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let key = IdempotencyKey::new("my-key-123").unwrap();
let json = serde_json::to_string(&key).unwrap();
assert_eq!(json, r#""my-key-123""#);
let back: IdempotencyKey = serde_json::from_str(&json).unwrap();
assert_eq!(back, key);
}
#[cfg(feature = "serde")]
#[test]
fn serde_deserialize_invalid_rejects() {
let result: Result<IdempotencyKey, _> = serde_json::from_str(r#""""#);
assert!(result.is_err());
}
#[test]
fn as_ref_str() {
let key = IdempotencyKey::new("my-key").unwrap();
let s: &str = key.as_ref();
assert_eq!(s, "my-key");
}
#[test]
fn error_display_all_variants() {
assert!(!IdempotencyKeyError::Empty.to_string().is_empty());
assert!(!IdempotencyKeyError::TooLong.to_string().is_empty());
assert!(!IdempotencyKeyError::InvalidChars.to_string().is_empty());
}
}