1use once_cell::sync::Lazy;
7use regex::Regex;
8use std::fmt;
9use std::str::FromStr;
10
11use crate::did::Did;
12use crate::handle::Handle;
13use crate::nsid::Nsid;
14use crate::recordkey::RecordKey;
15
16const MAX_ATURI_LENGTH: usize = 8 * 1024;
18
19static ATURI_REGEX: Lazy<Regex> = Lazy::new(|| {
25 Regex::new(
26 r"^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._~:@!$&%')(*+,;=\[\]/-]*))?$"
27 )
28 .unwrap()
29});
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct AtUri {
36 authority: String,
37 collection: Option<String>,
38 rkey: Option<String>,
39 fragment: Option<String>,
40}
41
42#[derive(Debug, Clone, thiserror::Error)]
44#[error("Invalid AT-URI: {reason}")]
45pub struct InvalidAtUriError {
46 pub reason: String,
47}
48
49impl AtUri {
50 pub fn new(s: &str) -> Result<Self, InvalidAtUriError> {
52 let err = |reason: &str| InvalidAtUriError {
53 reason: reason.to_string(),
54 };
55
56 if s.len() > MAX_ATURI_LENGTH {
57 return Err(err(&format!(
58 "AT-URI is too long ({} bytes, max {})",
59 s.len(),
60 MAX_ATURI_LENGTH
61 )));
62 }
63
64 if !s.starts_with("at://") {
65 return Err(err("AT-URI must start with \"at://\""));
66 }
67
68 let caps = ATURI_REGEX
69 .captures(s)
70 .ok_or_else(|| err("AT-URI format is invalid"))?;
71
72 let authority = caps
73 .name("authority")
74 .ok_or_else(|| err("AT-URI missing authority"))?
75 .as_str()
76 .to_string();
77
78 if authority.starts_with("did:") {
80 Did::new(&authority).map_err(|e| err(&format!("Invalid DID in AT-URI: {e}")))?;
81 } else {
82 Handle::new(&authority).map_err(|e| err(&format!("Invalid handle in AT-URI: {e}")))?;
83 }
84
85 let collection = caps.name("collection").map(|m| m.as_str().to_string());
86 let rkey = caps.name("rkey").map(|m| m.as_str().to_string());
87 let fragment = caps.name("fragment").map(|m| m.as_str().to_string());
88
89 if let Some(ref coll) = collection {
91 Nsid::new(coll).map_err(|e| err(&format!("Invalid collection NSID in AT-URI: {e}")))?;
92 }
93
94 if let Some(ref rk) = rkey {
96 RecordKey::new(rk).map_err(|e| err(&format!("Invalid record key in AT-URI: {e}")))?;
97 }
98
99 if rkey.is_some() && collection.is_none() {
101 return Err(err("AT-URI cannot have rkey without collection"));
102 }
103
104 Ok(AtUri {
105 authority,
106 collection,
107 rkey,
108 fragment,
109 })
110 }
111
112 pub fn is_valid(s: &str) -> bool {
114 AtUri::new(s).is_ok()
115 }
116
117 pub fn make(
123 authority: &str,
124 collection: Option<&str>,
125 rkey: Option<&str>,
126 ) -> Result<Self, InvalidAtUriError> {
127 let mut s = format!("at://{authority}");
128 if let Some(c) = collection {
129 s.push('/');
130 s.push_str(c);
131 if let Some(r) = rkey {
132 s.push('/');
133 s.push_str(r);
134 }
135 } else if rkey.is_some() {
136 return Err(InvalidAtUriError {
137 reason: "AT-URI cannot have rkey without collection".to_string(),
138 });
139 }
140 AtUri::new(&s)
141 }
142
143 pub fn authority(&self) -> &str {
145 &self.authority
146 }
147
148 pub fn collection(&self) -> Option<&str> {
150 self.collection.as_deref()
151 }
152
153 pub fn rkey(&self) -> Option<&str> {
155 self.rkey.as_deref()
156 }
157
158 pub fn fragment(&self) -> Option<&str> {
160 self.fragment.as_deref()
161 }
162
163 pub fn protocol(&self) -> &'static str {
165 "at:"
166 }
167
168 pub fn origin(&self) -> String {
171 format!("at://{}", self.authority)
172 }
173
174 pub fn set_authority(&mut self, v: impl Into<String>) -> Result<(), InvalidAtUriError> {
178 let v = v.into();
179 if v.starts_with("did:") {
180 Did::new(&v).map_err(|e| InvalidAtUriError {
181 reason: format!("Invalid DID authority: {e}"),
182 })?;
183 } else {
184 Handle::new(&v).map_err(|e| InvalidAtUriError {
185 reason: format!("Invalid handle authority: {e}"),
186 })?;
187 }
188 self.authority = v;
189 Ok(())
190 }
191
192 pub fn set_collection(&mut self, v: Option<&str>) -> Result<(), InvalidAtUriError> {
196 match v {
197 Some(coll) => {
198 Nsid::new(coll).map_err(|e| InvalidAtUriError {
199 reason: format!("Invalid collection NSID: {e}"),
200 })?;
201 self.collection = Some(coll.to_string());
202 }
203 None => {
204 self.collection = None;
205 self.rkey = None;
206 }
207 }
208 Ok(())
209 }
210
211 pub fn set_rkey(&mut self, v: Option<&str>) -> Result<(), InvalidAtUriError> {
214 match v {
215 Some(rk) => {
216 if self.collection.is_none() {
217 return Err(InvalidAtUriError {
218 reason: "cannot set rkey on an AT-URI that has no collection".to_string(),
219 });
220 }
221 RecordKey::new(rk).map_err(|e| InvalidAtUriError {
222 reason: format!("Invalid record key: {e}"),
223 })?;
224 self.rkey = Some(rk.to_string());
225 }
226 None => {
227 self.rkey = None;
228 }
229 }
230 Ok(())
231 }
232
233 pub fn set_fragment(&mut self, v: Option<&str>) -> Result<(), InvalidAtUriError> {
237 match v {
238 Some(f) if !f.starts_with('/') => Err(InvalidAtUriError {
239 reason: "AT-URI fragment must start with `/` (JSON Pointer)".to_string(),
240 }),
241 Some(f) => {
242 let probe = format!("at://{}#{}", self.authority, f);
246 AtUri::new(&probe).map_err(|e| InvalidAtUriError {
247 reason: format!("Invalid fragment: {e}"),
248 })?;
249 self.fragment = Some(f.to_string());
250 Ok(())
251 }
252 None => {
253 self.fragment = None;
254 Ok(())
255 }
256 }
257 }
258
259 pub fn resolve(&self, reference: &str) -> Result<Self, InvalidAtUriError> {
275 if reference.starts_with("at://") {
276 return AtUri::new(reference);
277 }
278 if let Some(frag) = reference.strip_prefix('#') {
279 let mut cloned = self.clone();
280 cloned.set_fragment(Some(&format!(
281 "{}{frag}",
282 if frag.starts_with('/') { "" } else { "/" }
283 )))?;
284 return Ok(cloned);
285 }
286 let (path_part, frag_part) = match reference.find('#') {
288 Some(i) => (&reference[..i], Some(&reference[i + 1..])),
289 None => (reference, None),
290 };
291 let parts: Vec<&str> = path_part.split('/').filter(|p| !p.is_empty()).collect();
292 let mut cloned = self.clone();
293 match parts.len() {
294 0 => cloned.set_collection(None)?,
295 1 => {
296 cloned.set_collection(Some(parts[0]))?;
297 cloned.set_rkey(None)?;
298 }
299 2 => {
300 cloned.set_collection(Some(parts[0]))?;
301 cloned.set_rkey(Some(parts[1]))?;
302 }
303 _ => {
304 return Err(InvalidAtUriError {
305 reason: format!("relative reference has too many path segments: {reference:?}"),
306 });
307 }
308 }
309 cloned.set_fragment(
310 frag_part
311 .map(|f| {
312 if f.starts_with('/') {
313 f.to_string()
314 } else {
315 format!("/{f}")
316 }
317 })
318 .as_deref(),
319 )?;
320 Ok(cloned)
321 }
322}
323
324impl fmt::Display for AtUri {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 write!(f, "at://{}", self.authority)?;
327 if let Some(ref coll) = self.collection {
328 write!(f, "/{coll}")?;
329 if let Some(ref rk) = self.rkey {
330 write!(f, "/{rk}")?;
331 }
332 }
333 if let Some(ref frag) = self.fragment {
334 write!(f, "#{frag}")?;
335 }
336 Ok(())
337 }
338}
339
340impl FromStr for AtUri {
341 type Err = InvalidAtUriError;
342 fn from_str(s: &str) -> Result<Self, Self::Err> {
343 AtUri::new(s)
344 }
345}
346
347impl serde::Serialize for AtUri {
348 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
349 self.to_string().serialize(serializer)
350 }
351}
352
353impl<'de> serde::Deserialize<'de> for AtUri {
354 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
355 let s = String::deserialize(deserializer)?;
356 AtUri::new(&s).map_err(serde::de::Error::custom)
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn valid_aturis() {
366 let cases = [
367 "at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y",
368 "at://did:plc:asdf123/app.bsky.feed.post",
369 "at://did:plc:asdf123",
370 "at://alice.bsky.social/app.bsky.feed.post/3jui7kd54zh2y",
371 "at://alice.bsky.social",
372 ];
373 for uri in &cases {
374 assert!(AtUri::new(uri).is_ok(), "should be valid: {uri}");
375 }
376 }
377
378 #[test]
379 fn invalid_aturis() {
380 assert!(AtUri::new("").is_err());
381 assert!(AtUri::new("http://example.com").is_err());
382 assert!(AtUri::new("at://").is_err());
383 }
384
385 #[test]
386 fn parse_components() {
387 let uri = AtUri::new("at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y").unwrap();
388 assert_eq!(uri.authority(), "did:plc:asdf123");
389 assert_eq!(uri.collection(), Some("app.bsky.feed.post"));
390 assert_eq!(uri.rkey(), Some("3jui7kd54zh2y"));
391 }
392
393 #[test]
394 fn display_roundtrip() {
395 let input = "at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y";
396 let uri = AtUri::new(input).unwrap();
397 assert_eq!(uri.to_string(), input);
398 }
399
400 #[test]
406 fn rejects_uppercase_scheme_via_regex() {
407 assert!(AtUri::new("AT://did:plc:asdf123").is_err());
408 assert!(AtUri::new("At://did:plc:asdf123").is_err());
409 assert!(AtUri::new("at://DID:plc:asdf123").is_err());
411 assert!(AtUri::new("at://did:PLC:asdf123").is_err());
412 }
413
414 #[test]
417 fn make_with_authority_only() {
418 let uri = AtUri::make("alice.bsky.social", None, None).unwrap();
419 assert_eq!(uri.to_string(), "at://alice.bsky.social");
420 assert!(uri.collection().is_none());
421 assert!(uri.rkey().is_none());
422 }
423
424 #[test]
425 fn make_with_authority_and_collection() {
426 let uri = AtUri::make("alice.bsky.social", Some("app.bsky.feed.post"), None).unwrap();
427 assert_eq!(uri.to_string(), "at://alice.bsky.social/app.bsky.feed.post");
428 }
429
430 #[test]
431 fn make_full() {
432 let uri = AtUri::make(
433 "did:plc:abc",
434 Some("app.bsky.feed.post"),
435 Some("3jui7kd54zh2y"),
436 )
437 .unwrap();
438 assert_eq!(
439 uri.to_string(),
440 "at://did:plc:abc/app.bsky.feed.post/3jui7kd54zh2y"
441 );
442 }
443
444 #[test]
445 fn make_rkey_without_collection_is_error() {
446 let err = AtUri::make("alice.bsky.social", None, Some("rec")).unwrap_err();
447 assert!(err.reason.contains("rkey without collection"));
448 }
449
450 #[test]
451 fn make_validates_each_part() {
452 assert!(AtUri::make("alice.bsky.social", Some("not an nsid"), None).is_err());
454 assert!(AtUri::make("alice.bsky.social", Some("app.bsky.feed.post"), Some(".")).is_err());
456 assert!(AtUri::make("not a handle", None, None).is_err());
458 }
459
460 #[test]
463 fn origin_is_at_authority_only() {
464 let uri = AtUri::new("at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y").unwrap();
465 assert_eq!(uri.origin(), "at://did:plc:asdf123");
466 assert_eq!(uri.protocol(), "at:");
467 }
468
469 #[test]
472 fn set_authority_accepts_valid_did_and_handle() {
473 let mut uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.post").unwrap();
474 uri.set_authority("did:plc:abc").unwrap();
475 assert_eq!(uri.authority(), "did:plc:abc");
476 uri.set_authority("bob.example").unwrap();
477 assert_eq!(uri.authority(), "bob.example");
478 }
479
480 #[test]
481 fn set_authority_rejects_invalid_value_and_leaves_uri_unchanged() {
482 let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
483 assert!(uri.set_authority("not an identifier").is_err());
484 assert_eq!(uri.authority(), "alice.bsky.social");
485 }
486
487 #[test]
488 fn set_collection_validates_nsid() {
489 let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
490 uri.set_collection(Some("app.bsky.feed.post")).unwrap();
491 assert_eq!(uri.collection(), Some("app.bsky.feed.post"));
492 assert!(uri.set_collection(Some("not an nsid")).is_err());
493 assert_eq!(uri.collection(), Some("app.bsky.feed.post"));
494 }
495
496 #[test]
497 fn clearing_collection_also_clears_rkey() {
498 let mut uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.post/abc").unwrap();
499 uri.set_collection(None).unwrap();
500 assert!(uri.collection().is_none());
501 assert!(uri.rkey().is_none());
502 }
503
504 #[test]
505 fn set_rkey_requires_collection() {
506 let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
507 let err = uri.set_rkey(Some("abc")).unwrap_err();
508 assert!(err.reason.contains("no collection"));
509 }
510
511 #[test]
512 fn set_rkey_validates_record_key() {
513 let mut uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.post/abc").unwrap();
514 assert!(uri.set_rkey(Some(".")).is_err());
515 assert_eq!(uri.rkey(), Some("abc"));
517 uri.set_rkey(Some("def-123")).unwrap();
518 assert_eq!(uri.rkey(), Some("def-123"));
519 uri.set_rkey(None).unwrap();
520 assert!(uri.rkey().is_none());
521 }
522
523 #[test]
524 fn set_fragment_requires_leading_slash() {
525 let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
526 assert!(uri.set_fragment(Some("no-slash")).is_err());
527 uri.set_fragment(Some("/path/to/thing")).unwrap();
528 assert_eq!(uri.fragment(), Some("/path/to/thing"));
529 uri.set_fragment(None).unwrap();
530 assert!(uri.fragment().is_none());
531 }
532
533 #[test]
536 fn resolve_absolute_uri_returns_that_uri() {
537 let base = AtUri::new("at://alice.bsky.social").unwrap();
538 let resolved = base
539 .resolve("at://did:plc:abc/app.bsky.feed.post/xyz")
540 .unwrap();
541 assert_eq!(
542 resolved.to_string(),
543 "at://did:plc:abc/app.bsky.feed.post/xyz"
544 );
545 }
546
547 #[test]
548 fn resolve_fragment_keeps_authority_and_path() {
549 let base = AtUri::new("at://alice.bsky.social/app.bsky.feed.post/abc").unwrap();
550 let resolved = base.resolve("#/likeCount").unwrap();
551 assert_eq!(
552 resolved.to_string(),
553 "at://alice.bsky.social/app.bsky.feed.post/abc#/likeCount"
554 );
555 }
556
557 #[test]
558 fn resolve_path_replaces_collection_and_rkey() {
559 let base = AtUri::new("at://alice.bsky.social/foo.bar.baz/old").unwrap();
560 let resolved = base.resolve("app.bsky.feed.post/new").unwrap();
561 assert_eq!(
562 resolved.to_string(),
563 "at://alice.bsky.social/app.bsky.feed.post/new"
564 );
565 }
566
567 #[test]
568 fn resolve_single_segment_replaces_only_collection() {
569 let base = AtUri::new("at://alice.bsky.social/foo.bar.baz/rec").unwrap();
570 let resolved = base.resolve("app.bsky.feed.post").unwrap();
571 assert_eq!(
573 resolved.to_string(),
574 "at://alice.bsky.social/app.bsky.feed.post"
575 );
576 }
577
578 #[test]
579 fn resolve_rejects_too_many_segments() {
580 let base = AtUri::new("at://alice.bsky.social").unwrap();
581 let err = base.resolve("a/b/c/d").unwrap_err();
582 assert!(err.reason.contains("too many path segments"));
583 }
584}