use derive_more::Display;
use std::{str::FromStr, sync::Arc};
use thiserror::Error;
const MAX_SEGMENT_LEN: usize = 255;
#[derive(Debug, Display, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(into = "String", try_from = "String")
)]
#[display("{entity_type}/{entity_id}")]
pub struct PersistenceId {
entity_type: Arc<str>,
entity_id: Arc<str>,
}
impl PersistenceId {
pub fn new<T, I>(entity_type: T, entity_id: I) -> Result<Self, InvalidPersistenceId>
where
T: AsRef<str>,
I: AsRef<str>,
{
let entity_type = valid_segment(entity_type.as_ref(), PersistenceIdSegment::EntityType)?;
let entity_id = valid_segment(entity_id.as_ref(), PersistenceIdSegment::EntityId)?;
Ok(Self {
entity_type: entity_type.into(),
entity_id: entity_id.into(),
})
}
pub fn entity_type(&self) -> &str {
&self.entity_type
}
pub fn entity_id(&self) -> &str {
&self.entity_id
}
}
impl FromStr for PersistenceId {
type Err = InvalidPersistenceId;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (entity_type, entity_id) = s
.split_once('/')
.ok_or(InvalidPersistenceId::MissingSeparator)?;
Self::new(entity_type, entity_id)
}
}
impl TryFrom<String> for PersistenceId {
type Error = InvalidPersistenceId;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<PersistenceId> for String {
fn from(id: PersistenceId) -> Self {
id.to_string()
}
}
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq)]
pub enum PersistenceIdSegment {
#[display("entity type")]
EntityType,
#[display("entity ID")]
EntityId,
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum InvalidPersistenceId {
#[error("empty {segment}")]
EmptySegment {
segment: PersistenceIdSegment,
},
#[error("{segment} longer than {MAX_SEGMENT_LEN} bytes")]
SegmentTooLong {
segment: PersistenceIdSegment,
},
#[error("{segment} contains a slash, the reserved separator")]
SlashInSegment {
segment: PersistenceIdSegment,
},
#[error("persistence ID without a slash separator")]
MissingSeparator,
}
fn valid_segment(value: &str, segment: PersistenceIdSegment) -> Result<&str, InvalidPersistenceId> {
if value.is_empty() {
Err(InvalidPersistenceId::EmptySegment { segment })
} else if value.len() > MAX_SEGMENT_LEN {
Err(InvalidPersistenceId::SegmentTooLong { segment })
} else if value.contains('/') {
Err(InvalidPersistenceId::SlashInSegment { segment })
} else {
Ok(value)
}
}
#[cfg(test)]
mod tests {
use crate::persistence::persistence_id::{
InvalidPersistenceId, PersistenceId, PersistenceIdSegment,
};
#[test]
fn display_and_from_str_round_trip() {
let id = PersistenceId::new("order", "42").expect("the segments are valid");
assert_eq!(id.to_string(), "order/42");
assert_eq!("order/42".parse::<PersistenceId>().as_ref(), Ok(&id));
assert_eq!(id.entity_type(), "order");
assert_eq!(id.entity_id(), "42");
}
#[test]
fn invalid_segments_are_rejected() {
assert_eq!(
PersistenceId::new("", "42"),
Err(InvalidPersistenceId::EmptySegment {
segment: PersistenceIdSegment::EntityType
})
);
assert_eq!(
PersistenceId::new("order", ""),
Err(InvalidPersistenceId::EmptySegment {
segment: PersistenceIdSegment::EntityId
})
);
assert_eq!(
PersistenceId::new("a".repeat(256), "42"),
Err(InvalidPersistenceId::SegmentTooLong {
segment: PersistenceIdSegment::EntityType
})
);
assert_eq!(
PersistenceId::new("order", "a".repeat(256)),
Err(InvalidPersistenceId::SegmentTooLong {
segment: PersistenceIdSegment::EntityId
})
);
assert_eq!(
PersistenceId::new("or/der", "42"),
Err(InvalidPersistenceId::SlashInSegment {
segment: PersistenceIdSegment::EntityType
})
);
assert_eq!(
PersistenceId::new("order", "4/2"),
Err(InvalidPersistenceId::SlashInSegment {
segment: PersistenceIdSegment::EntityId
})
);
assert_eq!(
"order-42".parse::<PersistenceId>(),
Err(InvalidPersistenceId::MissingSeparator)
);
}
#[cfg(feature = "serde")]
#[test]
fn serde_round_trips_as_a_string() {
let id = PersistenceId::new("order", "42").expect("the segments are valid");
let json = serde_json::to_string(&id).expect("the ID is serializable");
assert_eq!(json, "\"order/42\"");
assert_eq!(
serde_json::from_str::<PersistenceId>(&json).expect("the string is deserializable"),
id
);
}
#[cfg(feature = "serde")]
#[test]
fn serde_rejects_an_invalid_string() {
for invalid in ["\"order\"", "\"/42\"", "\"order/\""] {
assert!(
serde_json::from_str::<PersistenceId>(invalid).is_err(),
"{invalid} must not deserialize"
);
}
}
}