use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum IDPolicy {
#[serde(rename = "uuid")]
#[default]
UUID,
#[serde(rename = "opaque")]
OPAQUE,
}
impl IDPolicy {
#[must_use]
pub fn enforces_uuid(self) -> bool {
self == Self::UUID
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UUID => "uuid",
Self::OPAQUE => "opaque",
}
}
}
impl std::fmt::Display for IDPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IDValidationError {
pub value: String,
pub policy: IDPolicy,
pub message: String,
}
impl std::fmt::Display for IDValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for IDValidationError {}
pub fn validate_id(id: &str, policy: IDPolicy) -> Result<(), IDValidationError> {
match policy {
IDPolicy::UUID => validate_uuid_format(id),
IDPolicy::OPAQUE => Ok(()), }
}
fn validate_uuid_format(id: &str) -> Result<(), IDValidationError> {
if id.len() != 36 {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: format!(
"ID must be a valid UUID (36 characters), got {} characters",
id.len()
),
});
}
let parts: Vec<&str> = id.split('-').collect();
if parts.len() != 5 {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: "ID must be a valid UUID with format XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
.to_string(),
});
}
let expected_lengths = [8, 4, 4, 4, 12];
for (i, (part, &expected_len)) in parts.iter().zip(&expected_lengths).enumerate() {
if part.len() != expected_len {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: format!(
"UUID segment {} has invalid length: expected {}, got {}",
i,
expected_len,
part.len()
),
});
}
}
for (i, part) in parts.iter().enumerate() {
if !part.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: format!("UUID segment {i} contains non-hexadecimal characters: '{part}'"),
});
}
}
Ok(())
}
#[allow(dead_code)] pub fn validate_ids(ids: &[&str], policy: IDPolicy) -> Result<(), IDValidationError> {
for id in ids {
validate_id(id, policy)?;
}
Ok(())
}
pub trait IdValidator: Send + Sync {
fn validate(&self, value: &str) -> Result<(), IDValidationError>;
fn format_name(&self) -> &'static str;
}
#[derive(Debug, Clone, Copy)]
pub struct UuidIdValidator;
impl IdValidator for UuidIdValidator {
fn validate(&self, value: &str) -> Result<(), IDValidationError> {
validate_uuid_format(value)
}
fn format_name(&self) -> &'static str {
"UUID"
}
}
#[derive(Debug, Clone, Copy)]
pub struct NumericIdValidator;
impl IdValidator for NumericIdValidator {
fn validate(&self, value: &str) -> Result<(), IDValidationError> {
value.parse::<i64>().map_err(|_| IDValidationError {
value: value.to_string(),
policy: IDPolicy::OPAQUE,
message: format!(
"ID must be a valid {} (parseable as 64-bit integer)",
self.format_name()
),
})?;
Ok(())
}
fn format_name(&self) -> &'static str {
"integer"
}
}
#[derive(Debug, Clone, Copy)]
pub struct UlidIdValidator;
impl IdValidator for UlidIdValidator {
fn validate(&self, value: &str) -> Result<(), IDValidationError> {
if value.len() != 26 {
return Err(IDValidationError {
value: value.to_string(),
policy: IDPolicy::OPAQUE,
message: format!(
"ID must be a valid {} ({} characters), got {}",
self.format_name(),
26,
value.len()
),
});
}
if !value.chars().all(|c| {
c.is_ascii_digit()
|| (c.is_ascii_uppercase() && c != 'I' && c != 'L' && c != 'O' && c != 'U')
}) {
return Err(IDValidationError {
value: value.to_string(),
policy: IDPolicy::OPAQUE,
message: format!(
"ID must be a valid {} (Crockford base32: 0-9, A-Z except I, L, O, U)",
self.format_name()
),
});
}
Ok(())
}
fn format_name(&self) -> &'static str {
"ULID"
}
}
#[derive(Debug, Clone, Copy)]
pub struct OpaqueIdValidator;
impl IdValidator for OpaqueIdValidator {
fn validate(&self, _value: &str) -> Result<(), IDValidationError> {
Ok(()) }
fn format_name(&self) -> &'static str {
"opaque"
}
}
#[derive(Debug, Clone)]
pub struct IDValidationProfile {
pub name: String,
pub validator: ValidationProfileType,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ValidationProfileType {
Uuid(UuidIdValidator),
Numeric(NumericIdValidator),
Ulid(UlidIdValidator),
Opaque(OpaqueIdValidator),
}
impl ValidationProfileType {
#[must_use]
pub fn as_validator(&self) -> &dyn IdValidator {
match self {
Self::Uuid(v) => v,
Self::Numeric(v) => v,
Self::Ulid(v) => v,
Self::Opaque(v) => v,
}
}
}
impl IDValidationProfile {
#[must_use]
pub fn uuid() -> Self {
Self {
name: "uuid".to_string(),
validator: ValidationProfileType::Uuid(UuidIdValidator),
}
}
#[must_use]
pub fn numeric() -> Self {
Self {
name: "numeric".to_string(),
validator: ValidationProfileType::Numeric(NumericIdValidator),
}
}
#[must_use]
pub fn ulid() -> Self {
Self {
name: "ulid".to_string(),
validator: ValidationProfileType::Ulid(UlidIdValidator),
}
}
#[must_use]
pub fn opaque() -> Self {
Self {
name: "opaque".to_string(),
validator: ValidationProfileType::Opaque(OpaqueIdValidator),
}
}
#[must_use]
pub fn by_name(name: &str) -> Option<Self> {
match name.to_lowercase().as_str() {
"uuid" => Some(Self::uuid()),
"numeric" | "integer" => Some(Self::numeric()),
"ulid" => Some(Self::ulid()),
"opaque" | "string" => Some(Self::opaque()),
_ => None,
}
}
pub fn validate(&self, value: &str) -> Result<(), IDValidationError> {
self.validator.as_validator().validate(value)
}
}