Skip to main content

jacquard_common/types/
uri.rs

1use crate::bos::Bos;
2use crate::deps::fluent_uri::Uri;
3use crate::{
4    DefaultStr, IntoStatic,
5    types::{
6        aturi::{AtUri, validate_and_index},
7        cid::Cid,
8        collection::Collection,
9        did::{Did, validate_did},
10        nsid::Nsid,
11        string::{AtStrError, StrParseKind},
12    },
13};
14use alloc::string::{String, ToString};
15use core::{fmt::Display, marker::PhantomData, ops::Deref, str::FromStr};
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17
18/// Generic URI with type-specific parsing.
19///
20/// Automatically detects and parses URIs into the appropriate variant based on
21/// the scheme prefix. Used in lexicon where URIs can be of various types.
22///
23/// Variants are checked by prefix: `did:`, `at://`, `https://`, `wss://`, `ipld://`
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub enum UriValue<S: Bos<str> + AsRef<str> = DefaultStr> {
26    /// DID URI (did:).
27    Did(Did<S>),
28    /// AT Protocol URI (at://).
29    At(AtUri<S>),
30    /// HTTPS URL.
31    Https(Uri<String>),
32    /// WebSocket Secure URL.
33    Wss(Uri<String>),
34    /// IPLD CID URI.
35    Cid(Cid<S>),
36    /// Unrecognized URI scheme (catch-all).
37    Any(S),
38}
39
40/// Errors that can occur when parsing URIs.
41#[derive(Debug, thiserror::Error, miette::Diagnostic)]
42#[non_exhaustive]
43pub enum UriParseError {
44    /// AT Protocol string parsing error.
45    #[error("Invalid atproto string: {0}")]
46    At(#[from] AtStrError),
47    /// URI parsing error.
48    #[error(transparent)]
49    Uri(#[from] crate::deps::fluent_uri::ParseError),
50    /// CID parsing error.
51    #[error(transparent)]
52    Cid(#[from] crate::types::cid::Error),
53}
54
55// ---------------------------------------------------------------------------
56// Generic construction
57// ---------------------------------------------------------------------------
58
59impl<S: Bos<str> + AsRef<str>> UriValue<S> {
60    /// Parse a URI, validate by prefix, wrap `S` into the matching variant.
61    ///
62    /// `Https` and `Wss` variants always allocate a `Uri<String>` regardless of `S`.
63    pub fn new(uri: S) -> Result<Self, UriParseError> {
64        let s = uri.as_ref();
65        if s.starts_with("did:") {
66            if validate_did(s).is_ok() {
67                return Ok(UriValue::Did(unsafe { Did::unchecked(uri) }));
68            }
69        } else if s.starts_with("at://") {
70            if let Ok(indices) = validate_and_index(s) {
71                return Ok(UriValue::At(unsafe { AtUri::from_parts(uri, indices) }));
72            }
73        } else if s.starts_with("https://") {
74            if let Ok(parsed) = Uri::parse(s) {
75                return Ok(UriValue::Https(parsed.to_owned()));
76            }
77        } else if s.starts_with("wss://") {
78            if let Ok(parsed) = Uri::parse(s) {
79                return Ok(UriValue::Wss(parsed.to_owned()));
80            }
81        } else if s.starts_with("ipld://") {
82            return Ok(UriValue::Cid(unsafe { Cid::unchecked_str(uri) }));
83        }
84        Ok(UriValue::Any(uri))
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Owned construction
90// ---------------------------------------------------------------------------
91
92impl<S: Bos<str> + AsRef<str> + FromStr> UriValue<S> {
93    /// Parse a URI from a string, taking ownership.
94    pub fn new_owned(uri: impl AsRef<str>) -> Result<Self, UriParseError> {
95        let uri_str = uri.as_ref();
96        if uri_str.starts_with("did:") {
97            Ok(UriValue::Did(Did::new_owned(uri_str)?))
98        } else if uri_str.starts_with("at://") {
99            Ok(UriValue::At(AtUri::new_owned(uri_str)?))
100        } else if uri_str.starts_with("https://") {
101            Ok(UriValue::Https(Uri::parse(uri_str)?.to_owned()))
102        } else if uri_str.starts_with("wss://") {
103            Ok(UriValue::Wss(Uri::parse(uri_str)?.to_owned()))
104        } else if uri_str.starts_with("ipld://") {
105            let cid_part = &uri_str[7..];
106            if cid_part.is_empty() {
107                let s = S::from_str(uri_str).map_err(|_| {
108                    UriParseError::At(AtStrError::new(
109                        "uri",
110                        uri_str.to_string(),
111                        StrParseKind::Conversion,
112                    ))
113                })?;
114                Ok(UriValue::Any(s))
115            } else {
116                let s = S::from_str(cid_part).map_err(|_| {
117                    UriParseError::At(AtStrError::new(
118                        "uri",
119                        cid_part.to_string(),
120                        StrParseKind::Conversion,
121                    ))
122                })?;
123                Ok(UriValue::Cid(unsafe { Cid::unchecked_str(s) }))
124            }
125        } else {
126            let s = S::from_str(uri_str).map_err(|_| {
127                UriParseError::At(AtStrError::new(
128                    "uri",
129                    uri_str.to_string(),
130                    StrParseKind::Conversion,
131                ))
132            })?;
133            Ok(UriValue::Any(s))
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Accessors
140// ---------------------------------------------------------------------------
141
142impl<S: Bos<str> + AsRef<str>> UriValue<S> {
143    /// Get the URI as a string slice.
144    pub fn as_str(&self) -> &str {
145        match self {
146            UriValue::Did(did) => did.as_str(),
147            UriValue::At(at_uri) => at_uri.as_str(),
148            UriValue::Https(url) => url.as_str(),
149            UriValue::Wss(url) => url.as_str(),
150            UriValue::Cid(cid) => cid.as_str(),
151            UriValue::Any(s) => s.as_ref(),
152        }
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Serde
158// ---------------------------------------------------------------------------
159
160impl<S: Bos<str> + AsRef<str>> Serialize for UriValue<S> {
161    fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
162    where
163        Ser: Serializer,
164    {
165        serializer.serialize_str(self.as_str())
166    }
167}
168
169impl<'de, S: Bos<str> + AsRef<str> + Deserialize<'de>> Deserialize<'de> for UriValue<S> {
170    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171    where
172        D: Deserializer<'de>,
173    {
174        let value: S = Deserialize::deserialize(deserializer)?;
175        Self::new(value).map_err(serde::de::Error::custom)
176    }
177}
178
179// ---------------------------------------------------------------------------
180// AsRef, IntoStatic
181// ---------------------------------------------------------------------------
182
183impl<S: Bos<str> + AsRef<str>> AsRef<str> for UriValue<S> {
184    fn as_ref(&self) -> &str {
185        self.as_str()
186    }
187}
188
189impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for UriValue<S>
190where
191    S::Output: Bos<str> + AsRef<str>,
192{
193    type Output = UriValue<S::Output>;
194
195    fn into_static(self) -> Self::Output {
196        match self {
197            UriValue::Did(did) => UriValue::Did(did.into_static()),
198            UriValue::At(at_uri) => UriValue::At(at_uri.into_static()),
199            UriValue::Https(url) => UriValue::Https(url),
200            UriValue::Wss(url) => UriValue::Wss(url),
201            UriValue::Cid(cid) => UriValue::Cid(cid.into_static()),
202            UriValue::Any(s) => UriValue::Any(s.into_static()),
203        }
204    }
205}
206
207impl<S: Bos<str> + AsRef<str>> UriValue<S> {
208    /// Convert to a `UriValue` with a different backing type.
209    pub fn convert<B: Bos<str> + AsRef<str> + From<S>>(self) -> UriValue<B> {
210        match self {
211            UriValue::Did(did) => UriValue::Did(did.convert()),
212            UriValue::At(at_uri) => UriValue::At(at_uri.convert()),
213            UriValue::Https(url) => UriValue::Https(url),
214            UriValue::Wss(url) => UriValue::Wss(url),
215            UriValue::Cid(cid) => UriValue::Cid(cid.convert()),
216            UriValue::Any(s) => UriValue::Any(B::from(s)),
217        }
218    }
219}
220
221// ---------------------------------------------------------------------------
222// RecordUri
223// ---------------------------------------------------------------------------
224
225#[repr(transparent)]
226/// Collection type-annotated at:// URI.
227///
228/// Carries the corresponding collection type for fetching records easily.
229pub struct RecordUri<S: Bos<str> + AsRef<str>, R: Collection>(AtUri<S>, PhantomData<R>);
230
231impl<S: Bos<str> + AsRef<str>, R: Collection> RecordUri<S, R> {
232    /// Attempts to parse an at-uri as the corresponding collection.
233    pub fn try_from_uri(uri: AtUri<S>) -> Result<Self, UriError> {
234        if let Some(collection) = uri.collection() {
235            if collection.as_str() == R::NSID {
236                return Ok(Self(uri, PhantomData));
237            }
238        }
239        Err(UriError::CollectionMismatch {
240            expected: R::NSID,
241            found: uri
242                .collection()
243                .map(|c| Nsid::new_owned(c.as_str()).unwrap()),
244        })
245    }
246
247    /// Returns the internal un-typed AtUri.
248    pub fn into_inner(self) -> AtUri<S> {
249        self.0
250    }
251
252    /// Accesses the internal AtUri for use.
253    pub fn as_uri(&self) -> &AtUri<S> {
254        &self.0
255    }
256}
257
258impl<S: Bos<str> + AsRef<str>, R: Collection> Display for RecordUri<S, R> {
259    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
260        self.0.fmt(f)
261    }
262}
263
264impl<S: Bos<str> + AsRef<str>, R: Collection> AsRef<AtUri<S>> for RecordUri<S, R> {
265    fn as_ref(&self) -> &AtUri<S> {
266        &self.0
267    }
268}
269
270impl<S: Bos<str> + AsRef<str>, R: Collection> Deref for RecordUri<S, R> {
271    type Target = AtUri<S>;
272
273    fn deref(&self) -> &Self::Target {
274        &self.0
275    }
276}
277
278#[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)]
279#[non_exhaustive]
280/// Errors that can occur when parsing or validating collection type-annotated URIs.
281pub enum UriError {
282    /// Given at-uri didn't have the matching collection for the record.
283    #[error("Collection mismatch: expected {expected}, found {found:?}")]
284    CollectionMismatch {
285        /// The collection of the record.
286        expected: &'static str,
287        /// What the at-uri had.
288        found: Option<Nsid>,
289    },
290    /// Couldn't parse the string as an AtUri.
291    #[error("Invalid URI: {0}")]
292    InvalidUri(#[from] AtStrError),
293}
294
295#[cfg(test)]
296mod tests {
297    use smol_str::SmolStr;
298
299    use crate::CowStr;
300
301    use super::*;
302
303    #[test]
304    fn test_wss_variant_parsing() {
305        let uri = UriValue::new("wss://example.com/path").expect("valid wss uri");
306        assert!(
307            matches!(uri, UriValue::Wss(_)),
308            "wss:// should parse to UriValue::Wss"
309        );
310        assert_eq!(uri.as_str(), "wss://example.com/path");
311    }
312
313    #[test]
314    fn test_https_variant_parsing() {
315        let uri = UriValue::new("https://example.com/path").expect("valid https uri");
316        assert!(
317            matches!(uri, UriValue::Https(_)),
318            "https:// should parse to UriValue::Https"
319        );
320        assert_eq!(uri.as_str(), "https://example.com/path");
321    }
322
323    #[test]
324    fn test_wss_owned_variant_parsing() {
325        let uri: UriValue<SmolStr> =
326            UriValue::new_owned("wss://example.com").expect("valid wss uri");
327        assert!(
328            matches!(uri, UriValue::Wss(_)),
329            "owned wss:// should parse to UriValue::Wss"
330        );
331        assert_eq!(uri.as_str(), "wss://example.com");
332    }
333
334    #[test]
335    fn test_https_owned_variant_parsing() {
336        let uri: UriValue<SmolStr> =
337            UriValue::new_owned("https://example.com").expect("valid https uri");
338        assert!(
339            matches!(uri, UriValue::Https(_)),
340            "owned https:// should parse to UriValue::Https"
341        );
342        assert_eq!(uri.as_str(), "https://example.com");
343    }
344
345    #[test]
346    fn test_wss_cow_variant_parsing() {
347        let uri = UriValue::new(CowStr::Borrowed("wss://example.com")).expect("valid wss uri");
348        assert!(
349            matches!(uri, UriValue::Wss(_)),
350            "cow wss:// should parse to UriValue::Wss"
351        );
352        assert_eq!(uri.as_str(), "wss://example.com");
353    }
354
355    #[test]
356    fn test_https_cow_variant_parsing() {
357        let uri = UriValue::new(CowStr::Borrowed("https://example.com")).expect("valid https uri");
358        assert!(
359            matches!(uri, UriValue::Https(_)),
360            "cow https:// should parse to UriValue::Https"
361        );
362        assert_eq!(uri.as_str(), "https://example.com");
363    }
364
365    #[test]
366    fn test_uri_display() {
367        let wss: UriValue<SmolStr> = UriValue::new_owned("wss://example.com").unwrap();
368        assert_eq!(wss.as_str(), "wss://example.com");
369
370        let https: UriValue<SmolStr> = UriValue::new_owned("https://example.com").unwrap();
371        assert_eq!(https.as_str(), "https://example.com");
372    }
373
374    #[test]
375    fn test_into_static_preserves_variant() {
376        let wss: UriValue<SmolStr> = UriValue::new_owned("wss://example.com").unwrap();
377        let static_wss = wss.into_static();
378        assert!(matches!(static_wss, UriValue::Wss(_)));
379
380        let https: UriValue<SmolStr> = UriValue::new_owned("https://example.com").unwrap();
381        let static_https = https.into_static();
382        assert!(matches!(static_https, UriValue::Https(_)));
383    }
384}