use std::fmt;
use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum UrnError {
#[error("invalid URN format: expected 5 parts separated by colons")]
InvalidFormat,
#[error("invalid URN prefix: must start with 'urn'")]
InvalidPrefix,
#[error("invalid URN version: must start with 'v' followed by digits (e.g., v1, v2)")]
InvalidVersion,
#[error("invalid URN: {0} cannot be empty")]
EmptyPart(&'static str),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Urn {
capability: String,
version: String,
resource: String,
id: String,
}
impl Urn {
pub fn new(
capability: impl Into<String>,
version: impl Into<String>,
resource: impl Into<String>,
id: impl Into<String>,
) -> Result<Self, UrnError> {
let capability = capability.into();
let version = version.into();
let resource = resource.into();
let id = id.into();
if capability.is_empty() {
return Err(UrnError::EmptyPart("capability"));
}
if version.is_empty() {
return Err(UrnError::EmptyPart("version"));
}
if resource.is_empty() {
return Err(UrnError::EmptyPart("resource"));
}
if id.is_empty() {
return Err(UrnError::EmptyPart("id"));
}
Self::validate_version(&version)?;
Ok(Self {
capability,
version,
resource,
id,
})
}
pub fn parse(value: &str) -> Result<Self, UrnError> {
let parts: Vec<&str> = value.split(':').collect();
match parts.as_slice() {
[prefix, capability, version, resource, id] => {
if *prefix != "urn" {
return Err(UrnError::InvalidPrefix);
}
Self::new(*capability, *version, *resource, *id)
}
[] | [_] | [_, _] | [_, _, _] | [_, _, _, _] | [_, _, _, _, _, ..] => {
Err(UrnError::InvalidFormat)
}
}
}
pub fn validate(value: &str) -> Result<(), UrnError> {
Self::parse(value).map(|_| ())
}
#[must_use]
pub fn capability(&self) -> &str {
&self.capability
}
#[must_use]
pub fn version(&self) -> &str {
&self.version
}
#[must_use]
pub fn resource(&self) -> &str {
&self.resource
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
fn validate_version(version: &str) -> Result<(), UrnError> {
match version.strip_prefix('v') {
Some(digits) if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) => {
Ok(())
}
Some(_) | None => Err(UrnError::InvalidVersion),
}
}
}
impl fmt::Display for Urn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"urn:{}:{}:{}:{}",
self.capability, self.version, self.resource, self.id
)
}
}
impl std::str::FromStr for Urn {
type Err = UrnError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_urn() {
let urn = Urn::parse("urn:users:v1:user:12345");
assert!(urn.is_ok());
let urn = urn.ok();
assert_eq!(urn.as_ref().map(Urn::capability), Some("users"));
assert_eq!(urn.as_ref().map(Urn::version), Some("v1"));
assert_eq!(urn.as_ref().map(Urn::resource), Some("user"));
assert_eq!(urn.as_ref().map(Urn::id), Some("12345"));
}
#[test]
fn parse_invalid_format() {
let cases = [
("", UrnError::InvalidFormat),
("urn", UrnError::InvalidFormat),
("urn:a:b:c", UrnError::InvalidFormat),
("urn:a:b:c:d:e", UrnError::InvalidFormat),
];
for (input, expected) in cases {
let result = Urn::parse(input);
assert_eq!(result.err(), Some(expected.clone()), "input: {input}");
}
}
#[test]
fn parse_invalid_prefix() {
let result = Urn::parse("URN:users:v1:user:123");
assert_eq!(result.err(), Some(UrnError::InvalidPrefix));
}
#[test]
fn parse_invalid_version() {
let cases = [
"urn:users:1:user:123", "urn:users:version1:user:123", "urn:users:v:user:123", "urn:users:va:user:123", ];
for input in cases {
let result = Urn::parse(input);
assert_eq!(
result.err(),
Some(UrnError::InvalidVersion),
"input: {input}"
);
}
}
#[test]
fn parse_empty_parts() {
let cases = [
("urn::v1:user:123", UrnError::EmptyPart("capability")),
("urn:users::user:123", UrnError::EmptyPart("version")),
("urn:users:v1::123", UrnError::EmptyPart("resource")),
("urn:users:v1:user:", UrnError::EmptyPart("id")),
];
for (input, expected) in cases {
let result = Urn::parse(input);
assert_eq!(result.err(), Some(expected), "input: {input}");
}
}
#[test]
fn new_valid_urn() {
let urn = Urn::new("users", "v1", "user", "12345");
assert!(urn.is_ok());
assert_eq!(
urn.ok().map(|u| u.to_string()),
Some("urn:users:v1:user:12345".to_string())
);
}
#[test]
fn new_invalid_parts() {
let cases = [
(("", "v1", "user", "123"), UrnError::EmptyPart("capability")),
(("users", "", "user", "123"), UrnError::EmptyPart("version")),
(("users", "v1", "", "123"), UrnError::EmptyPart("resource")),
(("users", "v1", "user", ""), UrnError::EmptyPart("id")),
];
for ((cap, ver, res, id), expected) in cases {
let result = Urn::new(cap, ver, res, id);
assert_eq!(result.err(), Some(expected));
}
}
#[test]
fn display() {
let urn = Urn::new("orders", "v2", "order", "abc-123").ok();
assert_eq!(
urn.map(|u| u.to_string()),
Some("urn:orders:v2:order:abc-123".to_string())
);
}
#[test]
fn from_str() {
let urn: Result<Urn, _> = "urn:users:v1:user:123".parse();
assert!(urn.is_ok());
}
#[test]
fn validate() {
assert!(Urn::validate("urn:users:v1:user:123").is_ok());
assert!(Urn::validate("invalid").is_err());
}
#[test]
fn multi_digit_version() {
let urn = Urn::parse("urn:api:v123:resource:id");
assert!(urn.is_ok());
assert_eq!(
urn.ok().map(|u| u.version().to_string()),
Some("v123".to_string())
);
}
#[test]
fn id_with_special_chars() {
let urn = Urn::parse("urn:users:v1:user:abc-123_def.456");
assert!(urn.is_ok());
assert_eq!(
urn.ok().map(|u| u.id().to_string()),
Some("abc-123_def.456".to_string())
);
}
}