use std::fmt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub const MAX_NAME_LEN: usize = 200;
pub const MAX_KEY_LEN: usize = 512;
const IDEMPOTENCY_NAMESPACE: Uuid = Uuid::from_u128(0x5b2f_9c1e_7a4d_4e0b_9f3a_6c8d_1e2f_4a70);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EffectId(Uuid);
impl EffectId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
pub const fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub const fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl Default for EffectId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for EffectId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum IdentityError {
#[error("{0} must not be empty")]
Empty(&'static str),
#[error("{what} is {len} bytes, maximum is {max}")]
TooLong {
what: &'static str,
len: usize,
max: usize,
},
#[error("{0} must not contain control characters")]
ControlCharacter(&'static str),
}
fn validate(what: &'static str, value: &str, max: usize) -> Result<(), IdentityError> {
if value.is_empty() {
return Err(IdentityError::Empty(what));
}
if value.len() > max {
return Err(IdentityError::TooLong {
what,
len: value.len(),
max,
});
}
if value.chars().any(char::is_control) {
return Err(IdentityError::ControlCharacter(what));
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct EffectName(String);
impl EffectName {
pub fn new(name: impl Into<String>) -> Result<Self, IdentityError> {
let name = name.into();
validate("effect name", &name, MAX_NAME_LEN)?;
Ok(Self(name))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for EffectName {
type Error = IdentityError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<EffectName> for String {
fn from(value: EffectName) -> Self {
value.0
}
}
impl fmt::Display for EffectName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct LogicalKey(String);
impl LogicalKey {
pub fn new(key: impl Into<String>) -> Result<Self, IdentityError> {
let key = key.into();
validate("logical key", &key, MAX_KEY_LEN)?;
Ok(Self(key))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for LogicalKey {
type Error = IdentityError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<LogicalKey> for String {
fn from(value: LogicalKey) -> Self {
value.0
}
}
impl fmt::Display for LogicalKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct EffectKey {
pub name: EffectName,
pub key: LogicalKey,
}
impl EffectKey {
pub const fn new(name: EffectName, key: LogicalKey) -> Self {
Self { name, key }
}
pub fn idempotency_key(&self) -> IdempotencyKey {
let material = format!("{}:{}:{}", self.name.0.len(), self.name.0, self.key.0);
IdempotencyKey(Uuid::new_v5(&IDEMPOTENCY_NAMESPACE, material.as_bytes()))
}
pub fn compensation_idempotency_key(&self) -> IdempotencyKey {
let material = format!(
"compensate:{}:{}:{}",
self.name.0.len(),
self.name.0,
self.key.0
);
IdempotencyKey(Uuid::new_v5(&IDEMPOTENCY_NAMESPACE, material.as_bytes()))
}
}
impl fmt::Display for EffectKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.name, self.key)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct IdempotencyKey(Uuid);
impl IdempotencyKey {
pub const fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl fmt::Display for IdempotencyKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct WorkerId(String);
impl WorkerId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn random() -> Self {
Self(Uuid::now_v7().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for WorkerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(name: &str, key: &str) -> EffectKey {
EffectKey::new(
EffectName::new(name).unwrap(),
LogicalKey::new(key).unwrap(),
)
}
#[test]
fn idempotency_key_is_stable() {
assert_eq!(
key("payment.charge", "order_5824")
.idempotency_key()
.to_string(),
"ab211c89-566b-5012-bd33-3a7f81351a08"
);
}
#[test]
fn compensation_key_is_stable_and_distinct() {
let effect = key("payment.charge", "order_5824");
assert_eq!(
effect.compensation_idempotency_key().to_string(),
"46877ef3-8997-5e94-a1ac-4be0bd2782b4"
);
assert_ne!(
effect.compensation_idempotency_key(),
effect.idempotency_key()
);
}
#[test]
fn idempotency_key_separates_ambiguous_splits() {
assert_ne!(
key("a:b", "c").idempotency_key(),
key("a", "b:c").idempotency_key()
);
}
#[test]
fn rejects_invalid_identity() {
assert_eq!(
EffectName::new(""),
Err(IdentityError::Empty("effect name"))
);
assert!(matches!(
LogicalKey::new("x".repeat(MAX_KEY_LEN + 1)),
Err(IdentityError::TooLong { .. })
));
assert_eq!(
LogicalKey::new("a\nb"),
Err(IdentityError::ControlCharacter("logical key"))
);
}
#[test]
fn deserialization_validates() {
assert!(serde_json::from_str::<EffectName>("\"\"").is_err());
let name: EffectName = serde_json::from_str("\"payment.charge\"").unwrap();
assert_eq!(name.as_str(), "payment.charge");
}
#[test]
fn effect_ids_are_time_ordered() {
let a = EffectId::new();
let b = EffectId::new();
assert!(a < b);
}
}