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