use once_cell::sync::Lazy;
use regex::Regex;
use std::fmt;
use std::str::FromStr;
use crate::did::Did;
use crate::handle::Handle;
use crate::nsid::Nsid;
use crate::recordkey::RecordKey;
const MAX_ATURI_LENGTH: usize = 8 * 1024;
static ATURI_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)^at://(?P<authority>[a-zA-Z0-9._:%-]+)(/(?P<collection>[a-zA-Z0-9-.]+)(/(?P<rkey>[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?P<fragment>/[a-zA-Z0-9._~:@!$&%')(*+,;=\[\]/-]*))?$"
)
.unwrap()
});
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AtUri {
authority: String,
collection: Option<String>,
rkey: Option<String>,
fragment: Option<String>,
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("Invalid AT-URI: {reason}")]
pub struct InvalidAtUriError {
pub reason: String,
}
impl AtUri {
pub fn new(s: &str) -> Result<Self, InvalidAtUriError> {
let err = |reason: &str| InvalidAtUriError {
reason: reason.to_string(),
};
if s.len() > MAX_ATURI_LENGTH {
return Err(err(&format!(
"AT-URI is too long ({} bytes, max {})",
s.len(),
MAX_ATURI_LENGTH
)));
}
if !s.starts_with("at://") {
return Err(err("AT-URI must start with \"at://\""));
}
let caps = ATURI_REGEX
.captures(s)
.ok_or_else(|| err("AT-URI format is invalid"))?;
let authority = caps
.name("authority")
.ok_or_else(|| err("AT-URI missing authority"))?
.as_str()
.to_string();
if authority.starts_with("did:") {
Did::new(&authority).map_err(|e| err(&format!("Invalid DID in AT-URI: {e}")))?;
} else {
Handle::new(&authority).map_err(|e| err(&format!("Invalid handle in AT-URI: {e}")))?;
}
let collection = caps.name("collection").map(|m| m.as_str().to_string());
let rkey = caps.name("rkey").map(|m| m.as_str().to_string());
let fragment = caps.name("fragment").map(|m| m.as_str().to_string());
if let Some(ref coll) = collection {
Nsid::new(coll).map_err(|e| err(&format!("Invalid collection NSID in AT-URI: {e}")))?;
}
if let Some(ref rk) = rkey {
RecordKey::new(rk).map_err(|e| err(&format!("Invalid record key in AT-URI: {e}")))?;
}
if rkey.is_some() && collection.is_none() {
return Err(err("AT-URI cannot have rkey without collection"));
}
Ok(AtUri {
authority,
collection,
rkey,
fragment,
})
}
pub fn is_valid(s: &str) -> bool {
AtUri::new(s).is_ok()
}
pub fn authority(&self) -> &str {
&self.authority
}
pub fn collection(&self) -> Option<&str> {
self.collection.as_deref()
}
pub fn rkey(&self) -> Option<&str> {
self.rkey.as_deref()
}
pub fn fragment(&self) -> Option<&str> {
self.fragment.as_deref()
}
}
impl fmt::Display for AtUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "at://{}", self.authority)?;
if let Some(ref coll) = self.collection {
write!(f, "/{coll}")?;
if let Some(ref rk) = self.rkey {
write!(f, "/{rk}")?;
}
}
if let Some(ref frag) = self.fragment {
write!(f, "#{frag}")?;
}
Ok(())
}
}
impl FromStr for AtUri {
type Err = InvalidAtUriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
AtUri::new(s)
}
}
impl serde::Serialize for AtUri {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.to_string().serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for AtUri {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
AtUri::new(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_aturis() {
let cases = [
"at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y",
"at://did:plc:asdf123/app.bsky.feed.post",
"at://did:plc:asdf123",
"at://alice.bsky.social/app.bsky.feed.post/3jui7kd54zh2y",
"at://alice.bsky.social",
];
for uri in &cases {
assert!(AtUri::new(uri).is_ok(), "should be valid: {uri}");
}
}
#[test]
fn invalid_aturis() {
assert!(AtUri::new("").is_err());
assert!(AtUri::new("http://example.com").is_err());
assert!(AtUri::new("at://").is_err());
}
#[test]
fn parse_components() {
let uri = AtUri::new("at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y").unwrap();
assert_eq!(uri.authority(), "did:plc:asdf123");
assert_eq!(uri.collection(), Some("app.bsky.feed.post"));
assert_eq!(uri.rkey(), Some("3jui7kd54zh2y"));
}
#[test]
fn display_roundtrip() {
let input = "at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y";
let uri = AtUri::new(input).unwrap();
assert_eq!(uri.to_string(), input);
}
}