use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::did::{Did, DidError};
use crate::nsid::{Nsid, NsidError};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AtUri {
canonical: String,
did: Did,
collection: Nsid,
rkey: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum AtUriError {
#[error("not an at-uri: {0:?}")]
NotAtUri(String),
#[error("at-uri must have form at://did/collection/rkey: {0:?}")]
InvalidShape(String),
#[error("at-uri authority is not a DID: {0}")]
InvalidDid(#[source] DidError),
#[error("at-uri collection is not an NSID: {0}")]
InvalidNsid(#[source] NsidError),
#[error("at-uri rkey is empty: {0:?}")]
EmptyRkey(String),
}
impl AtUri {
#[must_use]
pub fn new(did: Did, collection: Nsid, rkey: String) -> Self {
let canonical = format!("at://{did}/{collection}/{rkey}");
Self {
canonical,
did,
collection,
rkey,
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.canonical
}
pub fn parse(input: &str) -> Result<Self, AtUriError> {
let body = input
.strip_prefix("at://")
.ok_or_else(|| AtUriError::NotAtUri(input.to_owned()))?;
if body.contains('?') || body.contains('#') {
return Err(AtUriError::InvalidShape(input.to_owned()));
}
let mut parts = body.split('/');
let did_str = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| AtUriError::InvalidShape(input.to_owned()))?;
let collection_str = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| AtUriError::InvalidShape(input.to_owned()))?;
let rkey = parts
.next()
.ok_or_else(|| AtUriError::EmptyRkey(input.to_owned()))?;
if rkey.is_empty() {
return Err(AtUriError::EmptyRkey(input.to_owned()));
}
if parts.next().is_some() {
return Err(AtUriError::InvalidShape(input.to_owned()));
}
let did = Did::parse(did_str).map_err(AtUriError::InvalidDid)?;
let collection = Nsid::parse(collection_str).map_err(AtUriError::InvalidNsid)?;
Ok(Self::new(did, collection, rkey.to_owned()))
}
#[must_use]
pub fn did(&self) -> &Did {
&self.did
}
#[must_use]
pub fn collection(&self) -> &Nsid {
&self.collection
}
#[must_use]
pub fn rkey(&self) -> &str {
&self.rkey
}
}
impl fmt::Display for AtUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "at://{}/{}/{}", self.did, self.collection, self.rkey)
}
}
impl FromStr for AtUri {
type Err = AtUriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for AtUri {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl std::ops::Deref for AtUri {
type Target = str;
fn deref(&self) -> &str {
&self.canonical
}
}
impl std::borrow::Borrow<str> for AtUri {
fn borrow(&self) -> &str {
&self.canonical
}
}
impl Serialize for AtUri {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for AtUri {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_plc_uri() {
let uri = AtUri::parse("at://did:plc:xyz/dev.panproto.schema.lens/abc").unwrap();
assert_eq!(uri.did().as_str(), "did:plc:xyz");
assert_eq!(uri.collection().as_str(), "dev.panproto.schema.lens");
assert_eq!(uri.rkey(), "abc");
}
#[test]
fn round_trips_via_display() {
let s = "at://did:plc:xyz/dev.panproto.schema.lens/abc";
let uri = AtUri::parse(s).unwrap();
assert_eq!(uri.to_string(), s);
}
#[test]
fn rejects_non_at_scheme() {
assert!(matches!(
AtUri::parse("https://example/foo/bar/baz"),
Err(AtUriError::NotAtUri(_))
));
}
#[test]
fn rejects_missing_rkey() {
assert!(matches!(
AtUri::parse("at://did:plc:xyz/dev.panproto.schema.lens"),
Err(AtUriError::InvalidShape(_) | AtUriError::EmptyRkey(_))
));
}
#[test]
fn rejects_empty_segments() {
assert!(matches!(
AtUri::parse("at://did:plc:xyz//abc"),
Err(AtUriError::InvalidShape(_))
));
}
#[test]
fn rejects_invalid_nsid_collection() {
assert!(matches!(
AtUri::parse("at://did:plc:xyz/notanns/abc"),
Err(AtUriError::InvalidNsid(_))
));
}
#[test]
fn rejects_invalid_did() {
assert!(matches!(
AtUri::parse("at://notadid/dev.panproto.schema.lens/abc"),
Err(AtUriError::InvalidDid(_))
));
}
#[test]
fn rejects_trailing_slash() {
assert!(matches!(
AtUri::parse("at://did:plc:xyz/dev.panproto.schema.lens/abc/"),
Err(AtUriError::InvalidShape(_))
));
}
#[test]
fn rejects_extra_path_component() {
assert!(matches!(
AtUri::parse("at://did:plc:xyz/dev.panproto.schema.lens/abc/extra"),
Err(AtUriError::InvalidShape(_))
));
}
#[test]
fn rejects_fragment() {
assert!(matches!(
AtUri::parse("at://did:plc:xyz/dev.panproto.schema.lens/abc#frag"),
Err(AtUriError::InvalidShape(_))
));
}
#[test]
fn rejects_query_string() {
assert!(matches!(
AtUri::parse("at://did:plc:xyz/dev.panproto.schema.lens/abc?q=1"),
Err(AtUriError::InvalidShape(_))
));
}
#[test]
fn serde_roundtrip() {
let s = "at://did:plc:xyz/dev.panproto.schema.lens/abc";
let uri = AtUri::parse(s).unwrap();
let json = serde_json::to_string(&uri).unwrap();
assert_eq!(json, format!("\"{s}\""));
let uri2: AtUri = serde_json::from_str(&json).unwrap();
assert_eq!(uri, uri2);
}
}