Skip to main content

didbot_lookup/
record.rs

1//! How one record in somebody else's repository is addressed.
2
3use std::fmt;
4
5/// What a reader addresses: a DID, a collection, and either one record key
6/// or the whole collection.
7///
8/// The authority is a DID and never a handle. A handle moves between
9/// accounts and a preference or a gate belongs to the account rather than
10/// to the name it currently answers to, so the cache is keyed by DID
11/// throughout and a caller holding a handle resolves it before it gets
12/// here.
13///
14/// A reference with no `rkey` reads the collection, which is what a
15/// policy's lookup naming no record key asks for.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct RecordRef {
18    /// The repository's DID.
19    pub did: String,
20    /// The collection (NSID) the record is in.
21    pub collection: String,
22    /// The record key, or `None` for every record in the collection.
23    pub rkey: Option<String>,
24}
25
26/// Why a string is not a record this reader can address.
27#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
28pub enum RefError {
29    /// Not an `at://` URI at all.
30    #[error("{0:?} does not begin with at://")]
31    NotAnAtUri(String),
32    /// An `at://` URI naming something other than a record or a
33    /// collection: a repository, or a record with something after it.
34    #[error(
35        "{0:?} names neither a record nor a collection: at://<did>/<collection>/<rkey> is one \
36         record, at://<did>/<collection> is a collection"
37    )]
38    NotOneRecord(String),
39    /// The authority is a handle, or something that is not a DID.
40    #[error("{0:?} is a handle rather than a DID; resolve it before reading a record at it")]
41    NotADid(String),
42    /// A segment this reader needs is empty.
43    #[error("{0:?} has an empty {1}")]
44    Empty(String, &'static str),
45}
46
47impl RecordRef {
48    /// A reference to `rkey` in `collection` in `did`'s repository.
49    pub fn new(
50        did: impl Into<String>,
51        collection: impl Into<String>,
52        rkey: impl Into<String>,
53    ) -> Self {
54        Self {
55            did: did.into(),
56            collection: collection.into(),
57            rkey: Some(rkey.into()),
58        }
59    }
60
61    /// A reference to every record in `collection` in `did`'s repository.
62    pub fn collection(did: impl Into<String>, collection: impl Into<String>) -> Self {
63        Self {
64            did: did.into(),
65            collection: collection.into(),
66            rkey: None,
67        }
68    }
69
70    /// Whether this names the whole collection rather than one record.
71    #[must_use]
72    pub fn is_collection(&self) -> bool {
73        self.rkey.is_none()
74    }
75
76    /// The record, or the collection, an `at://` URI names.
77    ///
78    /// # Errors
79    ///
80    /// See [`RefError`]: anything that is neither
81    /// `at://<did>/<collection>/<rkey>` nor `at://<did>/<collection>`.
82    pub fn parse(at_uri: &str) -> Result<Self, RefError> {
83        let rest = at_uri
84            .strip_prefix("at://")
85            .ok_or_else(|| RefError::NotAnAtUri(at_uri.to_owned()))?;
86        let mut parts = rest.split('/');
87        let (Some(authority), Some(collection), rkey, None) =
88            (parts.next(), parts.next(), parts.next(), parts.next())
89        else {
90            return Err(RefError::NotOneRecord(at_uri.to_owned()));
91        };
92        if !authority.starts_with("did:") {
93            return Err(RefError::NotADid(at_uri.to_owned()));
94        }
95        for (segment, name) in [(authority, "did"), (collection, "collection")]
96            .into_iter()
97            .chain(rkey.map(|rkey| (rkey, "rkey")))
98        {
99            if segment.is_empty() {
100                return Err(RefError::Empty(at_uri.to_owned(), name));
101            }
102        }
103        Ok(Self {
104            did: authority.to_owned(),
105            collection: collection.to_owned(),
106            rkey: rkey.map(str::to_owned),
107        })
108    }
109
110    /// The `at://` URI for this reference, which is also its cache key.
111    pub fn at_uri(&self) -> String {
112        self.to_string()
113    }
114}
115
116impl fmt::Display for RecordRef {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        write!(f, "at://{}/{}", self.did, self.collection)?;
119        match &self.rkey {
120            Some(rkey) => write!(f, "/{rkey}"),
121            None => Ok(()),
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn an_at_uri_round_trips_through_a_reference() {
132        let uri = "at://did:plc:abcdefghijklmnopqrstuvwx/app.bsky.feed.threadgate/3kabc";
133        let parsed = RecordRef::parse(uri).expect("one record");
134        assert_eq!(parsed.did, "did:plc:abcdefghijklmnopqrstuvwx");
135        assert_eq!(parsed.collection, "app.bsky.feed.threadgate");
136        assert_eq!(parsed.rkey.as_deref(), Some("3kabc"));
137        assert_eq!(parsed.at_uri(), uri);
138        assert!(!parsed.is_collection());
139    }
140
141    /// A two-segment at-uri is the collection, which is a different request
142    /// to a different XRPC method -- not a record at an empty key.
143    #[test]
144    fn a_collection_uri_round_trips_through_a_reference() {
145        let uri = "at://did:plc:abcdefghijklmnopqrstuvwx/community.lexicon.preference.ai";
146        let parsed = RecordRef::parse(uri).expect("one collection");
147        assert_eq!(parsed.rkey, None);
148        assert!(parsed.is_collection());
149        assert_eq!(parsed.at_uri(), uri);
150        assert_eq!(
151            RecordRef::collection(
152                "did:plc:abcdefghijklmnopqrstuvwx",
153                "community.lexicon.preference.ai"
154            ),
155            parsed
156        );
157    }
158
159    /// Each of these names neither a record nor a collection, and the
160    /// reader has to tell them apart from something it could go and fetch.
161    #[test]
162    fn nothing_but_one_record_or_collection_parses() {
163        for (uri, expected) in [
164            (
165                "https://example.test/x",
166                RefError::NotAnAtUri("https://example.test/x".to_owned()),
167            ),
168            (
169                "at://did:plc:abc/app.bsky.feed.post/3k/extra",
170                RefError::NotOneRecord("at://did:plc:abc/app.bsky.feed.post/3k/extra".to_owned()),
171            ),
172            (
173                "at://alice.example/app.bsky.feed.post/3k",
174                RefError::NotADid("at://alice.example/app.bsky.feed.post/3k".to_owned()),
175            ),
176            (
177                "at://did:plc:abc/app.bsky.feed.post/",
178                RefError::Empty("at://did:plc:abc/app.bsky.feed.post/".to_owned(), "rkey"),
179            ),
180        ] {
181            assert_eq!(RecordRef::parse(uri), Err(expected), "{uri}");
182        }
183    }
184}