A better Rust ATProto crate
102
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial migration of the easier leaf types

+1163 -1057
+6 -67
crates/jacquard-common/src/bos.rs
··· 32 32 //! } 33 33 //! ``` 34 34 35 + use core::{fmt, marker::PhantomData, ops::Deref}; 36 + 35 37 use alloc::{ 36 38 borrow::{Cow, ToOwned}, 37 39 boxed::Box, ··· 39 41 vec::Vec, 40 42 }; 41 43 44 + use serde::{Deserialize, Serialize}; 42 45 use smol_str::SmolStr; 43 46 44 - use crate::CowStr; 47 + use crate::{CowStr, IntoStatic}; 45 48 46 49 mod internal { 47 50 pub trait Ref<T: ?Sized> { ··· 140 143 // --- Standard library impls --- 141 144 142 145 impl_bos! { 146 + //{T: ?Sized} T => T 147 + 143 148 {T: ?Sized} &mut T => T 144 149 145 150 {T, const N: usize} [T; N] => [T] ··· 188 193 /// annotation required) and provides small-string inline storage without heap allocation 189 194 /// for strings of 22 bytes or fewer. 190 195 pub type DefaultStr = SmolStr; 191 - 192 - /// Construct `S` from either a borrowed `&'de str` or an owned `SmolStr`. 193 - /// 194 - /// Validated string types (Handle, Did, Nsid, etc.) may need to normalise input during 195 - /// deserialization (e.g. lowercasing handles). The normalisation always produces a `SmolStr`, 196 - /// but when the input is already valid the deserializer wants to borrow directly from the 197 - /// input buffer. This trait provides the two construction paths so that a single custom 198 - /// deserialize visitor can produce any backing type. 199 - pub trait StrConsumer<'de, 'r>: Bos<str> + Sized { 200 - /// Construct from a borrowed string slice (zero-copy path). 201 - fn from_borrowed_str(s: &'de str) -> Self; 202 - 203 - /// Construct from an owned `SmolStr` (normalisation/allocation path). 204 - fn from_smolstr(s: &'r SmolStr) -> Self; 205 - } 206 - 207 - impl<'de, 'r> StrConsumer<'de, 'r> for SmolStr { 208 - #[inline] 209 - fn from_borrowed_str(s: &'de str) -> Self { 210 - SmolStr::new(s) 211 - } 212 - 213 - #[inline] 214 - fn from_smolstr(s: &'r SmolStr) -> Self { 215 - s.clone() 216 - } 217 - } 218 - 219 - impl<'de, 'r> StrConsumer<'de, 'r> for String { 220 - #[inline] 221 - fn from_borrowed_str(s: &'de str) -> Self { 222 - s.into() 223 - } 224 - 225 - #[inline] 226 - fn from_smolstr(s: &'r SmolStr) -> Self { 227 - s.clone().into() 228 - } 229 - } 230 - 231 - impl<'de, 'r> StrConsumer<'de, 'r> for &'de str 232 - where 233 - 'r: 'de, 234 - { 235 - #[inline] 236 - fn from_borrowed_str(s: &'de str) -> Self { 237 - s 238 - } 239 - 240 - #[inline] 241 - fn from_smolstr(s: &'r SmolStr) -> Self { 242 - s.as_str() 243 - } 244 - } 245 - 246 - impl<'de, 'r> StrConsumer<'de, 'r> for CowStr<'de> { 247 - #[inline] 248 - fn from_borrowed_str(s: &'de str) -> Self { 249 - CowStr::Borrowed(s) 250 - } 251 - 252 - #[inline] 253 - fn from_smolstr(s: &'r SmolStr) -> Self { 254 - CowStr::Owned(s.clone()) 255 - } 256 - } 257 196 258 197 #[cfg(test)] 259 198 mod tests {
+21 -21
crates/jacquard-common/src/jetstream.rs
··· 21 21 #[serde(skip_serializing_if = "Option::is_none")] 22 22 #[serde(borrow)] 23 23 #[builder(into)] 24 - pub wanted_collections: Option<Vec<Nsid<'a>>>, 24 + pub wanted_collections: Option<Vec<Nsid<CowStr<'a>>>>, 25 25 26 26 /// Filter by DIDs (max 10,000) 27 27 #[serde(skip_serializing_if = "Option::is_none")] 28 - #[serde(borrow)] 28 + // TODO: add S: Bos<...> param 29 29 #[builder(into)] 30 - pub wanted_dids: Option<Vec<Did<'a>>>, 30 + pub wanted_dids: Option<Vec<Did>>, 31 31 32 32 /// Unix microseconds timestamp to start playback 33 33 #[serde(skip_serializing_if = "Option::is_none")] ··· 92 92 pub operation: CommitOperation, 93 93 /// Collection NSID 94 94 #[serde(borrow)] 95 - pub collection: Nsid<'a>, 95 + pub collection: Nsid<CowStr<'a>>, 96 96 /// Record key 97 97 #[serde(borrow)] 98 98 pub rkey: Rkey<'a>, ··· 110 110 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 111 111 pub struct JetstreamIdentity<'a> { 112 112 /// DID 113 - #[serde(borrow)] 114 - pub did: Did<'a>, 113 + // TODO: add S: Bos<...> param 114 + pub did: Did, 115 115 /// Handle 116 116 #[serde(skip_serializing_if = "Option::is_none")] 117 117 #[serde(borrow)] 118 - pub handle: Option<Handle<'a>>, 118 + pub handle: Option<Handle<CowStr<'a>>>, 119 119 /// Sequence number 120 120 pub seq: i64, 121 121 /// Timestamp ··· 128 128 /// Account active status 129 129 pub active: bool, 130 130 /// DID 131 - #[serde(borrow)] 132 - pub did: Did<'a>, 131 + // TODO: add S: Bos<...> param 132 + pub did: Did, 133 133 /// Sequence number 134 134 pub seq: i64, 135 135 /// Timestamp ··· 148 148 /// Commit event 149 149 Commit { 150 150 /// DID 151 - #[serde(borrow)] 152 - did: Did<'a>, 151 + // TODO: add S: Bos<...> param 152 + did: Did, 153 153 /// Unix microseconds timestamp 154 154 time_us: i64, 155 155 /// Commit details ··· 159 159 /// Identity event 160 160 Identity { 161 161 /// DID 162 - #[serde(borrow)] 163 - did: Did<'a>, 162 + // TODO: add S: Bos<...> param 163 + did: Did, 164 164 /// Unix microseconds timestamp 165 165 time_us: i64, 166 166 /// Identity details ··· 170 170 /// Account event 171 171 Account { 172 172 /// DID 173 - #[serde(borrow)] 174 - did: Did<'a>, 173 + // TODO: add S: Bos<...> param 174 + did: Did, 175 175 /// Unix microseconds timestamp 176 176 time_us: i64, 177 177 /// Account details ··· 188 188 /// Commit event 189 189 Commit { 190 190 /// DID 191 - #[serde(borrow)] 192 - did: Did<'a>, 191 + // TODO: add S: Bos<...> param 192 + did: Did, 193 193 /// Unix microseconds timestamp 194 194 time_us: i64, 195 195 /// Commit details ··· 199 199 /// Identity event 200 200 Identity { 201 201 /// DID 202 - #[serde(borrow)] 203 - did: Did<'a>, 202 + // TODO: add S: Bos<...> param 203 + did: Did, 204 204 /// Unix microseconds timestamp 205 205 time_us: i64, 206 206 /// Identity details ··· 210 210 /// Account event 211 211 Account { 212 212 /// DID 213 - #[serde(borrow)] 214 - did: Did<'a>, 213 + // TODO: add S: Bos<...> param 214 + did: Did, 215 215 /// Unix microseconds timestamp 216 216 time_us: i64, 217 217 /// Account details
+1 -1
crates/jacquard-common/src/lib.rs
··· 214 214 215 215 pub use cowstr::CowStr; 216 216 pub use into_static::IntoStatic; 217 - pub use bos::{Bos, BorrowOrShare, DefaultStr, StrConsumer}; 217 + pub use bos::{Bos, BorrowOrShare, DefaultStr}; 218 218 219 219 /// A copy-on-write immutable string type that uses [`smol_str::SmolStr`] for 220 220 /// the "owned" variant.
+28 -26
crates/jacquard-common/src/service_auth.rs
··· 77 77 #[error("audience mismatch: expected {expected}, got {actual}")] 78 78 AudienceMismatch { 79 79 /// Expected audience DID 80 - expected: Did<'static>, 80 + expected: Did, 81 81 /// Actual audience DID in token 82 - actual: Did<'static>, 82 + actual: Did, 83 83 }, 84 84 85 85 /// Method mismatch (lxm field) 86 86 #[error("method mismatch: expected {expected}, got {actual:?}")] 87 87 MethodMismatch { 88 88 /// Expected method NSID 89 - expected: Nsid<'static>, 89 + expected: Nsid, 90 90 /// Actual method NSID in token (if any) 91 - actual: Option<Nsid<'static>>, 91 + actual: Option<Nsid>, 92 92 }, 93 93 94 94 /// Missing required field ··· 128 128 #[derive(Debug, Clone, Serialize, Deserialize)] 129 129 pub struct ServiceAuthClaims<'a> { 130 130 /// Issuer (user's DID) 131 - #[serde(borrow)] 132 - pub iss: Did<'a>, 131 + pub iss: Did, 133 132 134 133 /// Audience (target service DID) 135 - #[serde(borrow)] 136 - pub aud: Did<'a>, 134 + pub aud: Did, 137 135 138 136 /// Expiration time (unix timestamp) 139 137 pub exp: i64, ··· 147 145 148 146 /// Lexicon method NSID (method binding) 149 147 #[serde(borrow, skip_serializing_if = "Option::is_none")] 150 - pub lxm: Option<Nsid<'a>>, 148 + pub lxm: Option<Nsid<CowStr<'a>>>, 151 149 } 152 150 153 151 impl<'a> IntoStatic for ServiceAuthClaims<'a> { ··· 196 194 } 197 195 198 196 /// Check if the method (lxm) matches the expected NSID. 199 - pub fn check_method(&self, nsid: &Nsid) -> bool { 197 + pub fn check_method(&self, nsid: &Nsid<CowStr<'_>>) -> bool { 200 198 self.lxm 201 199 .as_ref() 202 200 .map(|lxm| lxm.as_str() == nsid.as_str()) ··· 204 202 } 205 203 206 204 /// Require that the method (lxm) matches the expected NSID. 207 - pub fn require_method(&self, nsid: &Nsid) -> Result<(), ServiceAuthError> { 205 + pub fn require_method(&self, nsid: &Nsid<CowStr<'_>>) -> Result<(), ServiceAuthError> { 208 206 if !self.check_method(nsid) { 209 207 return Err(ServiceAuthError::MethodMismatch { 210 - expected: nsid.clone().into_static(), 211 - actual: self.lxm.as_ref().map(|l| l.clone().into_static()), 208 + // TODO: remove this call once migration complete 209 + expected: unsafe { Nsid::unchecked(nsid.as_str()).into_static() }, 210 + actual: self 211 + .lxm 212 + .as_ref() 213 + .map(|l| Nsid::new_owned(l.as_str()).unwrap()), 212 214 }); 213 215 } 214 216 Ok(()) ··· 420 422 fn test_claims_expiration() { 421 423 let now = chrono::Utc::now().timestamp(); 422 424 let expired_claims = ServiceAuthClaims { 423 - iss: Did::new("did:plc:test").unwrap(), 424 - aud: Did::new("did:web:example.com").unwrap(), 425 + iss: Did::new_static("did:plc:test").unwrap(), 426 + aud: Did::new_static("did:web:example.com").unwrap(), 425 427 exp: now - 100, 426 428 iat: now - 200, 427 429 jti: None, ··· 431 433 assert!(expired_claims.is_expired()); 432 434 433 435 let valid_claims = ServiceAuthClaims { 434 - iss: Did::new("did:plc:test").unwrap(), 435 - aud: Did::new("did:web:example.com").unwrap(), 436 + iss: Did::new_static("did:plc:test").unwrap(), 437 + aud: Did::new_static("did:web:example.com").unwrap(), 436 438 exp: now + 100, 437 439 iat: now, 438 440 jti: None, ··· 446 448 fn test_audience_validation() { 447 449 let now = chrono::Utc::now().timestamp(); 448 450 let claims = ServiceAuthClaims { 449 - iss: Did::new("did:plc:test").unwrap(), 450 - aud: Did::new("did:web:example.com").unwrap(), 451 + iss: Did::new_static("did:plc:test").unwrap(), 452 + aud: Did::new_static("did:web:example.com").unwrap(), 451 453 exp: now + 100, 452 454 iat: now, 453 455 jti: None, 454 456 lxm: None, 455 457 }; 456 458 457 - let expected_aud = Did::new("did:web:example.com").unwrap(); 459 + let expected_aud = Did::new_static("did:web:example.com").unwrap(); 458 460 assert!(claims.validate(&expected_aud).is_ok()); 459 461 460 - let wrong_aud = Did::new("did:web:wrong.com").unwrap(); 462 + let wrong_aud = Did::new_static("did:web:wrong.com").unwrap(); 461 463 assert!(matches!( 462 464 claims.validate(&wrong_aud), 463 465 Err(ServiceAuthError::AudienceMismatch { .. }) ··· 467 469 #[test] 468 470 fn test_method_check() { 469 471 let claims = ServiceAuthClaims { 470 - iss: Did::new("did:plc:test").unwrap(), 471 - aud: Did::new("did:web:example.com").unwrap(), 472 + iss: Did::new_static("did:plc:test").unwrap(), 473 + aud: Did::new_static("did:web:example.com").unwrap(), 472 474 exp: chrono::Utc::now().timestamp() + 100, 473 475 iat: chrono::Utc::now().timestamp(), 474 476 jti: None, 475 - lxm: Some(Nsid::new("app.bsky.feed.getFeedSkeleton").unwrap()), 477 + lxm: Some(Nsid::new_static("app.bsky.feed.getFeedSkeleton").unwrap()), 476 478 }; 477 479 478 - let expected = Nsid::new("app.bsky.feed.getFeedSkeleton").unwrap(); 480 + let expected = Nsid::new_static("app.bsky.feed.getFeedSkeleton").unwrap(); 479 481 assert!(claims.check_method(&expected)); 480 482 481 - let wrong = Nsid::new("app.bsky.feed.getTimeline").unwrap(); 483 + let wrong = Nsid::new_static("app.bsky.feed.getTimeline".into()).unwrap(); 482 484 assert!(!claims.check_method(&wrong)); 483 485 } 484 486 }
+50 -28
crates/jacquard-common/src/types/aturi.rs
··· 1 + use crate::cowstr::ToCowStr; 1 2 use crate::types::ident::AtIdentifier; 2 3 use crate::types::nsid::Nsid; 3 4 use crate::types::recordkey::{RecordKey, Rkey}; ··· 48 49 uri: CowStr<'u>, 49 50 #[borrows(uri)] 50 51 #[covariant] 51 - pub authority: AtIdentifier<'this>, 52 + pub authority: AtIdentifier<CowStr<'this>>, 52 53 #[borrows(uri)] 53 54 #[covariant] 54 55 pub path: Option<RepoPath<'this>>, ··· 66 67 CowStr::Owned(uri.as_ref().to_smolstr()), 67 68 |uri| { 68 69 let parts = ATURI_REGEX.captures(uri).unwrap(); 69 - unsafe { AtIdentifier::unchecked(parts.name("authority").unwrap().as_str()) } 70 + AtIdentifier::new_cow(parts.name("authority").unwrap().as_str().to_cowstr()) 71 + .unwrap() 70 72 }, 71 73 |uri| { 72 74 let parts = ATURI_REGEX.captures(uri).unwrap(); 73 75 if let Some(collection) = parts.name("collection") { 74 - let collection = unsafe { Nsid::unchecked(collection.as_str()) }; 76 + let collection = 77 + unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; 75 78 let rkey = if let Some(rkey) = parts.name("rkey") { 76 79 let rkey = unsafe { RecordKey::from(Rkey::unchecked(rkey.as_str())) }; 77 80 Some(rkey) ··· 107 110 #[derive(Clone, PartialEq, Eq, Hash, Debug)] 108 111 pub struct RepoPath<'u> { 109 112 /// Collection NSID (e.g., `app.bsky.feed.post`) 110 - pub collection: Nsid<'u>, 113 + pub collection: Nsid<CowStr<'u>>, 111 114 /// Optional record key identifying a specific record 112 115 pub rkey: Option<RecordKey<Rkey<'u>>>, 113 116 } ··· 147 150 pub fn new(uri: &'u str) -> Result<Self, AtStrError> { 148 151 if let Some(parts) = ATURI_REGEX.captures(uri) { 149 152 if let Some(authority) = parts.name("authority") { 150 - let authority = AtIdentifier::new(authority.as_str()) 153 + let authority = AtIdentifier::new_cow(authority.as_str().to_cowstr()) 151 154 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 152 155 let path = if let Some(collection) = parts.name("collection") { 153 - let collection = Nsid::new(collection.as_str()) 156 + let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 154 157 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 155 158 let rkey = if let Some(rkey) = parts.name("rkey") { 156 159 let rkey = ··· 196 199 pub fn raw(uri: &'u str) -> Self { 197 200 if let Some(parts) = ATURI_REGEX.captures(uri) { 198 201 if let Some(authority) = parts.name("authority") { 199 - let authority = AtIdentifier::raw(authority.as_str()); 202 + let authority = AtIdentifier::new_cow(authority.as_str().to_cowstr()).unwrap(); 200 203 let path = if let Some(collection) = parts.name("collection") { 201 - let collection = Nsid::raw(collection.as_str()); 204 + let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())).unwrap(); 202 205 let rkey = if let Some(rkey) = parts.name("rkey") { 203 206 let rkey = RecordKey::from(Rkey::raw(rkey.as_str())); 204 207 Some(rkey) ··· 237 240 pub unsafe fn unchecked(uri: &'u str) -> Self { 238 241 if let Some(parts) = ATURI_REGEX.captures(uri) { 239 242 if let Some(authority) = parts.name("authority") { 240 - let authority = unsafe { AtIdentifier::unchecked(authority.as_str()) }; 243 + let authority = 244 + unsafe { AtIdentifier::unchecked_cow(authority.as_str().to_cowstr()) }; 241 245 let path = if let Some(collection) = parts.name("collection") { 242 - let collection = unsafe { Nsid::unchecked(collection.as_str()) }; 246 + let collection = 247 + unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; 243 248 let rkey = if let Some(rkey) = parts.name("rkey") { 244 249 let rkey = RecordKey::from(unsafe { Rkey::unchecked(rkey.as_str()) }); 245 250 Some(rkey) ··· 270 275 Self { 271 276 inner: InnerBuilder { 272 277 uri: CowStr::Borrowed(uri), 273 - authority_builder: |_| unsafe { AtIdentifier::unchecked(uri) }, 278 + authority_builder: |_| unsafe { 279 + AtIdentifier::unchecked_cow(uri.to_cowstr()) 280 + }, 274 281 path_builder: |_| None, 275 282 fragment_builder: |_| None, 276 283 } ··· 281 288 Self { 282 289 inner: InnerBuilder { 283 290 uri: CowStr::Borrowed(uri), 284 - authority_builder: |_| unsafe { AtIdentifier::unchecked(uri) }, 291 + authority_builder: |_| unsafe { AtIdentifier::unchecked_cow(uri.to_cowstr()) }, 285 292 path_builder: |_| None, 286 293 fragment_builder: |_| None, 287 294 } ··· 323 330 } 324 331 325 332 /// Get the authority component (DID or handle) 326 - pub fn authority(&self) -> &AtIdentifier<'_> { 333 + pub fn authority(&self) -> &AtIdentifier<CowStr<'_>> { 327 334 self.inner.borrow_authority() 328 335 } 329 336 ··· 338 345 } 339 346 340 347 /// Get the collection NSID from the path, if present 341 - pub fn collection(&self) -> Option<&Nsid<'_>> { 348 + pub fn collection(&self) -> Option<&Nsid<CowStr<'_>>> { 342 349 self.inner.borrow_path().as_ref().map(|p| &p.collection) 343 350 } 344 351 ··· 403 410 let _authority = AtIdentifier::new(authority.as_str()) 404 411 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e))?; 405 412 let path = if let Some(collection) = parts.name("collection") { 406 - let collection = Nsid::new(collection.as_str()).map_err(|e| { 413 + let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())).map_err(|e| { 407 414 AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e) 408 415 })?; 409 416 let rkey = if let Some(rkey) = parts.name("rkey") { ··· 425 432 |uri| { 426 433 let parts = ATURI_REGEX.captures(uri).unwrap(); 427 434 unsafe { 428 - AtIdentifier::unchecked(parts.name("authority").unwrap().as_str()) 435 + AtIdentifier::unchecked_cow( 436 + parts.name("authority").unwrap().as_str().to_cowstr(), 437 + ) 429 438 } 430 439 }, 431 440 |uri| { 432 441 if path.is_some() { 433 442 let parts = ATURI_REGEX.captures(uri).unwrap(); 434 443 if let Some(collection) = parts.name("collection") { 435 - let collection = 436 - unsafe { Nsid::unchecked(collection.as_str()) }; 444 + let collection = unsafe { 445 + Nsid::unchecked(CowStr::Borrowed(collection.as_str())) 446 + }; 437 447 let rkey = if let Some(rkey) = parts.name("rkey") { 438 448 let rkey = unsafe { 439 449 RecordKey::from(Rkey::unchecked(rkey.as_str())) ··· 483 493 let authority = AtIdentifier::new_static(authority.as_str()) 484 494 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 485 495 let path = if let Some(collection) = parts.name("collection") { 486 - let collection = Nsid::new_static(collection.as_str()) 496 + let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 487 497 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 488 498 let rkey = if let Some(rkey) = parts.name("rkey") { 489 499 let rkey = ··· 535 545 let _authority = AtIdentifier::new(authority.as_str()) 536 546 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 537 547 let path = if let Some(collection) = parts.name("collection") { 538 - let collection = Nsid::new(collection.as_str()) 548 + let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 539 549 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 540 550 let rkey = if let Some(rkey) = parts.name("rkey") { 541 551 let rkey = ··· 557 567 |uri| { 558 568 let parts = ATURI_REGEX.captures(uri).unwrap(); 559 569 unsafe { 560 - AtIdentifier::unchecked(parts.name("authority").unwrap().as_str()) 570 + AtIdentifier::unchecked_cow( 571 + parts.name("authority").unwrap().as_str().to_cowstr(), 572 + ) 561 573 } 562 574 }, 563 575 |uri| { 564 576 if path.is_some() { 565 577 let parts = ATURI_REGEX.captures(uri).unwrap(); 566 578 if let Some(collection) = parts.name("collection") { 567 - let collection = 568 - unsafe { Nsid::unchecked(collection.as_str()) }; 579 + let collection = unsafe { 580 + Nsid::unchecked(CowStr::Borrowed(collection.as_str())) 581 + }; 569 582 let rkey = if let Some(rkey) = parts.name("rkey") { 570 583 let rkey = unsafe { 571 584 RecordKey::from(Rkey::unchecked(rkey.as_str())) ··· 617 630 self.inner.borrow_uri().clone().into_static(), 618 631 |uri| { 619 632 let parts = ATURI_REGEX.captures(uri).unwrap(); 620 - unsafe { AtIdentifier::unchecked(parts.name("authority").unwrap().as_str()) } 633 + unsafe { 634 + AtIdentifier::unchecked_cow( 635 + parts.name("authority").unwrap().as_str().to_cowstr(), 636 + ) 637 + } 621 638 }, 622 639 |uri| { 623 640 if self.inner.borrow_path().is_some() { 624 641 let parts = ATURI_REGEX.captures(uri).unwrap(); 625 642 if let Some(collection) = parts.name("collection") { 626 - let collection = unsafe { Nsid::unchecked(collection.as_str()) }; 643 + let collection = 644 + unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; 627 645 let rkey = if let Some(rkey) = parts.name("rkey") { 628 646 let rkey = 629 647 unsafe { RecordKey::from(Rkey::unchecked(rkey.as_str())) }; ··· 711 729 let _authority = AtIdentifier::new(authority.as_str()) 712 730 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 713 731 let _path = if let Some(collection) = parts.name("collection") { 714 - let collection = Nsid::new(collection.as_str()) 732 + let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 715 733 .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 716 734 let rkey = if let Some(rkey) = parts.name("rkey") { 717 735 let rkey = ··· 734 752 |uri| { 735 753 let parts = ATURI_REGEX.captures(uri).unwrap(); 736 754 unsafe { 737 - AtIdentifier::unchecked(parts.name("authority").unwrap().as_str()) 755 + AtIdentifier::unchecked_cow( 756 + parts.name("authority").unwrap().as_str().to_cowstr(), 757 + ) 738 758 } 739 759 }, 740 760 |uri| { 741 761 let parts = ATURI_REGEX.captures(uri).unwrap(); 742 762 if let Some(collection) = parts.name("collection") { 743 - let collection = unsafe { Nsid::unchecked(collection.as_str()) }; 763 + let collection = unsafe { 764 + Nsid::unchecked(CowStr::Borrowed(collection.as_str())) 765 + }; 744 766 let rkey = if let Some(rkey) = parts.name("rkey") { 745 767 let rkey = 746 768 unsafe { RecordKey::from(Rkey::unchecked(rkey.as_str())) };
+2 -2
crates/jacquard-common/src/types/collection.rs
··· 3 3 4 4 use serde::{Deserialize, Serialize}; 5 5 6 - use crate::IntoStatic; 7 6 use crate::types::value::Data; 8 7 use crate::types::{ 9 8 aturi::RepoPath, ··· 11 10 recordkey::{RecordKey, RecordKeyType, Rkey}, 12 11 }; 13 12 use crate::xrpc::XrpcResp; 13 + use crate::{CowStr, IntoStatic}; 14 14 15 15 /// Trait for a collection of records that can be stored in a repository. 16 16 /// ··· 35 35 /// Panics if [`Self::NSID`] is not a valid NSID. 36 36 /// 37 37 /// [`Nsid`]: crate::types::string::Nsid 38 - fn nsid() -> crate::types::nsid::Nsid<'static> { 38 + fn nsid() -> crate::types::nsid::Nsid<CowStr<'static>> { 39 39 Nsid::new_static(Self::NSID).expect("should be valid NSID") 40 40 } 41 41
+207 -194
crates/jacquard-common/src/types/did.rs
··· 1 + use crate::bos::{Bos, DefaultStr}; 1 2 use crate::types::string::AtStrError; 2 3 use crate::{CowStr, IntoStatic}; 3 4 use alloc::string::{String, ToString}; ··· 10 11 use regex_automata::meta::Regex; 11 12 #[cfg(target_arch = "wasm32")] 12 13 use regex_lite::Regex; 13 - use serde::{Deserialize, Deserializer, Serialize, de::Error}; 14 + use serde::{Deserialize, Deserializer, Serialize}; 14 15 use smol_str::{SmolStr, ToSmolStr}; 15 16 16 17 use super::Lazy; 17 18 18 - /// Decentralized Identifier (DID) for AT Protocol accounts 19 + /// Decentralized Identifier (DID) for AT Protocol accounts. 19 20 /// 20 21 /// DIDs are the persistent, long-term account identifiers in AT Protocol. Unlike handles, 21 22 /// which can change, a DID permanently identifies an account across the network. 22 - /// 23 - /// Supported DID methods: 24 - /// - `did:plc` - Bluesky's novel DID method 25 - /// - `did:web` - Based on HTTPS and DNS 26 - /// 27 - /// Validation enforces a maximum length of 2048 characters and uses the pattern: 28 - /// `did:[method]:[method-specific-id]` where the method is lowercase ASCII and the 29 - /// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs. 30 23 /// 31 24 /// See: <https://atproto.com/specs/did> 32 - #[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] 25 + #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] 33 26 #[serde(transparent)] 34 27 #[repr(transparent)] 35 - pub struct Did<'d>(pub(crate) CowStr<'d>); 28 + pub struct Did<S: Bos<str> = DefaultStr>(pub(crate) S); 36 29 37 30 /// Regex for DID validation per AT Protocol spec. 38 31 /// 39 32 /// Note: This regex allows `%` in the identifier but prevents DIDs from ending with `:` or `%`. 40 33 /// It does NOT validate that percent-encoding is well-formed (i.e., `%XX` where XX are hex digits). 41 - /// This matches the behavior of the official TypeScript implementation, which also does not 42 - /// enforce percent-encoding validity at validation time. While the spec states "percent sign 43 - /// must be followed by two hex characters," this is treated as a best practice rather than 44 - /// a hard validation requirement. 34 + /// This matches the behavior of the official TypeScript implementation. 45 35 pub static DID_REGEX: Lazy<Regex> = 46 36 Lazy::new(|| Regex::new(r"^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$").unwrap()); 47 37 48 - impl<'d> Did<'d> { 49 - /// Fallible constructor, validates, borrows from input 50 - pub fn new(did: &'d str) -> Result<Self, AtStrError> { 51 - let did = did.strip_prefix("at://").unwrap_or(did); 52 - if did.len() > 2048 { 53 - Err(AtStrError::too_long("did", did, 2048, did.len())) 54 - } else if !DID_REGEX.is_match(did) { 55 - Err(AtStrError::regex( 56 - "did", 57 - did, 58 - SmolStr::new_static("invalid"), 59 - )) 60 - } else { 61 - Ok(Self(CowStr::Borrowed(did))) 62 - } 38 + // --------------------------------------------------------------------------- 39 + // Shared validation 40 + // --------------------------------------------------------------------------- 41 + 42 + fn strip_did_prefix(did: &str) -> &str { 43 + did.strip_prefix("at://").unwrap_or(did) 44 + } 45 + 46 + fn validate_did(did: &str) -> Result<(), AtStrError> { 47 + if did.len() > 2048 { 48 + Err(AtStrError::too_long("did", did, 2048, did.len())) 49 + } else if !DID_REGEX.is_match(did) { 50 + Err(AtStrError::regex( 51 + "did", 52 + did, 53 + SmolStr::new_static("invalid"), 54 + )) 55 + } else { 56 + Ok(()) 57 + } 58 + } 59 + 60 + // --------------------------------------------------------------------------- 61 + // Core methods 62 + // --------------------------------------------------------------------------- 63 + 64 + impl<S: Bos<str> + AsRef<str>> Did<S> { 65 + /// Get the DID as a string slice. 66 + pub fn as_str(&self) -> &str { 67 + self.0.as_ref() 63 68 } 69 + } 64 70 65 - /// Fallible constructor, validates, borrows from input if possible 71 + impl<S: Bos<str>> Did<S> { 72 + /// Infallible unchecked constructor. 66 73 /// 67 - /// May allocate for a long DID with an at:// prefix, otherwise borrows. 68 - pub fn new_cow(did: CowStr<'d>) -> Result<Self, AtStrError> { 69 - let did = if let Some(did) = did.strip_prefix("at://") { 70 - CowStr::copy_from_str(did) 71 - } else { 72 - did 73 - }; 74 - if did.len() > 2048 { 75 - Err(AtStrError::too_long("did", &did, 2048, did.len())) 76 - } else if !DID_REGEX.is_match(&did) { 77 - Err(AtStrError::regex( 78 - "did", 79 - &did, 80 - SmolStr::new_static("invalid"), 81 - )) 82 - } else { 83 - Ok(Self(did)) 84 - } 74 + /// # Safety 75 + /// 76 + /// The caller must ensure the DID is valid. 77 + pub unsafe fn unchecked(did: S) -> Self { 78 + Did(did) 79 + } 80 + } 81 + 82 + // --------------------------------------------------------------------------- 83 + // Borrowed construction 84 + // --------------------------------------------------------------------------- 85 + 86 + impl<'d> Did<&'d str> { 87 + /// Fallible constructor, validates, borrows from input. 88 + /// Accepts (and strips) preceding 'at://' if present. 89 + pub fn new(did: &'d str) -> Result<Self, AtStrError> { 90 + let stripped = strip_did_prefix(did); 91 + validate_did(stripped)?; 92 + Ok(Self(stripped)) 93 + } 94 + 95 + /// Infallible constructor. Panics on invalid DIDs. 96 + pub fn raw(did: &'d str) -> Self { 97 + Self::new(did).expect("invalid DID") 85 98 } 99 + } 86 100 87 - /// Fallible constructor, validates, takes ownership 101 + // --------------------------------------------------------------------------- 102 + // Owned construction 103 + // --------------------------------------------------------------------------- 104 + 105 + impl<S: Bos<str> + From<SmolStr>> Did<S> { 106 + /// Fallible constructor, validates, takes ownership. 88 107 pub fn new_owned(did: impl AsRef<str>) -> Result<Self, AtStrError> { 89 108 let did = did.as_ref(); 90 - let did = did.strip_prefix("at://").unwrap_or(did); 91 - if did.len() > 2048 { 92 - Err(AtStrError::too_long("did", did, 2048, did.len())) 93 - } else if !DID_REGEX.is_match(did) { 94 - Err(AtStrError::regex( 95 - "did", 96 - did, 97 - SmolStr::new_static("invalid"), 98 - )) 99 - } else { 100 - Ok(Self(CowStr::Owned(did.to_smolstr()))) 101 - } 109 + let stripped = strip_did_prefix(did); 110 + validate_did(stripped)?; 111 + Ok(Self(S::from(stripped.to_smolstr()))) 102 112 } 103 113 104 - /// Fallible constructor, validates, doesn't allocate 114 + /// Fallible constructor for static strings. Zero-alloc if possible. 105 115 pub fn new_static(did: &'static str) -> Result<Self, AtStrError> { 106 - let did = did.strip_prefix("at://").unwrap_or(did); 107 - if did.len() > 2048 { 108 - Err(AtStrError::too_long("did", did, 2048, did.len())) 109 - } else if !DID_REGEX.is_match(did) { 110 - Err(AtStrError::regex( 111 - "did", 112 - did, 113 - SmolStr::new_static("invalid"), 114 - )) 115 - } else { 116 - Ok(Self(CowStr::new_static(did))) 117 - } 116 + let stripped = strip_did_prefix(did); 117 + validate_did(stripped)?; 118 + Ok(Self(S::from(SmolStr::new_static(stripped)))) 118 119 } 120 + } 121 + 122 + // --------------------------------------------------------------------------- 123 + // CowStr construction 124 + // --------------------------------------------------------------------------- 119 125 120 - /// Infallible constructor for when you *know* the string is a valid DID. 121 - /// Will panic on invalid DIDs. If you're manually decoding atproto records 122 - /// or API values you know are valid (rather than using serde), this is the one to use. 123 - /// The `From<String>` and `From<CowStr>` impls use the same logic. 124 - pub fn raw(did: &'d str) -> Self { 125 - let did = did.strip_prefix("at://").unwrap_or(did); 126 - if did.len() > 2048 { 127 - panic!("DID too long") 128 - } else if !DID_REGEX.is_match(did) { 129 - panic!("Invalid DID") 126 + impl<'d> Did<CowStr<'d>> { 127 + /// Fallible constructor, borrows if possible. 128 + pub fn new_cow(did: CowStr<'d>) -> Result<Self, AtStrError> { 129 + let did = if let Some(stripped) = did.strip_prefix("at://") { 130 + CowStr::copy_from_str(stripped) 130 131 } else { 131 - Self(CowStr::Borrowed(did)) 132 - } 132 + did 133 + }; 134 + validate_did(&did)?; 135 + Ok(Self(did)) 133 136 } 134 137 135 - /// Infallible constructor for when you *know* the string is a valid DID. 136 - /// Marked unsafe because responsibility for upholding the invariant is on the developer. 137 - pub unsafe fn unchecked(did: &'d str) -> Self { 138 - Self(CowStr::Borrowed(did)) 138 + pub unsafe fn unchecked_cow(did: CowStr<'d>) -> Self { 139 + Self(did) 140 + } 141 + } 142 + 143 + // --------------------------------------------------------------------------- 144 + // Deserialization 145 + // --------------------------------------------------------------------------- 146 + 147 + impl<'de, S> Deserialize<'de> for Did<S> 148 + where 149 + S: Bos<str> + AsRef<str> + Deserialize<'de>, 150 + { 151 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 152 + where 153 + D: Deserializer<'de>, 154 + { 155 + let s = S::deserialize(deserializer)?; 156 + validate_did(s.as_ref()).map_err(serde::de::Error::custom)?; 157 + Ok(Did(s)) 139 158 } 159 + } 160 + 161 + // --------------------------------------------------------------------------- 162 + // Trait impls 163 + // --------------------------------------------------------------------------- 164 + 165 + impl<S: Bos<str> + IntoStatic> IntoStatic for Did<S> 166 + where 167 + S::Output: Bos<str>, 168 + { 169 + type Output = Did<S::Output>; 140 170 141 - /// Get the DID as a string slice 142 - pub fn as_str(&self) -> &str { 143 - { 144 - let this = &self.0; 145 - this 146 - } 171 + fn into_static(self) -> Self::Output { 172 + Did(self.0.into_static()) 147 173 } 148 174 } 149 175 150 - impl FromStr for Did<'_> { 176 + impl FromStr for Did { 151 177 type Err = AtStrError; 152 178 153 - /// Has to take ownership due to the lifetime constraints of the FromStr trait. 154 - /// Prefer `Did::new()` or `Did::raw` if you want to borrow. 155 179 fn from_str(s: &str) -> Result<Self, Self::Err> { 156 180 Self::new_owned(s) 157 181 } 158 182 } 159 183 160 - impl IntoStatic for Did<'_> { 161 - type Output = Did<'static>; 184 + impl FromStr for Did<CowStr<'static>> { 185 + type Err = AtStrError; 162 186 163 - fn into_static(self) -> Self::Output { 164 - Did(self.0.into_static()) 187 + fn from_str(s: &str) -> Result<Self, Self::Err> { 188 + Self::new_owned(s) 165 189 } 166 190 } 167 191 168 - impl<'de, 'a> Deserialize<'de> for Did<'a> 169 - where 170 - 'de: 'a, 171 - { 172 - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 173 - where 174 - D: Deserializer<'de>, 175 - { 176 - let value = Deserialize::deserialize(deserializer)?; 177 - Self::new_cow(value).map_err(D::Error::custom) 192 + impl FromStr for Did<String> { 193 + type Err = AtStrError; 194 + 195 + fn from_str(s: &str) -> Result<Self, Self::Err> { 196 + Self::new_owned(s) 178 197 } 179 198 } 180 199 181 - impl fmt::Display for Did<'_> { 200 + impl<S: Bos<str> + AsRef<str>> fmt::Display for Did<S> { 182 201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 183 - f.write_str(&self.0) 202 + f.write_str(self.as_str()) 184 203 } 185 204 } 186 205 187 - impl fmt::Debug for Did<'_> { 206 + impl<S: Bos<str> + AsRef<str>> fmt::Debug for Did<S> { 188 207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 189 - write!(f, "at://{}", self.0) 208 + write!(f, "at://{}", self.as_str()) 190 209 } 191 210 } 192 211 193 - impl<'d> From<Did<'d>> for String { 194 - fn from(value: Did<'d>) -> Self { 195 - value.0.to_string() 212 + impl<S: Bos<str> + AsRef<str>> From<Did<S>> for String { 213 + fn from(value: Did<S>) -> Self { 214 + value.as_str().to_string() 196 215 } 197 216 } 198 217 199 - impl<'d> From<Did<'d>> for CowStr<'d> { 200 - fn from(value: Did<'d>) -> Self { 201 - value.0 218 + impl<S: Bos<str> + AsRef<str>> From<Did<S>> for CowStr<'static> { 219 + fn from(value: Did<S>) -> Self { 220 + CowStr::copy_from_str(value.as_str()) 202 221 } 203 222 } 204 223 205 - impl From<String> for Did<'static> { 224 + impl From<String> for Did { 206 225 fn from(value: String) -> Self { 207 - let value = if let Some(did) = value.strip_prefix("at://") { 208 - CowStr::Borrowed(did) 209 - } else { 210 - value.into() 211 - }; 212 - if value.len() > 2048 { 213 - panic!("DID too long") 214 - } else if !DID_REGEX.is_match(&value) { 215 - panic!("Invalid DID") 216 - } else { 217 - Self(value.into_static()) 218 - } 226 + Self::new_owned(value).unwrap() 219 227 } 220 228 } 221 229 222 - impl<'d> From<CowStr<'d>> for Did<'d> { 230 + impl<'d> From<CowStr<'d>> for Did<CowStr<'d>> { 223 231 fn from(value: CowStr<'d>) -> Self { 224 - let value = if let Some(did) = value.strip_prefix("at://") { 225 - CowStr::Borrowed(did) 226 - } else { 227 - value 228 - }; 229 - if value.len() > 2048 { 230 - panic!("DID too long") 231 - } else if !DID_REGEX.is_match(&value) { 232 - panic!("Invalid DID") 233 - } else { 234 - Self(value.into_static()) 235 - } 232 + Self::new_cow(value).unwrap() 236 233 } 237 234 } 238 235 239 - impl AsRef<str> for Did<'_> { 236 + impl<S: Bos<str> + AsRef<str>> AsRef<str> for Did<S> { 240 237 fn as_ref(&self) -> &str { 241 238 self.as_str() 242 239 } 243 240 } 244 241 245 - impl Deref for Did<'_> { 242 + impl<S: Bos<str> + AsRef<str>> Deref for Did<S> { 246 243 type Target = str; 247 244 248 245 fn deref(&self) -> &Self::Target { ··· 256 253 257 254 #[test] 258 255 fn valid_dids() { 259 - assert!(Did::new("did:plc:abc123").is_ok()); 260 - assert!(Did::new("did:web:example.com").is_ok()); 261 - assert!(Did::new("did:method:val_ue").is_ok()); 262 - assert!(Did::new("did:method:val-ue").is_ok()); 263 - assert!(Did::new("did:method:val.ue").is_ok()); 264 - assert!(Did::new("did:method:val%20ue").is_ok()); 256 + assert!(Did::<&str>::new("did:plc:abc123").is_ok()); 257 + assert!(Did::<&str>::new("did:web:example.com").is_ok()); 258 + assert!(Did::<&str>::new("did:method:val_ue").is_ok()); 259 + assert!(Did::<&str>::new("did:method:val-ue").is_ok()); 260 + assert!(Did::<&str>::new("did:method:val.ue").is_ok()); 261 + assert!(Did::<&str>::new("did:method:val%20ue").is_ok()); 262 + } 263 + 264 + #[test] 265 + fn valid_dids_owned() { 266 + assert!(Did::<SmolStr>::new_owned("did:plc:abc123").is_ok()); 267 + assert!(Did::<String>::new_owned("did:web:example.com").is_ok()); 265 268 } 266 269 267 270 #[test] 268 271 fn prefix_stripping() { 269 272 assert_eq!( 270 - Did::new("at://did:plc:foo").unwrap().as_str(), 273 + Did::<&str>::new("at://did:plc:foo").unwrap().as_str(), 271 274 "did:plc:foo" 272 275 ); 273 - assert_eq!(Did::new("did:plc:foo").unwrap().as_str(), "did:plc:foo"); 276 + assert_eq!( 277 + Did::<&str>::new("did:plc:foo").unwrap().as_str(), 278 + "did:plc:foo" 279 + ); 274 280 } 275 281 276 282 #[test] 277 283 fn must_start_with_did() { 278 - assert!(Did::new("DID:plc:foo").is_err()); 279 - assert!(Did::new("plc:foo").is_err()); 280 - assert!(Did::new("foo").is_err()); 284 + assert!(Did::<&str>::new("DID:plc:foo").is_err()); 285 + assert!(Did::<&str>::new("plc:foo").is_err()); 286 + assert!(Did::<&str>::new("foo").is_err()); 281 287 } 282 288 283 289 #[test] 284 290 fn method_must_be_lowercase() { 285 - assert!(Did::new("did:plc:foo").is_ok()); 286 - assert!(Did::new("did:PLC:foo").is_err()); 287 - assert!(Did::new("did:Plc:foo").is_err()); 291 + assert!(Did::<&str>::new("did:plc:foo").is_ok()); 292 + assert!(Did::<&str>::new("did:PLC:foo").is_err()); 293 + assert!(Did::<&str>::new("did:Plc:foo").is_err()); 288 294 } 289 295 290 296 #[test] 291 297 fn cannot_end_with_colon_or_percent() { 292 - assert!(Did::new("did:plc:foo:").is_err()); 293 - assert!(Did::new("did:plc:foo%").is_err()); 294 - assert!(Did::new("did:plc:foo:bar").is_ok()); 298 + assert!(Did::<&str>::new("did:plc:foo:").is_err()); 299 + assert!(Did::<&str>::new("did:plc:foo%").is_err()); 300 + assert!(Did::<&str>::new("did:plc:foo:bar").is_ok()); 295 301 } 296 302 297 303 #[test] 298 304 fn max_length() { 299 305 let valid_2048 = format!("did:plc:{}", "a".repeat(2048 - 8)); 300 306 assert_eq!(valid_2048.len(), 2048); 301 - assert!(Did::new(&valid_2048).is_ok()); 307 + assert!(Did::<&str>::new(&valid_2048).is_ok()); 302 308 303 309 let too_long_2049 = format!("did:plc:{}", "a".repeat(2049 - 8)); 304 310 assert_eq!(too_long_2049.len(), 2049); 305 - assert!(Did::new(&too_long_2049).is_err()); 311 + assert!(Did::<&str>::new(&too_long_2049).is_err()); 306 312 } 307 313 308 314 #[test] 309 315 fn allowed_characters() { 310 - assert!(Did::new("did:method:abc123").is_ok()); 311 - assert!(Did::new("did:method:ABC123").is_ok()); 312 - assert!(Did::new("did:method:a_b_c").is_ok()); 313 - assert!(Did::new("did:method:a-b-c").is_ok()); 314 - assert!(Did::new("did:method:a.b.c").is_ok()); 315 - assert!(Did::new("did:method:a:b:c").is_ok()); 316 + assert!(Did::<&str>::new("did:method:abc123").is_ok()); 317 + assert!(Did::<&str>::new("did:method:ABC123").is_ok()); 318 + assert!(Did::<&str>::new("did:method:a_b_c").is_ok()); 319 + assert!(Did::<&str>::new("did:method:a-b-c").is_ok()); 320 + assert!(Did::<&str>::new("did:method:a.b.c").is_ok()); 321 + assert!(Did::<&str>::new("did:method:a:b:c").is_ok()); 316 322 } 317 323 318 324 #[test] 319 325 fn disallowed_characters() { 320 - assert!(Did::new("did:method:a b").is_err()); 321 - assert!(Did::new("did:method:a@b").is_err()); 322 - assert!(Did::new("did:method:a#b").is_err()); 323 - assert!(Did::new("did:method:a?b").is_err()); 326 + assert!(Did::<&str>::new("did:method:a b").is_err()); 327 + assert!(Did::<&str>::new("did:method:a@b").is_err()); 328 + assert!(Did::<&str>::new("did:method:a#b").is_err()); 329 + assert!(Did::<&str>::new("did:method:a?b").is_err()); 324 330 } 325 331 326 332 #[test] 327 333 fn percent_encoding() { 328 - // Valid percent encoding 329 - assert!(Did::new("did:method:foo%20bar").is_ok()); 330 - assert!(Did::new("did:method:foo%2Fbar").is_ok()); 334 + assert!(Did::<&str>::new("did:method:foo%20bar").is_ok()); 335 + assert!(Did::<&str>::new("did:method:foo%2Fbar").is_ok()); 336 + assert!(Did::<&str>::new("did:method:foo%").is_err()); 337 + // Matches TS reference impl: malformed percent-encoding accepted. 338 + assert!(Did::<&str>::new("did:method:foo%2x").is_ok()); 339 + assert!(Did::<&str>::new("did:method:foo%ZZ").is_ok()); 340 + } 331 341 332 - // DIDs cannot end with % 333 - assert!(Did::new("did:method:foo%").is_err()); 342 + #[test] 343 + fn into_static() { 344 + let d = Did::<&str>::new("did:plc:abc123").unwrap(); 345 + let owned: Did<SmolStr> = d.into_static(); 346 + assert_eq!(owned.as_str(), "did:plc:abc123"); 347 + } 334 348 335 - // IMPORTANT: The regex does NOT validate that percent-encoding is well-formed. 336 - // This matches the TypeScript reference implementation's behavior. 337 - // While the spec says "percent sign must be followed by two hex characters", 338 - // implementations treat this as a best practice, not a hard validation requirement. 339 - // Thus, malformed percent encoding like %2x is accepted by the regex. 340 - assert!(Did::new("did:method:foo%2x").is_ok()); 341 - assert!(Did::new("did:method:foo%ZZ").is_ok()); 349 + #[test] 350 + fn cross_type_equality() { 351 + let borrowed = Did::<&str>::new("did:plc:abc123").unwrap(); 352 + let owned = Did::<SmolStr>::new_owned("did:plc:abc123").unwrap(); 353 + // Different S types but same content — PartialEq compares the inner S values. 354 + assert_eq!(borrowed.as_str(), owned.as_str()); 342 355 } 343 356 }
+92 -77
crates/jacquard-common/src/types/did_doc.rs
··· 2 2 use crate::types::crypto::{CryptoError, PublicKey}; 3 3 use crate::types::string::{Did, Handle}; 4 4 use crate::types::value::Data; 5 - use crate::{CowStr, IntoStatic}; 5 + use crate::{Bos, CowStr, DefaultStr, IntoStatic}; 6 6 use alloc::collections::BTreeMap; 7 7 use alloc::string::String; 8 8 use alloc::vec::Vec; ··· 35 35 /// "publicKeyMultibase": "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" 36 36 /// }] 37 37 /// }"##; 38 - /// let doc: DidDocument<'_> = serde_json::from_str(json)?; 38 + /// let doc: DidDocument = serde_json::from_str(json)?; 39 39 /// assert_eq!(doc.id.as_str(), "did:plc:alice"); 40 40 /// assert!(doc.pds_endpoint().is_some()); 41 41 /// # Ok(()) ··· 44 44 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)] 45 45 #[builder(start_fn = new)] 46 46 #[serde(rename_all = "camelCase")] 47 - pub struct DidDocument<'a> { 47 + #[serde(bound( 48 + serialize = "S: Serialize + Bos<str> + AsRef<str>", 49 + deserialize = "S: Deserialize<'de> + Bos<str> + AsRef<str>" 50 + ))] 51 + pub struct DidDocument<S: Bos<str> + AsRef<str> = DefaultStr> { 48 52 /// required prelude 49 53 #[serde(rename = "@context")] 50 54 #[serde(default = "default_context")] 51 - pub context: Vec<CowStr<'a>>, 55 + pub context: Vec<SmolStr>, 52 56 53 57 /// Document identifier (e.g., `did:plc:...` or `did:web:...`) 54 - #[serde(borrow)] 55 - pub id: Did<'a>, 58 + pub id: Did<S>, 56 59 57 60 /// Alternate identifiers for the subject, such as at://\<handle\> 58 - #[serde(borrow)] 59 61 #[serde(skip_serializing_if = "Option::is_none")] 60 - pub also_known_as: Option<Vec<CowStr<'a>>>, 62 + pub also_known_as: Option<Vec<S>>, 61 63 62 64 /// Verification methods (keys) for this DID 63 - #[serde(borrow)] 64 65 #[serde(skip_serializing_if = "Option::is_none")] 65 - pub verification_method: Option<Vec<VerificationMethod<'a>>>, 66 + pub verification_method: Option<Vec<VerificationMethod<S>>>, 66 67 67 68 /// Services associated with this DID (e.g., AtprotoPersonalDataServer) 68 - #[serde(borrow)] 69 69 #[serde(skip_serializing_if = "Option::is_none")] 70 - pub service: Option<Vec<Service<'a>>>, 71 - 72 - /// Forward‑compatible capture of unmodeled fields 73 - #[serde(flatten)] 74 - pub extra_data: BTreeMap<SmolStr, Data<'a>>, 70 + pub service: Option<Vec<Service<S>>>, 71 + // Forward‑compatible capture of unmodeled fields 72 + // TODO: re-enable extra data fields 73 + // #[serde(flatten)] 74 + // pub extra_data: BTreeMap<SmolStr, Data<'static>>, 75 75 } 76 76 77 77 /// Default context fields for DID documents 78 - pub fn default_context() -> Vec<CowStr<'static>> { 78 + pub fn default_context() -> Vec<SmolStr> { 79 79 vec![ 80 - CowStr::new_static("https://www.w3.org/ns/did/v1"), 81 - CowStr::new_static("https://w3id.org/security/multikey/v1"), 82 - CowStr::new_static("https://w3id.org/security/suites/secp256k1-2019/v1"), 80 + SmolStr::new_static("https://www.w3.org/ns/did/v1"), 81 + SmolStr::new_static("https://w3id.org/security/multikey/v1"), 82 + SmolStr::new_static("https://w3id.org/security/suites/secp256k1-2019/v1"), 83 83 ] 84 84 } 85 85 86 - impl crate::IntoStatic for DidDocument<'_> { 87 - type Output = DidDocument<'static>; 86 + impl<S> crate::IntoStatic for DidDocument<S> 87 + where 88 + S: Bos<str> + AsRef<str> + crate::IntoStatic, 89 + <S as IntoStatic>::Output: AsRef<str>, 90 + <S as IntoStatic>::Output: Bos<str>, 91 + { 92 + type Output = DidDocument<<S as crate::IntoStatic>::Output>; 88 93 fn into_static(self) -> Self::Output { 89 94 DidDocument { 90 95 context: default_context(), ··· 92 97 also_known_as: self.also_known_as.into_static(), 93 98 verification_method: self.verification_method.into_static(), 94 99 service: self.service.into_static(), 95 - extra_data: self.extra_data.into_static(), 100 + // TODO: re-enable extra data fields 101 + // extra_data: self.extra_data.into_static(), 96 102 } 97 103 } 98 104 } 99 105 100 - impl<'a> DidDocument<'a> { 106 + impl<S> DidDocument<S> 107 + where 108 + S: Bos<str> + AsRef<str> + Clone, 109 + { 101 110 /// Extract validated handles from `alsoKnownAs` entries like `at://\<handle\>`. 102 - pub fn handles(&self) -> Vec<Handle<'static>> { 111 + pub fn handles(&self) -> Vec<Handle> { 103 112 self.also_known_as 104 113 .as_ref() 105 114 .map(|v| { 106 115 v.iter() 107 - .filter_map(|h| Handle::new(h).ok()) 116 + .filter_map(|h| Handle::new(h.as_ref()).ok()) 108 117 .map(|h| h.into_static()) 109 118 .collect() 110 119 }) ··· 112 121 } 113 122 114 123 /// Extract the first Multikey `publicKeyMultibase` value from verification methods. 115 - pub fn atproto_multikey(&self) -> Option<CowStr<'static>> { 124 + pub fn atproto_multikey(&self) -> Option<S> { 116 125 self.verification_method.as_ref().and_then(|methods| { 117 126 methods.iter().find_map(|m| { 118 127 if m.r#type.as_ref() == "Multikey" { 119 - m.public_key_multibase 120 - .as_ref() 121 - .map(|k| k.clone().into_static()) 128 + m.public_key_multibase.as_ref().map(|k| k.clone()) 122 129 } else { 123 130 None 124 131 } ··· 128 135 129 136 /// Extract the AtprotoPersonalDataServer service endpoint as a `fluent_uri::Uri<String>`. 130 137 /// Accepts endpoint as string or object (string preferred). 131 - pub fn pds_endpoint(&self) -> Option<Uri<String>> { 138 + pub fn pds_endpoint(&self) -> Option<Uri<&str>> { 132 139 self.service.as_ref().and_then(|services| { 133 140 services.iter().find_map(|s| { 134 141 if s.r#type.as_ref() == "AtprotoPersonalDataServer" { 135 142 match &s.service_endpoint { 136 - Some(Data::String(strv)) => { 137 - Uri::parse(strv.as_ref()).ok().map(|u| u.to_owned()) 138 - } 139 - Some(Data::Object(obj)) => { 140 - // Some documents may include structured endpoints; try common fields 141 - if let Some(Data::String(urlv)) = obj.0.get("url") { 142 - Uri::parse(urlv.as_ref()).ok().map(|u| u.to_owned()) 143 - } else { 144 - None 145 - } 146 - } 143 + Some(strv) => Uri::parse(strv.as_ref()).ok(), 144 + 147 145 _ => None, 148 146 } 149 147 } else { ··· 156 154 /// Decode the atproto Multikey (first occurrence) into a typed public key. 157 155 pub fn atproto_public_key(&self) -> Result<Option<PublicKey<'static>>, CryptoError> { 158 156 if let Some(multibase) = self.atproto_multikey() { 159 - let pk = PublicKey::decode(&multibase)?; 157 + let pk = PublicKey::decode(multibase.as_ref())?; 160 158 Ok(Some(pk)) 161 159 } else { 162 160 Ok(None) ··· 168 166 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)] 169 167 #[builder(start_fn = new)] 170 168 #[serde(rename_all = "camelCase")] 171 - pub struct VerificationMethod<'a> { 169 + #[serde(bound( 170 + serialize = "S: Serialize + Bos<str> + AsRef<str>", 171 + deserialize = "S: Deserialize<'de> + Bos<str> + AsRef<str>" 172 + ))] 173 + pub struct VerificationMethod<S: Bos<str> + AsRef<str>> { 172 174 /// Identifier for this key material within the document 173 - #[serde(borrow)] 174 - pub id: CowStr<'a>, 175 + pub id: S, 175 176 /// Key type (e.g., `Multikey`) 176 - #[serde(borrow, rename = "type")] 177 - pub r#type: CowStr<'a>, 177 + #[serde(rename = "type")] 178 + pub r#type: S, 178 179 /// Optional controller DID 179 - #[serde(borrow)] 180 180 #[serde(skip_serializing_if = "Option::is_none")] 181 - pub controller: Option<CowStr<'a>>, 181 + pub controller: Option<S>, 182 182 /// Multikey `publicKeyMultibase` (base58btc) 183 - #[serde(borrow)] 184 183 #[serde(skip_serializing_if = "Option::is_none")] 185 - pub public_key_multibase: Option<CowStr<'a>>, 186 - 187 - /// Forward‑compatible capture of unmodeled fields 188 - #[serde(flatten)] 189 - pub extra_data: BTreeMap<SmolStr, Data<'a>>, 184 + pub public_key_multibase: Option<S>, 185 + // Forward‑compatible capture of unmodeled fields 186 + // TODO: re-enable extra data fields 187 + // #[serde(flatten)] 188 + // pub extra_data: BTreeMap<SmolStr, Data<'static>>, 190 189 } 191 190 192 - impl crate::IntoStatic for VerificationMethod<'_> { 193 - type Output = VerificationMethod<'static>; 191 + impl<S> crate::IntoStatic for VerificationMethod<S> 192 + where 193 + S: Bos<str> + AsRef<str> + crate::IntoStatic, 194 + <S as IntoStatic>::Output: AsRef<str>, 195 + <S as IntoStatic>::Output: Bos<str>, 196 + { 197 + type Output = VerificationMethod<<S as crate::IntoStatic>::Output>; 194 198 fn into_static(self) -> Self::Output { 195 199 VerificationMethod { 196 200 id: self.id.into_static(), 197 201 r#type: self.r#type.into_static(), 198 202 controller: self.controller.into_static(), 199 203 public_key_multibase: self.public_key_multibase.into_static(), 200 - extra_data: self.extra_data.into_static(), 204 + // TODO: re-enable extra data fields 205 + // extra_data: self.extra_data.into_static(), 201 206 } 202 207 } 203 208 } ··· 206 211 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)] 207 212 #[builder(start_fn = new)] 208 213 #[serde(rename_all = "camelCase")] 209 - pub struct Service<'a> { 214 + #[serde(bound( 215 + serialize = "S: Serialize + Bos<str> + AsRef<str>", 216 + deserialize = "S: Deserialize<'de> + Bos<str> + AsRef<str>" 217 + ))] 218 + pub struct Service<S: Bos<str> + AsRef<str>> { 210 219 /// Service identifier 211 - #[serde(borrow)] 212 - pub id: CowStr<'a>, 220 + pub id: S, 213 221 /// Service type (e.g., `AtprotoPersonalDataServer`) 214 - #[serde(borrow, rename = "type")] 215 - pub r#type: CowStr<'a>, 216 - /// String or object; we preserve as Data 217 - #[serde(borrow)] 222 + #[serde(rename = "type")] 223 + pub r#type: S, 224 + /// currently atproto expects this to be a url 225 + /// 226 + /// TODO: add back in map/set support once Data<'_> is migrated 218 227 #[serde(skip_serializing_if = "Option::is_none")] 219 - pub service_endpoint: Option<Data<'a>>, 220 - 221 - /// Forward‑compatible capture of unmodeled fields 222 - #[serde(flatten)] 223 - pub extra_data: BTreeMap<SmolStr, Data<'a>>, 228 + pub service_endpoint: Option<S>, 229 + // Forward‑compatible capture of unmodeled fields 230 + // TODO: re-enable extra data fields 231 + // #[serde(flatten)] 232 + // pub extra_data: BTreeMap<SmolStr, Data<'static>>, 224 233 } 225 234 226 - impl crate::IntoStatic for Service<'_> { 227 - type Output = Service<'static>; 235 + impl<S> crate::IntoStatic for Service<S> 236 + where 237 + S: Bos<str> + AsRef<str> + crate::IntoStatic, 238 + <S as IntoStatic>::Output: AsRef<str>, 239 + <S as IntoStatic>::Output: Bos<str>, 240 + { 241 + type Output = Service<<S as crate::IntoStatic>::Output>; 228 242 fn into_static(self) -> Self::Output { 229 243 Service { 230 244 id: self.id.into_static(), 231 245 r#type: self.r#type.into_static(), 232 246 service_endpoint: self.service_endpoint.into_static(), 233 - extra_data: self.extra_data.into_static(), 247 + // TODO: re-enable extra data fields 248 + // extra_data: self.extra_data.into_static(), 234 249 } 235 250 } 236 251 } ··· 274 289 ] 275 290 }); 276 291 let doc_string = serde_json::to_string(&doc_json).unwrap(); 277 - let doc: DidDocument<'_> = serde_json::from_str(&doc_string).unwrap(); 292 + let doc: DidDocument = serde_json::from_str(&doc_string).unwrap(); 278 293 let pk = doc.atproto_public_key().unwrap().expect("present"); 279 294 assert!(matches!(pk.codec, crate::types::crypto::KeyCodec::Ed25519)); 280 295 assert_eq!(pk.bytes.as_ref(), &k); ··· 283 298 #[test] 284 299 fn parse_sample_doc_and_helpers() { 285 300 let raw = include_str!("test_did_doc.json"); 286 - let doc: DidDocument<'_> = serde_json::from_str(raw).expect("parse doc"); 301 + let doc: DidDocument = serde_json::from_str(raw).expect("parse doc"); 287 302 // id 288 303 assert_eq!(doc.id.as_str(), "did:plc:yfvwmnlztr4dwkb7hwz55r2g"); 289 304 // pds endpoint ··· 294 309 assert!(handles.iter().any(|h| h.as_str() == "nonbinary.computer")); 295 310 // multikey string present 296 311 let mk = doc.atproto_multikey().expect("has multikey"); 297 - assert!(mk.as_ref().starts_with('z')); 312 + assert!(AsRef::<str>::as_ref(&mk).starts_with('z')); 298 313 // typed decode (may be ed25519, secp256k1, or p256 depending on multicodec) 299 314 let _ = doc.atproto_public_key().expect("decode ok"); 300 315 }
+370 -268
crates/jacquard-common/src/types/handle.rs
··· 1 + use crate::bos::{Bos, DefaultStr}; 1 2 use crate::types::string::AtStrError; 2 3 use crate::types::{DISALLOWED_TLDS, ends_with}; 3 4 use crate::{CowStr, IntoStatic}; 4 - use alloc::string::{String, ToString}; 5 + use alloc::string::String; 5 6 use core::fmt; 7 + use core::hash::{Hash, Hasher}; 6 8 use core::ops::Deref; 7 9 use core::str::FromStr; 8 10 #[cfg(all(not(target_arch = "wasm32"), feature = "std"))] ··· 11 13 use regex_automata::meta::Regex; 12 14 #[cfg(target_arch = "wasm32")] 13 15 use regex_lite::Regex; 14 - use serde::{Deserialize, Deserializer, Serialize, de::Error}; 16 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 15 17 use smol_str::{SmolStr, StrExt}; 16 18 17 19 use super::Lazy; 18 20 19 - /// AT Protocol handle (human-readable account identifier) 20 - /// 21 - /// Handles are user-friendly account identifiers that must resolve to a DID through DNS 22 - /// or HTTPS. Unlike DIDs, handles can change over time, though they remain an important 23 - /// part of user identity. 21 + /// AT Protocol handle (human-readable account identifier). 24 22 /// 25 - /// Format rules: 26 - /// - Maximum 253 characters 27 - /// - At least two segments separated by dots (e.g., "alice.bsky.social") 28 - /// - Each segment is 1-63 characters of ASCII letters, numbers, and hyphens 29 - /// - Segments cannot start or end with a hyphen 30 - /// - Final segment (TLD) cannot start with a digit 31 - /// - Case-insensitive (normalized to lowercase) 23 + /// # Case semantics 32 24 /// 33 - /// Certain TLDs are disallowed (.local, .localhost, .arpa, .invalid, .internal, .example, .alt, .onion). 25 + /// Handle is **case-preserving but case-insensitive**: the stored string retains its 26 + /// original casing, but equality, hashing, serialization, and display all operate on 27 + /// the lowercased form. `as_str()` returns the raw stored value. 34 28 /// 35 29 /// See: <https://atproto.com/specs/handle> 36 - #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 37 - #[serde(transparent)] 30 + #[derive(Clone)] 38 31 #[repr(transparent)] 39 - pub struct Handle<'h>(pub(crate) CowStr<'h>); 32 + pub struct Handle<S: Bos<str> = DefaultStr>(pub(crate) S); 40 33 41 - /// Regex for handle validation per AT Protocol spec 34 + /// Regex for handle validation per AT Protocol spec. 42 35 pub static HANDLE_REGEX: Lazy<Regex> = Lazy::new(|| { 43 36 Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap() 44 37 }); 45 - impl<'h> Handle<'h> { 46 - /// Fallible constructor, validates, borrows from input 38 + 39 + // --------------------------------------------------------------------------- 40 + // Shared validation 41 + // --------------------------------------------------------------------------- 42 + 43 + fn strip_handle_prefix(handle: &str) -> &str { 44 + handle 45 + .strip_prefix("at://") 46 + .or_else(|| handle.strip_prefix('@')) 47 + .unwrap_or(handle) 48 + } 49 + 50 + fn validate_handle(handle: &str) -> Result<(), AtStrError> { 51 + if handle.len() > 253 { 52 + Err(AtStrError::too_long("handle", handle, 253, handle.len())) 53 + } else if !HANDLE_REGEX.is_match(handle) { 54 + Err(AtStrError::regex( 55 + "handle", 56 + handle, 57 + SmolStr::new_static("invalid"), 58 + )) 59 + } else if ends_with(handle, DISALLOWED_TLDS) && handle != "handle.invalid" { 60 + Err(AtStrError::disallowed("handle", handle, DISALLOWED_TLDS)) 61 + } else { 62 + Ok(()) 63 + } 64 + } 65 + 66 + // --------------------------------------------------------------------------- 67 + // Core methods 68 + // --------------------------------------------------------------------------- 69 + 70 + impl<S: Bos<str> + AsRef<str>> Handle<S> { 71 + /// Get the handle as a string slice. 47 72 /// 48 - /// Accepts (and strips) preceding '@' or 'at://' if present 73 + /// Returns the raw stored value, which may contain uppercase if the handle 74 + /// was deserialized from non-canonical wire data. For canonical output, 75 + /// use `Display` or `Serialize`. 76 + pub fn as_str(&self) -> &str { 77 + self.0.as_ref() 78 + } 79 + 80 + /// Confirm that this is a (syntactically) valid handle (as we pass-through 81 + /// "handle.invalid" during construction). 82 + pub fn is_valid(&self) -> bool { 83 + let s = self.as_str(); 84 + s.len() <= 253 && HANDLE_REGEX.is_match(s) && !ends_with(s, DISALLOWED_TLDS) 85 + } 86 + } 87 + 88 + impl<S: Bos<str>> Handle<S> { 89 + /// Infallible unchecked constructor. 90 + /// 91 + /// # Safety 92 + /// 93 + /// The caller must ensure the handle is valid. 94 + pub unsafe fn unchecked(handle: S) -> Self { 95 + Handle(handle) 96 + } 97 + } 98 + 99 + // --------------------------------------------------------------------------- 100 + // Borrowed construction: Handle<&'h str> 101 + // --------------------------------------------------------------------------- 102 + 103 + impl<'h> Handle<&'h str> { 104 + /// Fallible constructor, validates, borrows from input. 105 + /// 106 + /// Rejects uppercase input — use `Handle::<SmolStr>::new_owned()` for 107 + /// case-insensitive construction. 108 + /// Accepts (and strips) preceding '@' or 'at://' if present. 49 109 pub fn new(handle: &'h str) -> Result<Self, AtStrError> { 50 110 if handle.contains(|c: char| c.is_ascii_uppercase()) { 51 - return Self::new_owned(handle); 52 - } 53 - let stripped = handle 54 - .strip_prefix("at://") 55 - .or_else(|| handle.strip_prefix('@')) 56 - .unwrap_or(handle); 57 - if stripped.len() > 253 { 58 - Err(AtStrError::too_long( 59 - "handle", 60 - stripped, 61 - 253, 62 - stripped.len(), 63 - )) 64 - } else if !HANDLE_REGEX.is_match(stripped) { 65 - Err(AtStrError::regex( 111 + return Err(AtStrError::regex( 66 112 "handle", 67 - stripped, 68 - SmolStr::new_static("invalid"), 69 - )) 70 - } else if ends_with(stripped, DISALLOWED_TLDS) { 71 - // specifically pass this through as it is returned in instances where someone 72 - // has screwed up their handle, and it's awkward to fail so early 73 - if handle == "handle.invalid" { 74 - Ok(Self(CowStr::Borrowed(stripped))) 75 - } else { 76 - Err(AtStrError::disallowed("handle", stripped, DISALLOWED_TLDS)) 77 - } 78 - } else { 79 - Ok(Self(CowStr::Borrowed(stripped))) 113 + handle, 114 + SmolStr::new_static("contains uppercase (use new_owned for normalisation)"), 115 + )); 80 116 } 117 + let stripped = strip_handle_prefix(handle); 118 + validate_handle(stripped)?; 119 + Ok(Self(stripped)) 81 120 } 82 121 83 - /// confirm that this is a (syntactically) valid handle (as we pass-through 84 - /// "handle.invalid" during construction) 85 - pub fn is_valid(&self) -> bool { 86 - self.0.len() <= 253 87 - && HANDLE_REGEX.is_match(&self.0) 88 - && !ends_with(&self.0, DISALLOWED_TLDS) 122 + /// Infallible constructor. Panics on invalid handles. 123 + pub fn raw(handle: &'h str) -> Self { 124 + Self::new(handle).expect("invalid handle") 89 125 } 126 + } 90 127 91 - /// Fallible constructor, validates, takes ownership 128 + // --------------------------------------------------------------------------- 129 + // Owned construction: any S that can be built from SmolStr 130 + // --------------------------------------------------------------------------- 131 + 132 + impl<S: Bos<str> + From<SmolStr>> Handle<S> { 133 + /// Fallible constructor, validates, takes ownership. Normalises to lowercase. 92 134 pub fn new_owned(handle: impl AsRef<str>) -> Result<Self, AtStrError> { 93 135 let handle = handle.as_ref(); 94 - let stripped = handle 95 - .strip_prefix("at://") 96 - .or_else(|| handle.strip_prefix('@')) 97 - .unwrap_or(handle); 136 + let stripped = strip_handle_prefix(handle); 98 137 let normalized = stripped.to_lowercase_smolstr(); 99 - let handle = normalized.as_str(); 100 - if handle.len() > 253 { 101 - Err(AtStrError::too_long("handle", handle, 253, handle.len())) 102 - } else if !HANDLE_REGEX.is_match(handle) { 103 - Err(AtStrError::regex( 104 - "handle", 105 - handle, 106 - SmolStr::new_static("invalid"), 107 - )) 108 - } else if ends_with(handle, DISALLOWED_TLDS) { 109 - // specifically pass this through as it is returned in instances where someone 110 - // has screwed up their handle, and it's awkward to fail so early 111 - if handle == "handle.invalid" { 112 - Ok(Self(CowStr::Owned(normalized))) 113 - } else { 114 - Err(AtStrError::disallowed( 115 - "handle", 116 - normalized.as_str(), 117 - DISALLOWED_TLDS, 118 - )) 119 - } 120 - } else { 121 - Ok(Self(CowStr::Owned(normalized))) 122 - } 138 + validate_handle(&normalized)?; 139 + Ok(Self(S::from(normalized))) 123 140 } 124 141 125 - /// Fallible constructor, validates, doesn't allocate 142 + /// Fallible constructor for static strings. Zero-alloc if already lowercase. 126 143 pub fn new_static(handle: &'static str) -> Result<Self, AtStrError> { 127 - let stripped = handle 128 - .strip_prefix("at://") 129 - .or_else(|| handle.strip_prefix('@')) 130 - .unwrap_or(handle); 131 - 132 - let handle = if handle.contains(|c: char| c.is_ascii_uppercase()) { 144 + let stripped = strip_handle_prefix(handle); 145 + let smol = if stripped.contains(|c: char| c.is_ascii_uppercase()) { 133 146 stripped.to_lowercase_smolstr() 134 147 } else { 135 148 SmolStr::new_static(stripped) 136 149 }; 137 - if handle.len() > 253 { 138 - Err(AtStrError::too_long("handle", &handle, 253, handle.len())) 139 - } else if !HANDLE_REGEX.is_match(&handle) { 140 - Err(AtStrError::regex( 141 - "handle", 142 - &handle, 143 - SmolStr::new_static("invalid"), 144 - )) 145 - } else if ends_with(&handle, DISALLOWED_TLDS) { 146 - // specifically pass this through as it is returned in instances where someone 147 - // has screwed up their handle, and it's awkward to fail so early 148 - if handle == "handle.invalid" { 149 - Ok(Self(CowStr::Owned(handle))) 150 - } else { 151 - Err(AtStrError::disallowed("handle", stripped, DISALLOWED_TLDS)) 152 - } 153 - } else { 154 - Ok(Self(CowStr::Owned(handle))) 155 - } 150 + validate_handle(&smol)?; 151 + Ok(Self(S::from(smol))) 156 152 } 153 + } 157 154 158 - /// Fallible constructor, validates, borrows from input if possible 159 - /// 160 - /// May allocate for a long handle with an at:// or @ prefix, otherwise borrows. 161 - /// Accepts (and strips) preceding '@' or 'at://' if present 155 + // --------------------------------------------------------------------------- 156 + // CowStr construction 157 + // --------------------------------------------------------------------------- 158 + 159 + impl<'h> Handle<CowStr<'h>> { 160 + /// Fallible constructor, borrows if possible, allocates for uppercase/prefix. 162 161 pub fn new_cow(handle: CowStr<'h>) -> Result<Self, AtStrError> { 163 162 if handle.contains(|c: char| c.is_ascii_uppercase()) { 164 - return Self::new_owned(handle); 163 + return Handle::<CowStr<'h>>::new_owned(handle); 165 164 } 166 - let handle = if let Some(stripped) = handle.strip_prefix("at://") { 167 - CowStr::copy_from_str(stripped) 168 - } else if let Some(stripped) = handle.strip_prefix('@') { 169 - CowStr::copy_from_str(stripped) 165 + let handle = if handle.starts_with("at://") || handle.starts_with('@') { 166 + CowStr::copy_from_str(strip_handle_prefix(&handle)) 170 167 } else { 171 168 handle 172 169 }; 173 - if handle.len() > 253 { 174 - Err(AtStrError::too_long("handle", &handle, 253, handle.len())) 175 - } else if !HANDLE_REGEX.is_match(&handle) { 176 - Err(AtStrError::regex( 177 - "handle", 178 - &handle, 179 - SmolStr::new_static("invalid"), 180 - )) 181 - } else if ends_with(&handle, DISALLOWED_TLDS) { 182 - // specifically pass this through as it is returned in instances where someone 183 - // has screwed up their handle, and it's awkward to fail so early 184 - if handle == "handle.invalid" { 185 - Ok(Self(handle)) 186 - } else { 187 - Err(AtStrError::disallowed( 188 - "handle", 189 - handle.as_str(), 190 - DISALLOWED_TLDS, 191 - )) 192 - } 193 - } else { 194 - Ok(Self(handle)) 195 - } 170 + validate_handle(&handle)?; 171 + Ok(Self(handle)) 172 + } 173 + 174 + pub unsafe fn unchecked_cow(handle: CowStr<'h>) -> Self { 175 + Self(handle) 176 + } 177 + } 178 + 179 + // --------------------------------------------------------------------------- 180 + // Deserialization — generic over S: Deserialize<'de> 181 + // --------------------------------------------------------------------------- 182 + 183 + impl<'de, S> Deserialize<'de> for Handle<S> 184 + where 185 + S: Bos<str> + AsRef<str> + Deserialize<'de>, 186 + { 187 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 188 + where 189 + D: Deserializer<'de>, 190 + { 191 + let s = S::deserialize(deserializer)?; 192 + validate_handle(s.as_ref()).map_err(serde::de::Error::custom)?; 193 + Ok(Handle(s)) 196 194 } 195 + } 197 196 198 - /// Infallible constructor for when you *know* the string is a valid handle. 199 - /// Will panic on invalid handles. If you're manually decoding atproto records 200 - /// or API values you know are valid (rather than using serde), this is the one to use. 201 - /// The `From<String>` and `From<CowStr>` impls use the same logic. 202 - /// 203 - /// Accepts (and strips) preceding '@' or 'at://' if present 204 - pub fn raw(handle: &'h str) -> Self { 205 - if handle.contains(|c: char| c.is_ascii_uppercase()) { 206 - return Self::new_owned(handle).expect("Invalid handle"); 207 - } 208 - let stripped = handle 209 - .strip_prefix("at://") 210 - .or_else(|| handle.strip_prefix('@')) 211 - .unwrap_or(handle); 212 - let handle = stripped; 213 - if handle.len() > 253 { 214 - panic!("handle too long") 215 - } else if !HANDLE_REGEX.is_match(handle) { 216 - panic!("Invalid handle") 217 - } else if ends_with(handle, DISALLOWED_TLDS) { 218 - // specifically pass this through as it is returned in instances where someone 219 - // has screwed up their handle, and it's awkward to fail so early 220 - if handle == "handle.invalid" { 221 - Self(CowStr::Borrowed(stripped)) 222 - } else { 223 - panic!("top-level domain not allowed in handles") 224 - } 197 + // --------------------------------------------------------------------------- 198 + // Serialization — always lowercase 199 + // --------------------------------------------------------------------------- 200 + 201 + impl<S: Bos<str> + AsRef<str>> Serialize for Handle<S> { 202 + fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> 203 + where 204 + Ser: Serializer, 205 + { 206 + let raw = self.as_str(); 207 + if raw.bytes().all(|b| !b.is_ascii_uppercase()) { 208 + serializer.serialize_str(raw) 225 209 } else { 226 - Self(CowStr::Borrowed(handle)) 210 + let lowered = raw.to_lowercase_smolstr(); 211 + serializer.serialize_str(&lowered) 227 212 } 228 213 } 214 + } 229 215 230 - /// Infallible constructor for when you *know* the string is a valid handle. 231 - /// Marked unsafe because responsibility for upholding the invariant is on the developer. 232 - /// 233 - /// Accepts (and strips) preceding '@' or 'at://' if present 234 - pub unsafe fn unchecked(handle: &'h str) -> Self { 235 - let stripped = handle 236 - .strip_prefix("at://") 237 - .or_else(|| handle.strip_prefix('@')) 238 - .unwrap_or(handle); 239 - if stripped.contains(|c: char| c.is_ascii_uppercase()) { 240 - return Self(CowStr::Owned(stripped.to_lowercase_smolstr())); 241 - } 242 - Self(CowStr::Borrowed(stripped)) 216 + // --------------------------------------------------------------------------- 217 + // Case-insensitive equality and hashing 218 + // --------------------------------------------------------------------------- 219 + 220 + impl<S: Bos<str> + AsRef<str>, T: Bos<str> + AsRef<str>> PartialEq<Handle<T>> for Handle<S> { 221 + fn eq(&self, other: &Handle<T>) -> bool { 222 + self.as_str().eq_ignore_ascii_case(other.as_str()) 243 223 } 224 + } 244 225 245 - /// Get the handle as a string slice 246 - pub fn as_str(&self) -> &str { 247 - { 248 - let this = &self.0; 249 - this 226 + impl<S: Bos<str> + AsRef<str>> Eq for Handle<S> {} 227 + 228 + impl<S: Bos<str> + AsRef<str>> Hash for Handle<S> { 229 + fn hash<H: Hasher>(&self, state: &mut H) { 230 + for byte in self.as_str().bytes() { 231 + state.write_u8(byte.to_ascii_lowercase()); 250 232 } 251 233 } 252 234 } 253 235 254 - impl FromStr for Handle<'_> { 236 + // --------------------------------------------------------------------------- 237 + // Other trait impls 238 + // --------------------------------------------------------------------------- 239 + 240 + impl<S: Bos<str> + IntoStatic> IntoStatic for Handle<S> 241 + where 242 + S::Output: Bos<str>, 243 + { 244 + type Output = Handle<S::Output>; 245 + 246 + fn into_static(self) -> Self::Output { 247 + Handle(self.0.into_static()) 248 + } 249 + } 250 + 251 + impl FromStr for Handle { 255 252 type Err = AtStrError; 256 253 257 - /// Has to take ownership due to the lifetime constraints of the FromStr trait. 258 - /// Prefer `Handle::new()` or `Handle::raw` if you want to borrow. 259 254 fn from_str(s: &str) -> Result<Self, Self::Err> { 260 255 Self::new_owned(s) 261 256 } 262 257 } 263 258 264 - impl IntoStatic for Handle<'_> { 265 - type Output = Handle<'static>; 259 + impl FromStr for Handle<CowStr<'static>> { 260 + type Err = AtStrError; 266 261 267 - fn into_static(self) -> Self::Output { 268 - Handle(self.0.into_static()) 262 + fn from_str(s: &str) -> Result<Self, Self::Err> { 263 + Self::new_owned(s) 269 264 } 270 265 } 271 266 272 - impl<'de, 'a> Deserialize<'de> for Handle<'a> 273 - where 274 - 'de: 'a, 275 - { 276 - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 277 - where 278 - D: Deserializer<'de>, 279 - { 280 - let value = Deserialize::deserialize(deserializer)?; 281 - Self::new_cow(value).map_err(D::Error::custom) 267 + impl FromStr for Handle<String> { 268 + type Err = AtStrError; 269 + 270 + fn from_str(s: &str) -> Result<Self, Self::Err> { 271 + Self::new_owned(s) 282 272 } 283 273 } 284 274 285 - impl fmt::Display for Handle<'_> { 275 + impl<S: Bos<str> + AsRef<str>> fmt::Display for Handle<S> { 286 276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 287 - write!(f, "{}", self.0) 277 + if self.0.as_ref().contains(|c: char| c.is_ascii_uppercase()) { 278 + for c in self.as_str().chars() { 279 + fmt::Write::write_char(f, c.to_ascii_lowercase())?; 280 + } 281 + } else { 282 + f.write_str(self.as_str())?; 283 + } 284 + Ok(()) 288 285 } 289 286 } 290 287 291 - impl fmt::Debug for Handle<'_> { 288 + impl<S: Bos<str> + AsRef<str>> fmt::Debug for Handle<S> { 292 289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 293 - write!(f, "at://{}", self.0) 290 + write!(f, "at://")?; 291 + if self.0.as_ref().contains(|c: char| c.is_ascii_uppercase()) { 292 + for c in self.as_str().chars() { 293 + fmt::Write::write_char(f, c.to_ascii_lowercase())?; 294 + } 295 + } else { 296 + f.write_str(self.as_str())?; 297 + } 298 + Ok(()) 294 299 } 295 300 } 296 301 297 - impl<'h> From<Handle<'h>> for String { 298 - fn from(value: Handle<'h>) -> Self { 299 - value.0.to_string() 302 + impl<S: Bos<str> + AsRef<str>> From<Handle<S>> for String { 303 + fn from(value: Handle<S>) -> Self { 304 + value.as_str().to_ascii_lowercase() 300 305 } 301 306 } 302 307 303 - impl<'h> From<Handle<'h>> for CowStr<'h> { 304 - fn from(value: Handle<'h>) -> Self { 305 - value.0 308 + impl<S: Bos<str> + AsRef<str>> From<Handle<S>> for SmolStr { 309 + fn from(value: Handle<S>) -> Self { 310 + value.as_str().to_ascii_lowercase_smolstr() 306 311 } 307 312 } 308 313 309 - impl From<String> for Handle<'static> { 314 + impl From<String> for Handle { 310 315 fn from(value: String) -> Self { 311 316 Self::new_owned(value).unwrap() 312 317 } 313 318 } 314 319 315 - impl<'h> From<CowStr<'h>> for Handle<'h> { 320 + impl<'h> From<CowStr<'h>> for Handle<CowStr<'h>> { 316 321 fn from(value: CowStr<'h>) -> Self { 317 - Self::new_owned(value).unwrap() 322 + Self::new_cow(value).unwrap() 318 323 } 319 324 } 320 325 321 - impl AsRef<str> for Handle<'_> { 326 + impl<S: Bos<str> + AsRef<str>> AsRef<str> for Handle<S> { 322 327 fn as_ref(&self) -> &str { 323 328 self.as_str() 324 329 } 325 330 } 326 331 327 - impl Deref for Handle<'_> { 332 + impl<S: Bos<str> + AsRef<str>> Deref for Handle<S> { 328 333 type Target = str; 329 334 330 335 fn deref(&self) -> &Self::Target { ··· 338 343 339 344 #[test] 340 345 fn valid_handles() { 341 - assert!(Handle::new("alice.test").is_ok()); 342 - assert!(Handle::new("foo.bsky.social").is_ok()); 343 - assert!(Handle::new("a.b.c.d.e").is_ok()); 344 - assert!(Handle::new("a1.b2.c3").is_ok()); 345 - assert!(Handle::new("name-with-dash.com").is_ok()); 346 + assert!(Handle::<&str>::new("alice.test").is_ok()); 347 + assert!(Handle::<&str>::new("foo.bsky.social").is_ok()); 348 + assert!(Handle::<&str>::new("a.b.c.d.e").is_ok()); 349 + assert!(Handle::<&str>::new("a1.b2.c3").is_ok()); 350 + assert!(Handle::<&str>::new("name-with-dash.com").is_ok()); 351 + } 352 + 353 + #[test] 354 + fn valid_handles_owned() { 355 + assert!(Handle::<SmolStr>::new_owned("alice.test").is_ok()); 356 + assert!(Handle::<SmolStr>::new_owned("Alice.Test").is_ok()); 357 + assert!(Handle::<String>::new_owned("foo.bsky.social").is_ok()); 358 + } 359 + 360 + #[test] 361 + fn borrowed_rejects_uppercase() { 362 + assert!(Handle::<&str>::new("Alice.Test").is_err()); 346 363 } 347 364 348 365 #[test] 349 366 fn prefix_stripping() { 350 - assert_eq!(Handle::new("@alice.test").unwrap().as_str(), "alice.test"); 367 + assert_eq!( 368 + Handle::<&str>::new("@alice.test").unwrap().as_str(), 369 + "alice.test" 370 + ); 371 + assert_eq!( 372 + Handle::<&str>::new("at://alice.test").unwrap().as_str(), 373 + "alice.test" 374 + ); 375 + assert_eq!( 376 + Handle::<&str>::new("alice.test").unwrap().as_str(), 377 + "alice.test" 378 + ); 379 + } 380 + 381 + #[test] 382 + fn prefix_stripping_owned() { 351 383 assert_eq!( 352 - Handle::new("at://alice.test").unwrap().as_str(), 384 + Handle::<SmolStr>::new_owned("@Alice.Test") 385 + .unwrap() 386 + .as_str(), 353 387 "alice.test" 354 388 ); 355 - assert_eq!(Handle::new("alice.test").unwrap().as_str(), "alice.test"); 389 + assert_eq!( 390 + Handle::<SmolStr>::new_owned("at://alice.test") 391 + .unwrap() 392 + .as_str(), 393 + "alice.test" 394 + ); 356 395 } 357 396 358 397 #[test] 359 398 fn max_length() { 360 - // 253 chars: three 63-char segments + one 61-char segment + 3 dots = 253 361 - let s1 = format!("a{}a", "b".repeat(61)); // 63 362 - let s2 = format!("c{}c", "d".repeat(61)); // 63 363 - let s3 = format!("e{}e", "f".repeat(61)); // 63 364 - let s4 = format!("g{}g", "h".repeat(59)); // 61 365 - let valid_253 = format!("{}.{}.{}.{}", s1, s2, s3, s4); 399 + let s1 = format!("a{}a", "b".repeat(61)); 400 + let s2 = format!("c{}c", "d".repeat(61)); 401 + let s3 = format!("e{}e", "f".repeat(61)); 402 + let s4 = format!("g{}g", "h".repeat(59)); 403 + let valid_253 = format!("{s1}.{s2}.{s3}.{s4}"); 366 404 assert_eq!(valid_253.len(), 253); 367 - assert!(Handle::new(&valid_253).is_ok()); 405 + assert!(Handle::<&str>::new(&valid_253).is_ok()); 368 406 369 - // 254 chars: make last segment 62 chars 370 - let s4_long = format!("g{}g", "h".repeat(60)); // 62 371 - let too_long_254 = format!("{}.{}.{}.{}", s1, s2, s3, s4_long); 407 + let s4_long = format!("g{}g", "h".repeat(60)); 408 + let too_long_254 = format!("{s1}.{s2}.{s3}.{s4_long}"); 372 409 assert_eq!(too_long_254.len(), 254); 373 - assert!(Handle::new(&too_long_254).is_err()); 410 + assert!(Handle::<&str>::new(&too_long_254).is_err()); 374 411 } 375 412 376 413 #[test] 377 414 fn segment_length_constraints() { 378 - let valid_63_char_segment = format!("{}.com", "a".repeat(63)); 379 - assert!(Handle::new(&valid_63_char_segment).is_ok()); 380 - 381 - let too_long_64_char_segment = format!("{}.com", "a".repeat(64)); 382 - assert!(Handle::new(&too_long_64_char_segment).is_err()); 415 + let valid = format!("{}.com", "a".repeat(63)); 416 + assert!(Handle::<&str>::new(&valid).is_ok()); 417 + let too_long = format!("{}.com", "a".repeat(64)); 418 + assert!(Handle::<&str>::new(&too_long).is_err()); 383 419 } 384 420 385 421 #[test] 386 422 fn hyphen_placement() { 387 - assert!(Handle::new("valid-label.com").is_ok()); 388 - assert!(Handle::new("-nope.com").is_err()); 389 - assert!(Handle::new("nope-.com").is_err()); 423 + assert!(Handle::<&str>::new("valid-label.com").is_ok()); 424 + assert!(Handle::<&str>::new("-nope.com").is_err()); 425 + assert!(Handle::<&str>::new("nope-.com").is_err()); 390 426 } 391 427 392 428 #[test] 393 429 fn tld_must_start_with_letter() { 394 - assert!(Handle::new("foo.bar").is_ok()); 395 - assert!(Handle::new("foo.9bar").is_err()); 430 + assert!(Handle::<&str>::new("foo.bar").is_ok()); 431 + assert!(Handle::<&str>::new("foo.9bar").is_err()); 396 432 } 397 433 398 434 #[test] 399 435 fn disallowed_tlds() { 400 - assert!(Handle::new("foo.local").is_err()); 401 - assert!(Handle::new("foo.localhost").is_err()); 402 - assert!(Handle::new("foo.arpa").is_err()); 403 - assert!(Handle::new("foo.invalid").is_err()); 404 - assert!(Handle::new("foo.internal").is_err()); 405 - assert!(Handle::new("foo.example").is_err()); 406 - assert!(Handle::new("foo.alt").is_err()); 407 - assert!(Handle::new("foo.onion").is_err()); 436 + for tld in [ 437 + "local", 438 + "localhost", 439 + "arpa", 440 + "invalid", 441 + "internal", 442 + "example", 443 + "alt", 444 + "onion", 445 + ] { 446 + assert!( 447 + Handle::<&str>::new(&format!("foo.{tld}")).is_err(), 448 + "should reject .{tld}" 449 + ); 450 + } 408 451 } 409 452 410 453 #[test] 411 454 fn minimum_segments() { 412 - assert!(Handle::new("a.b").is_ok()); 413 - assert!(Handle::new("a").is_err()); 414 - assert!(Handle::new("com").is_err()); 455 + assert!(Handle::<&str>::new("a.b").is_ok()); 456 + assert!(Handle::<&str>::new("a").is_err()); 457 + assert!(Handle::<&str>::new("com").is_err()); 415 458 } 416 459 417 460 #[test] 418 461 fn invalid_characters() { 419 - assert!(Handle::new("foo!bar.com").is_err()); 420 - assert!(Handle::new("foo_bar.com").is_err()); 421 - assert!(Handle::new("foo bar.com").is_err()); 422 - assert!(Handle::new("foo@bar.com").is_err()); 462 + assert!(Handle::<&str>::new("foo!bar.com").is_err()); 463 + assert!(Handle::<&str>::new("foo_bar.com").is_err()); 464 + assert!(Handle::<&str>::new("foo bar.com").is_err()); 465 + assert!(Handle::<&str>::new("foo@bar.com").is_err()); 423 466 } 424 467 425 468 #[test] 426 469 fn empty_segments() { 427 - assert!(Handle::new("foo..com").is_err()); 428 - assert!(Handle::new(".foo.com").is_err()); 429 - assert!(Handle::new("foo.com.").is_err()); 470 + assert!(Handle::<&str>::new("foo..com").is_err()); 471 + assert!(Handle::<&str>::new(".foo.com").is_err()); 472 + assert!(Handle::<&str>::new("foo.com.").is_err()); 473 + } 474 + 475 + #[test] 476 + fn handle_invalid_passthrough() { 477 + assert!(Handle::<&str>::new("handle.invalid").is_ok()); 478 + assert!(Handle::<SmolStr>::new_owned("handle.invalid").is_ok()); 479 + } 480 + 481 + #[test] 482 + fn into_static_borrowed() { 483 + let h = Handle::<&str>::new("alice.test").unwrap(); 484 + let owned: Handle<SmolStr> = h.into_static(); 485 + assert_eq!(owned.as_str(), "alice.test"); 486 + } 487 + 488 + #[test] 489 + fn into_static_already_owned() { 490 + let h = Handle::<SmolStr>::new_owned("alice.test").unwrap(); 491 + let owned: Handle<SmolStr> = h.into_static(); 492 + assert_eq!(owned.as_str(), "alice.test"); 493 + } 494 + 495 + #[test] 496 + fn case_insensitive_equality() { 497 + let lower = Handle::<SmolStr>::new_owned("alice.test").unwrap(); 498 + let upper = Handle(SmolStr::new("Alice.Test")); 499 + assert_eq!(lower, upper); 500 + } 501 + 502 + #[test] 503 + fn case_insensitive_hash() { 504 + let a = Handle::<SmolStr>::new_owned("alice.test").unwrap(); 505 + let b = Handle(SmolStr::new("Alice.Test")); 506 + assert_eq!(a, b); 507 + #[allow(deprecated)] 508 + let (mut ha, mut hb) = (core::hash::SipHasher::new(), core::hash::SipHasher::new()); 509 + a.hash(&mut ha); 510 + b.hash(&mut hb); 511 + assert_eq!(ha.finish(), hb.finish()); 512 + } 513 + 514 + #[test] 515 + fn display_lowercases() { 516 + let h = Handle(SmolStr::new("Alice.Test")); 517 + assert_eq!(format!("{h}"), "alice.test"); 518 + } 519 + 520 + #[test] 521 + fn serialize_lowercases() { 522 + let h = Handle(SmolStr::new("Alice.Test")); 523 + let json = serde_json::to_string(&h).unwrap(); 524 + assert_eq!(json, "\"alice.test\""); 525 + } 526 + 527 + #[test] 528 + fn cross_type_equality() { 529 + let borrowed = Handle::<&str>::new("alice.test").unwrap(); 530 + let owned = Handle::<SmolStr>::new_owned("alice.test").unwrap(); 531 + assert_eq!(borrowed, owned); 430 532 } 431 533 }
+136 -100
crates/jacquard-common/src/types/ident.rs
··· 1 + use crate::bos::{Bos, DefaultStr}; 1 2 use crate::types::handle::Handle; 2 3 use crate::types::string::AtStrError; 3 - use crate::{IntoStatic, types::did::Did}; 4 + use crate::{CowStr, IntoStatic, types::did::Did}; 4 5 use alloc::string::String; 6 + use alloc::string::ToString; 5 7 use core::fmt; 6 8 use core::str::FromStr; 7 9 8 10 use serde::{Deserialize, Serialize}; 9 11 10 - use crate::CowStr; 12 + use smol_str::SmolStr; 11 13 12 - /// AT Protocol identifier (either a DID or handle) 14 + /// AT Protocol identifier (either a DID or handle). 13 15 /// 14 16 /// Represents the union of DIDs and handles, which can both be used to identify 15 17 /// accounts in AT Protocol. DIDs are permanent identifiers, while handles are ··· 18 20 /// Automatically determines whether a string is a DID or a handle during parsing. 19 21 #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] 20 22 #[serde(untagged)] 21 - pub enum AtIdentifier<'i> { 23 + #[serde(bound( 24 + serialize = "S: Bos<str> + AsRef<str> + Serialize", 25 + deserialize = "S: Bos<str> + AsRef<str> + Deserialize<'de>" 26 + ))] 27 + pub enum AtIdentifier<S: Bos<str> + AsRef<str> = DefaultStr> { 22 28 /// DID variant 23 - #[serde(borrow)] 24 - Did(Did<'i>), 29 + Did(Did<S>), 25 30 /// Handle variant 26 - Handle(Handle<'i>), 31 + Handle(Handle<S>), 32 + } 33 + 34 + // --------------------------------------------------------------------------- 35 + // Core methods 36 + // --------------------------------------------------------------------------- 37 + 38 + impl<S: Bos<str> + AsRef<str>> AtIdentifier<S> { 39 + /// Get the identifier as a string slice. 40 + pub fn as_str(&self) -> &str { 41 + match self { 42 + AtIdentifier::Did(did) => did.as_str(), 43 + AtIdentifier::Handle(handle) => handle.as_str(), 44 + } 45 + } 27 46 } 28 47 29 - impl<'i> AtIdentifier<'i> { 30 - /// Fallible constructor, validates, borrows from input 48 + // --------------------------------------------------------------------------- 49 + // Borrowed construction 50 + // --------------------------------------------------------------------------- 51 + 52 + impl<'i> AtIdentifier<&'i str> { 53 + /// Fallible constructor, validates, borrows from input. 31 54 pub fn new(ident: &'i str) -> Result<Self, AtStrError> { 32 - if let Ok(did) = ident.parse() { 55 + if let Ok(did) = Did::new(ident) { 33 56 Ok(AtIdentifier::Did(did)) 34 57 } else { 35 - ident.parse().map(AtIdentifier::Handle) 58 + Handle::new(ident).map(AtIdentifier::Handle) 36 59 } 37 60 } 38 61 39 - /// Fallible constructor, validates, takes ownership 62 + /// Infallible constructor. Panics on invalid identifiers. 63 + pub fn raw(ident: &'i str) -> Self { 64 + Self::new(ident).expect("valid identifier") 65 + } 66 + 67 + /// Infallible unchecked constructor. 68 + /// 69 + /// # Safety 70 + /// 71 + /// Validates DIDs, treats anything else as a valid handle. 72 + pub unsafe fn unchecked(ident: &'i str) -> Self { 73 + if let Ok(did) = Did::new(ident) { 74 + AtIdentifier::Did(did) 75 + } else { 76 + unsafe { AtIdentifier::Handle(Handle::unchecked(ident)) } 77 + } 78 + } 79 + } 80 + 81 + // --------------------------------------------------------------------------- 82 + // Owned construction 83 + // --------------------------------------------------------------------------- 84 + 85 + impl<S: Bos<str> + AsRef<str> + From<SmolStr>> AtIdentifier<S> { 86 + /// Fallible constructor, validates, takes ownership. 40 87 pub fn new_owned(ident: impl AsRef<str>) -> Result<Self, AtStrError> { 41 88 let ident = ident.as_ref(); 42 89 if let Ok(did) = Did::new_owned(ident) { 43 90 Ok(AtIdentifier::Did(did)) 44 91 } else { 45 - Ok(AtIdentifier::Handle(Handle::new_owned(ident)?)) 92 + Handle::new_owned(ident).map(AtIdentifier::Handle) 46 93 } 47 94 } 48 95 49 - /// Fallible constructor, validates, doesn't allocate 50 - pub fn new_static(ident: &'static str) -> Result<AtIdentifier<'static>, AtStrError> { 96 + /// Fallible constructor for static strings. 97 + pub fn new_static(ident: &'static str) -> Result<Self, AtStrError> { 51 98 if let Ok(did) = Did::new_static(ident) { 52 99 Ok(AtIdentifier::Did(did)) 53 100 } else { 54 - Ok(AtIdentifier::Handle(Handle::new_static(ident)?)) 101 + Handle::new_static(ident).map(AtIdentifier::Handle) 55 102 } 56 103 } 104 + } 57 105 58 - /// Fallible constructor, validates, borrows from input if possible 106 + // --------------------------------------------------------------------------- 107 + // CowStr construction 108 + // --------------------------------------------------------------------------- 109 + 110 + impl<'i> AtIdentifier<CowStr<'i>> { 111 + /// Fallible constructor, borrows if possible. 59 112 pub fn new_cow(ident: CowStr<'i>) -> Result<Self, AtStrError> { 60 113 if let Ok(did) = Did::new_cow(ident.clone()) { 61 114 Ok(AtIdentifier::Did(did)) 62 115 } else { 63 - Ok(AtIdentifier::Handle(Handle::new_cow(ident)?)) 116 + Handle::new_cow(ident).map(AtIdentifier::Handle) 64 117 } 65 118 } 66 119 67 - /// Infallible constructor for when you *know* the string is a valid identifier. 68 - /// Will panic on invalid identifiers. If you're manually decoding atproto records 69 - /// or API values you know are valid (rather than using serde), this is the one to use. 70 - /// The `From<String>` and `From<CowStr>` impls use the same logic. 71 - pub fn raw(ident: &'i str) -> Self { 72 - if let Ok(did) = ident.parse() { 73 - AtIdentifier::Did(did) 74 - } else { 75 - ident 76 - .parse() 77 - .map(AtIdentifier::Handle) 78 - .expect("valid handle") 120 + pub unsafe fn unchecked_cow(ident: CowStr<'i>) -> Self { 121 + unsafe { 122 + if let Ok(did) = Did::new_cow(ident.clone()) { 123 + AtIdentifier::Did(did) 124 + } else { 125 + AtIdentifier::Handle(Handle::unchecked_cow(ident)) 126 + } 79 127 } 80 128 } 129 + } 81 130 82 - /// Infallible constructor for when you *know* the string is a valid identifier. 83 - /// Marked unsafe because responsibility for upholding the invariant is on the developer. 84 - /// 85 - /// Will validate DIDs, but will treat anything else as a valid handle 86 - pub unsafe fn unchecked(ident: &'i str) -> Self { 87 - if let Ok(did) = ident.parse() { 88 - AtIdentifier::Did(did) 89 - } else { 90 - unsafe { AtIdentifier::Handle(Handle::unchecked(ident)) } 91 - } 92 - } 131 + // --------------------------------------------------------------------------- 132 + // Trait impls 133 + // --------------------------------------------------------------------------- 134 + 135 + impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for AtIdentifier<S> 136 + where 137 + S::Output: Bos<str> + AsRef<str>, 138 + { 139 + type Output = AtIdentifier<S::Output>; 93 140 94 - /// Get the identifier as a string slice 95 - pub fn as_str(&self) -> &str { 141 + fn into_static(self) -> Self::Output { 96 142 match self { 97 - AtIdentifier::Did(did) => did.as_str(), 98 - AtIdentifier::Handle(handle) => handle.as_str(), 143 + AtIdentifier::Did(did) => AtIdentifier::Did(did.into_static()), 144 + AtIdentifier::Handle(handle) => AtIdentifier::Handle(handle.into_static()), 99 145 } 100 146 } 101 147 } 102 148 103 - impl<'i> From<Did<'i>> for AtIdentifier<'i> { 104 - fn from(did: Did<'i>) -> Self { 149 + impl<S: Bos<str> + AsRef<str>> From<Did<S>> for AtIdentifier<S> { 150 + fn from(did: Did<S>) -> Self { 105 151 AtIdentifier::Did(did) 106 152 } 107 153 } 108 154 109 - impl<'i> From<Handle<'i>> for AtIdentifier<'i> { 110 - fn from(handle: Handle<'i>) -> Self { 155 + impl<S: Bos<str> + AsRef<str>> From<Handle<S>> for AtIdentifier<S> { 156 + fn from(handle: Handle<S>) -> Self { 111 157 AtIdentifier::Handle(handle) 112 158 } 113 159 } 114 160 115 - impl FromStr for AtIdentifier<'_> { 161 + impl FromStr for AtIdentifier { 162 + type Err = AtStrError; 163 + 164 + fn from_str(s: &str) -> Result<Self, Self::Err> { 165 + Self::new_owned(s) 166 + } 167 + } 168 + 169 + impl FromStr for AtIdentifier<CowStr<'static>> { 116 170 type Err = AtStrError; 117 171 118 172 fn from_str(s: &str) -> Result<Self, Self::Err> { 119 - if let Ok(did) = s.parse() { 120 - Ok(AtIdentifier::Did(did)) 121 - } else { 122 - s.parse().map(AtIdentifier::Handle) 123 - } 173 + Self::new_owned(s) 124 174 } 125 175 } 126 176 127 - impl IntoStatic for AtIdentifier<'_> { 128 - type Output = AtIdentifier<'static>; 177 + impl FromStr for AtIdentifier<String> { 178 + type Err = AtStrError; 129 179 130 - fn into_static(self) -> Self::Output { 131 - match self { 132 - AtIdentifier::Did(did) => AtIdentifier::Did(did.into_static()), 133 - AtIdentifier::Handle(handle) => AtIdentifier::Handle(handle.into_static()), 134 - } 180 + fn from_str(s: &str) -> Result<Self, Self::Err> { 181 + Self::new_owned(s) 135 182 } 136 183 } 137 184 138 - impl fmt::Display for AtIdentifier<'_> { 185 + impl<S: Bos<str> + AsRef<str>> fmt::Display for AtIdentifier<S> { 139 186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 140 187 match self { 141 188 AtIdentifier::Did(did) => did.fmt(f), ··· 144 191 } 145 192 } 146 193 147 - impl From<String> for AtIdentifier<'static> { 194 + impl From<String> for AtIdentifier { 148 195 fn from(value: String) -> Self { 149 - if let Ok(did) = value.parse() { 150 - AtIdentifier::Did(did) 151 - } else { 152 - value 153 - .parse() 154 - .map(AtIdentifier::Handle) 155 - .expect("valid handle") 156 - } 196 + Self::new_owned(value).expect("valid identifier") 157 197 } 158 198 } 159 199 160 - impl<'i> From<CowStr<'i>> for AtIdentifier<'i> { 200 + impl<'i> From<CowStr<'i>> for AtIdentifier<CowStr<'i>> { 161 201 fn from(value: CowStr<'i>) -> Self { 162 - if let Ok(did) = value.parse() { 163 - AtIdentifier::Did(did) 164 - } else { 165 - value 166 - .parse() 167 - .map(AtIdentifier::Handle) 168 - .expect("valid handle") 169 - } 202 + Self::new_cow(value).expect("valid identifier") 170 203 } 171 204 } 172 205 173 - impl<'i> From<AtIdentifier<'i>> for String { 174 - fn from(value: AtIdentifier) -> Self { 175 - match value { 176 - AtIdentifier::Did(did) => did.into(), 177 - AtIdentifier::Handle(handle) => handle.into(), 178 - } 206 + impl<S: Bos<str> + AsRef<str>> From<AtIdentifier<S>> for String { 207 + fn from(value: AtIdentifier<S>) -> Self { 208 + value.as_str().to_string() 179 209 } 180 210 } 181 211 182 - impl AsRef<str> for AtIdentifier<'_> { 212 + impl<S: Bos<str> + AsRef<str>> AsRef<str> for AtIdentifier<S> { 183 213 fn as_ref(&self) -> &str { 184 - match self { 185 - AtIdentifier::Did(did) => did.as_ref(), 186 - AtIdentifier::Handle(handle) => handle.as_ref(), 187 - } 214 + self.as_str() 188 215 } 189 216 } 190 217 191 218 #[cfg(test)] 192 219 mod tests { 193 220 use super::*; 221 + use crate::cowstr::ToCowStr; 194 222 195 223 #[test] 196 224 fn parses_did() { 197 - let ident = AtIdentifier::new("did:plc:foo").unwrap(); 225 + let ident = AtIdentifier::<&str>::new("did:plc:foo").unwrap(); 198 226 assert!(matches!(ident, AtIdentifier::Did(_))); 199 227 assert_eq!(ident.as_str(), "did:plc:foo"); 200 228 } 201 229 202 230 #[test] 203 231 fn parses_handle() { 204 - let ident = AtIdentifier::new("alice.test").unwrap(); 232 + let ident = AtIdentifier::<&str>::new("alice.test").unwrap(); 205 233 assert!(matches!(ident, AtIdentifier::Handle(_))); 206 234 assert_eq!(ident.as_str(), "alice.test"); 207 235 } 208 236 209 237 #[test] 210 238 fn did_takes_precedence() { 211 - // DID is tried first, so valid DIDs are parsed as DIDs 212 - let ident = AtIdentifier::new("did:web:alice.test").unwrap(); 239 + let ident = AtIdentifier::<&str>::new("did:web:alice.test").unwrap(); 213 240 assert!(matches!(ident, AtIdentifier::Did(_))); 214 241 } 215 242 216 243 #[test] 217 244 fn from_types() { 218 - let did = Did::new("did:plc:foo").unwrap(); 219 - let ident: AtIdentifier = did.into(); 245 + let did = Did::<SmolStr>::new_owned("did:plc:foo").unwrap(); 246 + let ident: AtIdentifier<SmolStr> = did.into(); 247 + assert!(matches!(ident, AtIdentifier::Did(_))); 248 + 249 + let handle = Handle::new_cow("alice.test".to_cowstr()).unwrap(); 250 + let ident: AtIdentifier<CowStr> = handle.into(); 251 + assert!(matches!(ident, AtIdentifier::Handle(_))); 252 + } 253 + 254 + #[test] 255 + fn owned_construction() { 256 + let ident = AtIdentifier::<SmolStr>::new_owned("did:plc:foo").unwrap(); 220 257 assert!(matches!(ident, AtIdentifier::Did(_))); 221 258 222 - let handle = Handle::new("alice.test").unwrap(); 223 - let ident: AtIdentifier = handle.into(); 259 + let ident = AtIdentifier::<SmolStr>::new_owned("alice.test").unwrap(); 224 260 assert!(matches!(ident, AtIdentifier::Handle(_))); 225 261 } 226 262 }
+151 -197
crates/jacquard-common/src/types/nsid.rs
··· 1 + use crate::bos::{Bos, DefaultStr}; 1 2 use crate::types::recordkey::RecordKeyType; 2 3 use crate::types::string::AtStrError; 3 4 use crate::{CowStr, IntoStatic}; ··· 11 12 use regex_automata::meta::Regex; 12 13 #[cfg(target_arch = "wasm32")] 13 14 use regex_lite::Regex; 14 - use serde::{Deserialize, Deserializer, Serialize, de::Error}; 15 + use serde::{Deserialize, Deserializer, Serialize}; 15 16 use smol_str::{SmolStr, ToSmolStr}; 16 17 17 18 use super::Lazy; 18 19 19 - /// Namespaced Identifier (NSID) for Lexicon schemas and XRPC endpoints 20 - /// 21 - /// NSIDs provide globally unique identifiers for Lexicon schemas, record types, and XRPC methods. 22 - /// They're structured as reversed domain names with a camelCase name segment. 23 - /// 24 - /// Format: `domain.authority.name` (e.g., `com.example.fooBar`) 25 - /// - Domain authority: reversed domain name (≤253 chars, lowercase, dots separate segments) 26 - /// - Name: camelCase identifier (letters and numbers only, cannot start with a digit) 27 - /// 28 - /// Validation rules: 29 - /// - Minimum 3 segments 30 - /// - Maximum 317 characters total 31 - /// - Each domain segment is 1-63 characters 32 - /// - Case-sensitive 20 + /// Namespaced Identifier (NSID) for Lexicon schemas and XRPC endpoints. 33 21 /// 34 22 /// See: <https://atproto.com/specs/nsid> 35 - #[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] 23 + #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] 36 24 #[serde(transparent)] 37 25 #[repr(transparent)] 38 - pub struct Nsid<'n>(pub(crate) CowStr<'n>); 26 + pub struct Nsid<S: Bos<str> = DefaultStr>(pub(crate) S); 39 27 40 - /// Regex for NSID validation per AT Protocol spec 28 + /// Regex for NSID validation per AT Protocol spec. 41 29 pub static NSID_REGEX: Lazy<Regex> = Lazy::new(|| { 42 30 Regex::new(r"^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z][a-zA-Z0-9]{0,62})$").unwrap() 43 31 }); 44 32 45 - impl<'n> Nsid<'n> { 46 - /// Fallible constructor, validates, borrows from input 47 - pub fn new(nsid: &'n str) -> Result<Self, AtStrError> { 48 - if nsid.len() > 317 { 49 - Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) 50 - } else if !NSID_REGEX.is_match(nsid) { 51 - Err(AtStrError::regex( 52 - "nsid", 53 - nsid, 54 - SmolStr::new_static("invalid"), 55 - )) 56 - } else { 57 - Ok(Self(CowStr::Borrowed(nsid))) 58 - } 59 - } 60 - 61 - /// Fallible constructor, validates, borrows from input 62 - pub fn new_owned(nsid: impl AsRef<str>) -> Result<Self, AtStrError> { 63 - let nsid = nsid.as_ref(); 64 - if nsid.len() > 317 { 65 - Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) 66 - } else if !NSID_REGEX.is_match(nsid) { 67 - Err(AtStrError::regex( 68 - "nsid", 69 - nsid, 70 - SmolStr::new_static("invalid"), 71 - )) 72 - } else { 73 - Ok(Self(CowStr::Owned(nsid.to_smolstr()))) 74 - } 75 - } 76 - 77 - /// Fallible constructor, validates, doesn't allocate 78 - pub fn new_static(nsid: &'static str) -> Result<Self, AtStrError> { 79 - if nsid.len() > 317 { 80 - Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) 81 - } else if !NSID_REGEX.is_match(nsid) { 82 - Err(AtStrError::regex( 83 - "nsid", 84 - nsid, 85 - SmolStr::new_static("invalid"), 86 - )) 87 - } else { 88 - Ok(Self(CowStr::new_static(nsid))) 89 - } 90 - } 91 - 92 - /// Fallible constructor, validates, borrows from input if possible 93 - pub fn new_cow(nsid: CowStr<'n>) -> Result<Self, AtStrError> { 94 - if nsid.len() > 317 { 95 - Err(AtStrError::too_long("nsid", &nsid, 317, nsid.len())) 96 - } else if !NSID_REGEX.is_match(&nsid) { 97 - Err(AtStrError::regex( 98 - "nsid", 99 - &nsid, 100 - SmolStr::new_static("invalid"), 101 - )) 102 - } else { 103 - Ok(Self(nsid)) 104 - } 105 - } 106 - 107 - /// Infallible constructor for when you *know* the string is a valid NSID. 108 - /// Will panic on invalid NSIDs. If you're manually decoding atproto records 109 - /// or API values you know are valid (rather than using serde), this is the one to use. 110 - /// The `From<String>` and `From<CowStr>` impls use the same logic. 111 - pub fn raw(nsid: &'n str) -> Self { 112 - if nsid.len() > 317 { 113 - panic!("NSID too long") 114 - } else if !NSID_REGEX.is_match(nsid) { 115 - panic!("Invalid NSID") 116 - } else { 117 - Self(CowStr::Borrowed(nsid)) 118 - } 33 + fn validate_nsid(nsid: &str) -> Result<(), AtStrError> { 34 + if nsid.len() > 317 { 35 + Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) 36 + } else if !NSID_REGEX.is_match(nsid) { 37 + Err(AtStrError::regex( 38 + "nsid", 39 + nsid, 40 + SmolStr::new_static("invalid"), 41 + )) 42 + } else { 43 + Ok(()) 119 44 } 45 + } 120 46 121 - /// Infallible constructor for when you *know* the string is a valid NSID. 122 - /// Marked unsafe because responsibility for upholding the invariant is on the developer. 123 - pub unsafe fn unchecked(nsid: &'n str) -> Self { 124 - Self(CowStr::Borrowed(nsid)) 47 + impl<S: Bos<str> + AsRef<str>> Nsid<S> { 48 + /// Get the NSID as a string slice. 49 + pub fn as_str(&self) -> &str { 50 + self.0.as_ref() 125 51 } 126 52 127 53 /// Returns the domain authority part of the NSID. 128 54 pub fn domain_authority(&self) -> &str { 129 - let split = self.0.rfind('.').expect("enforced by constructor"); 130 - &self.0[..split] 55 + let s = self.as_str(); 56 + let split = s.rfind('.').expect("enforced by constructor"); 57 + &s[..split] 131 58 } 132 59 133 60 /// Returns the name segment of the NSID. 134 61 pub fn name(&self) -> &str { 135 - let split = self.0.rfind('.').expect("enforced by constructor"); 136 - &self.0[split + 1..] 62 + let s = self.as_str(); 63 + let split = s.rfind('.').expect("enforced by constructor"); 64 + &s[split + 1..] 137 65 } 66 + } 138 67 139 - /// Get the NSID as a string slice 140 - pub fn as_str(&self) -> &str { 141 - { 142 - let this = &self.0; 143 - this 144 - } 68 + impl<S: Bos<str>> Nsid<S> { 69 + /// # Safety 70 + /// 71 + /// The caller must ensure the NSID is valid. 72 + pub unsafe fn unchecked(nsid: S) -> Self { 73 + Nsid(nsid) 145 74 } 146 75 } 147 76 148 - impl<'n> FromStr for Nsid<'n> { 149 - type Err = AtStrError; 77 + impl<'n> Nsid<&'n str> { 78 + /// Fallible constructor, validates, borrows from input. 79 + pub fn new(nsid: &'n str) -> Result<Self, AtStrError> { 80 + validate_nsid(nsid)?; 81 + Ok(Self(nsid)) 82 + } 150 83 151 - /// Has to take ownership due to the lifetime constraints of the FromStr trait. 152 - /// Prefer `Nsid::new()` or `Nsid::raw` if you want to borrow. 153 - fn from_str(s: &str) -> Result<Self, Self::Err> { 154 - Self::new_owned(s) 84 + /// Infallible constructor. Panics on invalid NSIDs. 85 + pub fn raw(nsid: &'n str) -> Self { 86 + Self::new(nsid).expect("invalid NSID") 155 87 } 156 88 } 157 89 158 - impl IntoStatic for Nsid<'_> { 159 - type Output = Nsid<'static>; 90 + impl<S: Bos<str> + From<SmolStr>> Nsid<S> { 91 + /// Fallible constructor, validates, takes ownership. 92 + pub fn new_owned(nsid: impl AsRef<str>) -> Result<Self, AtStrError> { 93 + let nsid = nsid.as_ref(); 94 + validate_nsid(nsid)?; 95 + Ok(Self(S::from(nsid.to_smolstr()))) 96 + } 160 97 161 - fn into_static(self) -> Self::Output { 162 - Nsid(self.0.into_static()) 98 + /// Fallible constructor for static strings. Zero-alloc if possible. 99 + pub fn new_static(nsid: &'static str) -> Result<Self, AtStrError> { 100 + validate_nsid(nsid)?; 101 + Ok(Self(S::from(SmolStr::new_static(nsid)))) 163 102 } 164 103 } 165 104 166 - impl<'de, 'a> Deserialize<'de> for Nsid<'a> 105 + impl<'n> Nsid<CowStr<'n>> { 106 + /// Fallible constructor, borrows if possible. 107 + pub fn new_cow(nsid: CowStr<'n>) -> Result<Self, AtStrError> { 108 + validate_nsid(&nsid)?; 109 + Ok(Self(nsid)) 110 + } 111 + } 112 + 113 + impl<'de, S> Deserialize<'de> for Nsid<S> 167 114 where 168 - 'de: 'a, 115 + S: Bos<str> + AsRef<str> + Deserialize<'de>, 169 116 { 170 117 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 171 118 where 172 119 D: Deserializer<'de>, 173 120 { 174 - let value = Deserialize::deserialize(deserializer)?; 175 - Self::new_cow(value).map_err(D::Error::custom) 121 + let s = S::deserialize(deserializer)?; 122 + validate_nsid(s.as_ref()).map_err(serde::de::Error::custom)?; 123 + Ok(Nsid(s)) 176 124 } 177 125 } 178 126 179 - impl fmt::Display for Nsid<'_> { 180 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 181 - f.write_str(&self.0) 127 + impl<S: Bos<str> + IntoStatic> IntoStatic for Nsid<S> 128 + where 129 + S::Output: Bos<str>, 130 + { 131 + type Output = Nsid<S::Output>; 132 + 133 + fn into_static(self) -> Self::Output { 134 + Nsid(self.0.into_static()) 135 + } 136 + } 137 + 138 + impl FromStr for Nsid { 139 + type Err = AtStrError; 140 + 141 + fn from_str(s: &str) -> Result<Self, Self::Err> { 142 + Self::new_owned(s) 143 + } 144 + } 145 + 146 + impl FromStr for Nsid<CowStr<'static>> { 147 + type Err = AtStrError; 148 + 149 + fn from_str(s: &str) -> Result<Self, Self::Err> { 150 + Self::new_owned(s) 151 + } 152 + } 153 + 154 + impl FromStr for Nsid<String> { 155 + type Err = AtStrError; 156 + 157 + fn from_str(s: &str) -> Result<Self, Self::Err> { 158 + Self::new_owned(s) 182 159 } 183 160 } 184 161 185 - impl fmt::Debug for Nsid<'_> { 162 + impl<S: Bos<str> + AsRef<str>> fmt::Display for Nsid<S> { 186 163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 187 - write!(f, "at://{}", self.0) 164 + f.write_str(self.as_str()) 188 165 } 189 166 } 190 167 191 - impl<'n> From<Nsid<'n>> for String { 192 - fn from(value: Nsid) -> Self { 193 - value.0.to_string() 168 + impl<S: Bos<str> + AsRef<str>> fmt::Debug for Nsid<S> { 169 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 170 + write!(f, "at://{}", self.as_str()) 194 171 } 195 172 } 196 173 197 - impl<'n> From<Nsid<'n>> for CowStr<'n> { 198 - fn from(value: Nsid<'n>) -> Self { 199 - value.0 174 + impl<S: Bos<str> + AsRef<str>> From<Nsid<S>> for String { 175 + fn from(value: Nsid<S>) -> Self { 176 + value.as_str().to_string() 200 177 } 201 178 } 202 179 203 - impl From<Nsid<'_>> for SmolStr { 204 - fn from(value: Nsid) -> Self { 205 - value.0.to_smolstr() 180 + impl<S: Bos<str> + AsRef<str>> From<Nsid<S>> for SmolStr { 181 + fn from(value: Nsid<S>) -> Self { 182 + value.as_str().to_smolstr() 206 183 } 207 184 } 208 185 209 - impl<'n> From<String> for Nsid<'n> { 186 + impl From<String> for Nsid { 210 187 fn from(value: String) -> Self { 211 - if value.len() > 317 { 212 - panic!("NSID too long") 213 - } else if !NSID_REGEX.is_match(&value) { 214 - panic!("Invalid NSID") 215 - } else { 216 - Self(CowStr::Owned(value.to_smolstr())) 217 - } 188 + Self::new_owned(value).unwrap() 218 189 } 219 190 } 220 191 221 - impl<'n> From<CowStr<'n>> for Nsid<'n> { 192 + impl<'n> From<CowStr<'n>> for Nsid<CowStr<'n>> { 222 193 fn from(value: CowStr<'n>) -> Self { 223 - if value.len() > 317 { 224 - panic!("NSID too long") 225 - } else if !NSID_REGEX.is_match(&value) { 226 - panic!("Invalid NSID") 227 - } else { 228 - Self(value) 229 - } 194 + Self::new_cow(value).unwrap() 230 195 } 231 196 } 232 197 233 - impl From<SmolStr> for Nsid<'_> { 198 + impl From<SmolStr> for Nsid { 234 199 fn from(value: SmolStr) -> Self { 235 - if value.len() > 317 { 236 - panic!("NSID too long") 237 - } else if !NSID_REGEX.is_match(&value) { 238 - panic!("Invalid NSID") 239 - } else { 240 - Self(CowStr::Owned(value)) 241 - } 200 + Self::new_owned(value).unwrap() 242 201 } 243 202 } 244 203 245 - impl AsRef<str> for Nsid<'_> { 204 + impl<S: Bos<str> + AsRef<str>> AsRef<str> for Nsid<S> { 246 205 fn as_ref(&self) -> &str { 247 206 self.as_str() 248 207 } 249 208 } 250 209 251 - impl Deref for Nsid<'_> { 210 + impl<S: Bos<str> + AsRef<str>> Deref for Nsid<S> { 252 211 type Target = str; 253 212 254 213 fn deref(&self) -> &Self::Target { ··· 256 215 } 257 216 } 258 217 259 - unsafe impl RecordKeyType for Nsid<'_> { 218 + unsafe impl<S: Bos<str> + AsRef<str> + Clone + Serialize> RecordKeyType for Nsid<S> { 260 219 fn as_str(&self) -> &str { 261 220 self.as_str() 262 221 } ··· 268 227 269 228 #[test] 270 229 fn valid_nsids() { 271 - assert!(Nsid::new("com.example.foo").is_ok()); 272 - assert!(Nsid::new("com.example.fooBar").is_ok()); 273 - assert!(Nsid::new("com.long-domain.foo").is_ok()); 274 - assert!(Nsid::new("a.b.c").is_ok()); 275 - assert!(Nsid::new("a1.b2.c3").is_ok()); 230 + assert!(Nsid::<&str>::new("com.example.foo").is_ok()); 231 + assert!(Nsid::<&str>::new("com.example.fooBar").is_ok()); 232 + assert!(Nsid::<&str>::new("com.long-domain.foo").is_ok()); 233 + assert!(Nsid::<&str>::new("a.b.c").is_ok()); 234 + assert!(Nsid::<&str>::new("a1.b2.c3").is_ok()); 276 235 } 277 236 278 237 #[test] 279 238 fn minimum_segments() { 280 - assert!(Nsid::new("a.b.c").is_ok()); // 3 segments minimum 281 - assert!(Nsid::new("a.b").is_err()); 282 - assert!(Nsid::new("a").is_err()); 239 + assert!(Nsid::<&str>::new("a.b.c").is_ok()); 240 + assert!(Nsid::<&str>::new("a.b").is_err()); 241 + assert!(Nsid::<&str>::new("a").is_err()); 283 242 } 284 243 285 244 #[test] 286 245 fn domain_and_name_parsing() { 287 - let nsid = Nsid::new("com.example.fooBar").unwrap(); 246 + let nsid = Nsid::<&str>::new("com.example.fooBar").unwrap(); 288 247 assert_eq!(nsid.domain_authority(), "com.example"); 289 248 assert_eq!(nsid.name(), "fooBar"); 290 249 } 291 250 292 251 #[test] 293 252 fn max_length() { 294 - // 317 chars: 63 + 63 + 63 + 63 + 63 = 315 + 4 dots + 1 = 320, too much 295 - // try: 63 + 63 + 63 + 63 + 62 = 314 + 4 dots = 318, still too much 296 - // try: 63 + 63 + 63 + 63 + 61 = 313 + 4 dots = 317 297 253 let s1 = format!("a{}a", "b".repeat(61)); 298 254 let s2 = format!("c{}c", "d".repeat(61)); 299 255 let s3 = format!("e{}e", "f".repeat(61)); 300 256 let s4 = format!("g{}g", "h".repeat(61)); 301 257 let s5 = format!("i{}i", "j".repeat(59)); 302 - let valid_317 = format!("{}.{}.{}.{}.{}", s1, s2, s3, s4, s5); 258 + let valid_317 = format!("{s1}.{s2}.{s3}.{s4}.{s5}"); 303 259 assert_eq!(valid_317.len(), 317); 304 - assert!(Nsid::new(&valid_317).is_ok()); 260 + assert!(Nsid::<&str>::new(&valid_317).is_ok()); 305 261 306 262 let s5_long = format!("i{}i", "j".repeat(60)); 307 - let too_long_318 = format!("{}.{}.{}.{}.{}", s1, s2, s3, s4, s5_long); 263 + let too_long_318 = format!("{s1}.{s2}.{s3}.{s4}.{s5_long}"); 308 264 assert_eq!(too_long_318.len(), 318); 309 - assert!(Nsid::new(&too_long_318).is_err()); 265 + assert!(Nsid::<&str>::new(&too_long_318).is_err()); 310 266 } 311 267 312 268 #[test] 313 269 fn segment_length() { 314 270 let valid_63 = format!("{}.{}.foo", "a".repeat(63), "b".repeat(63)); 315 - assert!(Nsid::new(&valid_63).is_ok()); 271 + assert!(Nsid::<&str>::new(&valid_63).is_ok()); 316 272 317 273 let too_long_64 = format!("{}.b.foo", "a".repeat(64)); 318 - assert!(Nsid::new(&too_long_64).is_err()); 274 + assert!(Nsid::<&str>::new(&too_long_64).is_err()); 319 275 } 320 276 321 277 #[test] 322 278 fn first_segment_cannot_start_with_digit() { 323 - assert!(Nsid::new("com.example.foo").is_ok()); 324 - assert!(Nsid::new("9com.example.foo").is_err()); 279 + assert!(Nsid::<&str>::new("com.example.foo").is_ok()); 280 + assert!(Nsid::<&str>::new("9com.example.foo").is_err()); 325 281 } 326 282 327 283 #[test] 328 284 fn name_segment_rules() { 329 - assert!(Nsid::new("com.example.foo").is_ok()); 330 - assert!(Nsid::new("com.example.fooBar123").is_ok()); 331 - assert!(Nsid::new("com.example.9foo").is_err()); // can't start with digit 332 - assert!(Nsid::new("com.example.foo-bar").is_err()); // no hyphens in name 285 + assert!(Nsid::<&str>::new("com.example.foo").is_ok()); 286 + assert!(Nsid::<&str>::new("com.example.fooBar123").is_ok()); 287 + assert!(Nsid::<&str>::new("com.example.9foo").is_err()); 288 + assert!(Nsid::<&str>::new("com.example.foo-bar").is_err()); 333 289 } 334 290 335 291 #[test] 336 292 fn domain_segment_rules() { 337 - assert!(Nsid::new("foo-bar.example.baz").is_ok()); 338 - assert!(Nsid::new("foo.bar-baz.qux").is_ok()); 339 - assert!(Nsid::new("-foo.bar.baz").is_err()); // can't start with hyphen 340 - assert!(Nsid::new("foo-.bar.baz").is_err()); // can't end with hyphen 293 + assert!(Nsid::<&str>::new("foo-bar.example.baz").is_ok()); 294 + assert!(Nsid::<&str>::new("foo.bar-baz.qux").is_ok()); 295 + assert!(Nsid::<&str>::new("-foo.bar.baz").is_err()); 296 + assert!(Nsid::<&str>::new("foo-.bar.baz").is_err()); 341 297 } 342 298 343 299 #[test] 344 300 fn case_sensitivity() { 345 - // Domain should be case-insensitive per spec (but not enforced in validation) 346 - // Name is case-sensitive 347 - assert!(Nsid::new("com.example.fooBar").is_ok()); 348 - assert!(Nsid::new("com.example.FooBar").is_ok()); 301 + assert!(Nsid::<&str>::new("com.example.fooBar").is_ok()); 302 + assert!(Nsid::<&str>::new("com.example.FooBar").is_ok()); 349 303 } 350 304 351 305 #[test] 352 - fn no_hyphens_in_name() { 353 - assert!(Nsid::new("com.example.foo").is_ok()); 354 - assert!(Nsid::new("com.example.foo-bar").is_err()); 355 - assert!(Nsid::new("com.example.fooBar").is_ok()); 306 + fn into_static() { 307 + let n = Nsid::<&str>::new("com.example.foo").unwrap(); 308 + let owned: Nsid<SmolStr> = n.into_static(); 309 + assert_eq!(owned.as_str(), "com.example.foo"); 356 310 } 357 311 }
+9 -8
crates/jacquard-common/src/types/string.rs
··· 26 26 } 27 27 } 28 28 29 + use crate::cowstr::ToCowStr; 29 30 pub use crate::{ 30 31 CowStr, 31 32 types::{ ··· 64 65 /// Timestamp identifier 65 66 Tid(Tid), 66 67 /// Namespaced identifier 67 - Nsid(Nsid<'s>), 68 + Nsid(Nsid<CowStr<'s>>), 68 69 /// Decentralized identifier 69 - Did(Did<'s>), 70 + Did(Did<CowStr<'s>>), 70 71 /// Account handle 71 - Handle(Handle<'s>), 72 + Handle(Handle<CowStr<'s>>), 72 73 /// Identifier (DID or handle) 73 - AtIdentifier(AtIdentifier<'s>), 74 + AtIdentifier(AtIdentifier<CowStr<'s>>), 74 75 /// AT URI 75 76 AtUri(AtUri<'s>), 76 77 /// Generic URI ··· 102 103 Self::Language(lang) 103 104 } else if let Ok(tid) = Tid::from_str(string) { 104 105 Self::Tid(tid) 105 - } else if let Ok(did) = Did::new(string) { 106 + } else if let Ok(did) = Did::new_cow(string.to_cowstr()) { 106 107 Self::Did(did) 107 - } else if let Ok(handle) = Handle::new(string) { 108 + } else if let Ok(handle) = Handle::new_cow(string.to_cowstr()) { 108 109 Self::Handle(handle) 109 - } else if let Ok(atid) = AtIdentifier::new(string) { 110 + } else if let Ok(atid) = AtIdentifier::new_cow(string.to_cowstr()) { 110 111 Self::AtIdentifier(atid) 111 - } else if let Ok(nsid) = Nsid::new(string) { 112 + } else if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { 112 113 Self::Nsid(nsid) 113 114 } else if let Ok(aturi) = AtUri::new(string) { 114 115 Self::AtUri(aturi)
+7 -4
crates/jacquard-common/src/types/uri.rs
··· 1 + use crate::cowstr::ToCowStr; 1 2 use crate::deps::fluent_uri::Uri; 2 3 use crate::{ 3 4 CowStr, IntoStatic, ··· 19 20 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 20 21 pub enum UriValue<'u> { 21 22 /// DID URI (did:) 22 - Did(Did<'u>), 23 + Did(Did<CowStr<'u>>), 23 24 /// AT Protocol URI (at://) 24 25 At(AtUri<'u>), 25 26 /// HTTPS URL ··· 51 52 /// Parse a URI from a string slice, borrowing 52 53 pub fn new(uri: &'u str) -> Result<Self, UriParseError> { 53 54 if uri.starts_with("did:") { 54 - Ok(UriValue::Did(Did::new(uri)?)) 55 + Ok(UriValue::Did(Did::new_cow(uri.to_cowstr())?)) 55 56 } else if uri.starts_with("at://") { 56 57 Ok(UriValue::At(AtUri::new(uri)?)) 57 58 } else if uri.starts_with("https://") { ··· 189 190 } 190 191 Err(UriError::CollectionMismatch { 191 192 expected: R::NSID, 192 - found: uri.collection().map(|c| c.clone().into_static()), 193 + found: uri 194 + .collection() 195 + .map(|c| Nsid::new_owned(c.as_str()).unwrap()), 193 196 }) 194 197 } 195 198 ··· 234 237 /// The collection of the record 235 238 expected: &'static str, 236 239 /// What the at-uri had 237 - found: Option<Nsid<'static>>, 240 + found: Option<Nsid>, 238 241 }, 239 242 /// Couldn't parse the string as an AtUri 240 243 #[error("Invalid URI: {0}")]
+11 -10
crates/jacquard-common/src/types/value/parsing.rs
··· 1 + use crate::cowstr::ToCowStr; 1 2 use crate::deps::fluent_uri::Uri; 2 3 use crate::{ 3 4 IntoStatic, ··· 51 52 } 52 53 } 53 54 LexiconStringType::Did => { 54 - if let Ok(value) = Did::new(value) { 55 + if let Ok(value) = Did::new_cow(value.to_cowstr()) { 55 56 map.insert(key.to_smolstr(), Data::String(AtprotoStr::Did(value))); 56 57 } else { 57 58 map.insert( ··· 61 62 } 62 63 } 63 64 LexiconStringType::Handle => { 64 - if let Ok(value) = Handle::new(value) { 65 + if let Ok(value) = Handle::new_cow(value.into()) { 65 66 map.insert(key.to_smolstr(), Data::String(AtprotoStr::Handle(value))); 66 67 } else { 67 68 map.insert( ··· 71 72 } 72 73 } 73 74 LexiconStringType::AtIdentifier => { 74 - if let Ok(value) = AtIdentifier::new(value) { 75 + if let Ok(value) = AtIdentifier::new_cow(value.to_cowstr()) { 75 76 map.insert( 76 77 key.to_smolstr(), 77 78 Data::String(AtprotoStr::AtIdentifier(value)), ··· 84 85 } 85 86 } 86 87 LexiconStringType::Nsid => { 87 - if let Ok(value) = Nsid::new(value) { 88 + if let Ok(value) = Nsid::new_cow(value.to_cowstr()) { 88 89 map.insert(key.to_smolstr(), Data::String(AtprotoStr::Nsid(value))); 89 90 } else { 90 91 map.insert( ··· 156 157 /// smarter parsing to avoid trying as many posibilities. 157 158 pub fn parse_string<'s>(string: &'s str) -> AtprotoStr<'s> { 158 159 if string.len() < 2048 && string.starts_with("did:") { 159 - if let Ok(did) = Did::new(string) { 160 + if let Ok(did) = Did::new_cow(string.to_cowstr()) { 160 161 return AtprotoStr::Did(did); 161 162 } 162 163 } else if string.starts_with("20") && string.ends_with("Z") { ··· 193 194 194 195 // First segment is a known TLD → reverse domain order → try NSID first. 195 196 if first_is_tld { 196 - if let Ok(nsid) = Nsid::new(string) { 197 + if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { 197 198 return AtprotoStr::Nsid(nsid); 198 199 } 199 200 } 200 201 201 202 // Last segment is a known TLD and first is not → normal domain order → handle. 202 203 if last_is_tld && !first_is_tld { 203 - if let Ok(handle) = AtIdentifier::new(string) { 204 + if let Ok(handle) = AtIdentifier::new_cow(string.to_cowstr()) { 204 205 return AtprotoStr::AtIdentifier(handle); 205 206 } 206 207 } 207 208 208 209 // camelCase in last segment → NSID (e.g., "com.atproto.repo.getRecord"). 209 210 if has_upper_last_segment { 210 - if let Ok(nsid) = Nsid::new(string) { 211 + if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { 211 212 return AtprotoStr::Nsid(nsid); 212 213 } 213 214 } 214 215 215 216 // Fallback: try both, preferring handle. 216 - if let Ok(handle) = AtIdentifier::new(string) { 217 + if let Ok(handle) = AtIdentifier::new_cow(string.to_cowstr()) { 217 218 return AtprotoStr::AtIdentifier(handle); 218 - } else if let Ok(nsid) = Nsid::new(string) { 219 + } else if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { 219 220 return AtprotoStr::Nsid(nsid); 220 221 } else if string.contains("://") && Uri::<&str>::parse(string).is_ok() { 221 222 return AtprotoStr::Uri(UriValue::Any(string.into()));
+18 -16
crates/jacquard-common/src/types/value/tests.rs
··· 468 468 #[derive(Debug, Deserialize)] 469 469 struct AtprotoTypes<'a> { 470 470 #[serde(borrow)] 471 - did: Did<'a>, 472 - handle: Handle<'a>, 471 + did: Did<CowStr<'a>>, 472 + handle: Handle<CowStr<'a>>, 473 473 cid: Cid<'a>, 474 474 } 475 475 476 476 let mut map = BTreeMap::new(); 477 477 map.insert( 478 478 SmolStr::new_static("did"), 479 - Data::String(AtprotoStr::Did(Did::new("did:plc:abc123").unwrap())), 479 + Data::String(AtprotoStr::Did(Did::new_owned("did:plc:abc123").unwrap())), 480 480 ); 481 481 map.insert( 482 482 SmolStr::new_static("handle"), 483 483 Data::String(AtprotoStr::Handle( 484 - Handle::new("alice.bsky.social").unwrap(), 484 + Handle::new_static("alice.bsky.social").unwrap(), 485 485 )), 486 486 ); 487 487 map.insert( ··· 508 508 #[derive(Debug, Deserialize)] 509 509 struct MixedTypes<'a> { 510 510 #[serde(borrow)] 511 - nsid: Nsid<'a>, 512 - handle: Handle<'a>, 513 - did: Did<'a>, 511 + nsid: Nsid<CowStr<'a>>, 512 + handle: Handle<CowStr<'a>>, 513 + did: Did<CowStr<'a>>, 514 514 // These use SmolStr internally, so they allocate but still deserialize fine 515 515 tid: Tid, 516 516 created_at: Datetime, ··· 519 519 let mut map = BTreeMap::new(); 520 520 map.insert( 521 521 SmolStr::new_static("nsid"), 522 - Data::String(AtprotoStr::Nsid(Nsid::new("app.bsky.feed.post").unwrap())), 522 + Data::String(AtprotoStr::Nsid( 523 + Nsid::new_static("app.bsky.feed.post").unwrap(), 524 + )), 523 525 ); 524 526 map.insert( 525 527 SmolStr::new_static("handle"), 526 528 Data::String(AtprotoStr::Handle( 527 - Handle::new("alice.bsky.social").unwrap(), 529 + Handle::new_static("alice.bsky.social").unwrap(), 528 530 )), 529 531 ); 530 532 map.insert( 531 533 SmolStr::new_static("did"), 532 - Data::String(AtprotoStr::Did(Did::new("did:plc:test123").unwrap())), 534 + Data::String(AtprotoStr::Did(Did::new_owned("did:plc:test123").unwrap())), 533 535 ); 534 536 map.insert( 535 537 SmolStr::new_static("tid"), ··· 559 561 struct WithAtUri<'a> { 560 562 #[serde(borrow)] 561 563 uri: AtUri<'a>, 562 - did: Did<'a>, 564 + did: Did<CowStr<'a>>, 563 565 } 564 566 565 567 let mut map = BTreeMap::new(); ··· 571 573 ); 572 574 map.insert( 573 575 SmolStr::new_static("did"), 574 - Data::String(AtprotoStr::Did(Did::new("did:plc:test").unwrap())), 576 + Data::String(AtprotoStr::Did(Did::new_owned("did:plc:test").unwrap())), 575 577 ); 576 578 let data = Data::Object(Object(map)); 577 579 ··· 615 617 #[derive(Debug, Deserialize)] 616 618 struct WithIdentifiers<'a> { 617 619 #[serde(borrow)] 618 - ident_did: AtIdentifier<'a>, 619 - ident_handle: AtIdentifier<'a>, 620 + ident_did: AtIdentifier<CowStr<'a>>, 621 + ident_handle: AtIdentifier<CowStr<'a>>, 620 622 } 621 623 622 624 let mut map = BTreeMap::new(); 623 625 map.insert( 624 626 SmolStr::new_static("ident_did"), 625 627 Data::String(AtprotoStr::AtIdentifier(AtIdentifier::Did( 626 - Did::new("did:plc:abc").unwrap(), 628 + Did::new_owned("did:plc:abc").unwrap(), 627 629 ))), 628 630 ); 629 631 map.insert( 630 632 SmolStr::new_static("ident_handle"), 631 633 Data::String(AtprotoStr::AtIdentifier(AtIdentifier::Handle( 632 - Handle::new("bob.test").unwrap(), 634 + Handle::new_static("bob.test").unwrap(), 633 635 ))), 634 636 ); 635 637 let data = Data::Object(Object(map));
+50 -34
crates/jacquard-common/src/xrpc/atproto.rs
··· 5 5 //! implementations sufficient for bootstrap code generation without builders or 6 6 //! validation helpers. 7 7 //! 8 + use crate::Bos; 8 9 use crate::CowStr; 10 + use crate::DefaultStr; 9 11 use crate::IntoStatic; 10 12 use crate::types::ident::AtIdentifier; 11 13 use crate::types::string::{AtUri, Cid, Did, Handle, Nsid}; ··· 15 17 use alloc::vec::Vec; 16 18 use core::error::Error; 17 19 use core::fmt::{self, Display}; 20 + use core::marker::PhantomData; 18 21 use serde::{Deserialize, Serialize}; 22 + use smol_str::SmolStr; 19 23 20 24 // ============================================================================ 21 25 // com.atproto.repo.listRecords ··· 27 31 #[allow(missing_docs)] 28 32 pub struct ListRecords<'a> { 29 33 #[serde(borrow)] 30 - pub collection: Nsid<'a>, 34 + pub collection: Nsid<CowStr<'a>>, 31 35 #[serde(skip_serializing_if = "Option::is_none")] 32 36 #[serde(borrow)] 33 37 pub cursor: Option<CowStr<'a>>, 34 38 #[serde(skip_serializing_if = "Option::is_none")] 35 39 pub limit: Option<i64>, 36 40 #[serde(borrow)] 37 - pub repo: AtIdentifier<'a>, 41 + pub repo: AtIdentifier<CowStr<'a>>, 38 42 #[serde(skip_serializing_if = "Option::is_none")] 39 43 pub reverse: Option<bool>, 40 44 } ··· 131 135 #[serde(borrow)] 132 136 pub cid: Option<Cid<'a>>, 133 137 #[serde(borrow)] 134 - pub collection: Nsid<'a>, 138 + pub collection: Nsid<CowStr<'a>>, 135 139 #[serde(borrow)] 136 - pub repo: AtIdentifier<'a>, 140 + pub repo: AtIdentifier<CowStr<'a>>, 137 141 #[serde(borrow)] 138 142 pub rkey: CowStr<'a>, 139 143 } ··· 237 241 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 238 242 #[serde(rename_all = "camelCase")] 239 243 #[allow(missing_docs)] 240 - pub struct ResolveHandle<'a> { 241 - #[serde(borrow)] 242 - pub handle: Handle<'a>, 244 + pub struct ResolveHandle<S: Bos<str> + AsRef<str> = DefaultStr> { 245 + pub handle: Handle<S>, 243 246 } 244 247 245 - impl IntoStatic for ResolveHandle<'_> { 246 - type Output = ResolveHandle<'static>; 248 + impl<S> IntoStatic for ResolveHandle<S> 249 + where 250 + S: Bos<str> + AsRef<str> + IntoStatic, 251 + <S as IntoStatic>::Output: Bos<str>, 252 + <S as IntoStatic>::Output: AsRef<str>, 253 + { 254 + type Output = ResolveHandle<<S as IntoStatic>::Output>; 247 255 248 256 fn into_static(self) -> Self::Output { 249 257 ResolveHandle { ··· 256 264 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 257 265 #[serde(rename_all = "camelCase")] 258 266 #[allow(missing_docs)] 259 - pub struct ResolveHandleOutput<'a> { 260 - #[serde(borrow)] 261 - pub did: Did<'a>, 267 + pub struct ResolveHandleOutput<S: Bos<str> + AsRef<str> = DefaultStr> { 268 + pub did: Did<S>, 262 269 } 263 270 264 - impl IntoStatic for ResolveHandleOutput<'_> { 265 - type Output = ResolveHandleOutput<'static>; 271 + impl<S> IntoStatic for ResolveHandleOutput<S> 272 + where 273 + S: Bos<str> + AsRef<str> + IntoStatic, 274 + <S as IntoStatic>::Output: Bos<str>, 275 + <S as IntoStatic>::Output: AsRef<str>, 276 + { 277 + type Output = ResolveHandleOutput<<S as IntoStatic>::Output>; 266 278 267 279 fn into_static(self) -> Self::Output { 268 280 ResolveHandleOutput { ··· 313 325 impl XrpcResp for ResolveHandleResponse { 314 326 const NSID: &'static str = "com.atproto.identity.resolveHandle"; 315 327 const ENCODING: &'static str = "application/json"; 316 - type Output<'de> = ResolveHandleOutput<'de>; 328 + type Output<'de> = ResolveHandleOutput; 317 329 type Err<'de> = ResolveHandleError<'de>; 318 330 } 319 331 320 - impl<'a> XrpcRequest for ResolveHandle<'a> { 332 + impl<S: Bos<str> + AsRef<str> + Serialize> XrpcRequest for ResolveHandle<S> { 321 333 const NSID: &'static str = "com.atproto.identity.resolveHandle"; 322 334 const METHOD: XrpcMethod = XrpcMethod::Query; 323 335 type Response = ResolveHandleResponse; ··· 331 343 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 332 344 #[serde(rename_all = "camelCase")] 333 345 #[allow(missing_docs)] 334 - pub struct ResolveDid<'a> { 335 - #[serde(borrow)] 336 - pub did: Did<'a>, 346 + pub struct ResolveDid<S: Bos<str> + AsRef<str> = DefaultStr> { 347 + pub did: Did<S>, 337 348 } 338 349 339 - impl IntoStatic for ResolveDid<'_> { 340 - type Output = ResolveDid<'static>; 350 + impl<S> IntoStatic for ResolveDid<S> 351 + where 352 + S: Bos<str> + AsRef<str> + IntoStatic, 353 + <S as IntoStatic>::Output: Bos<str>, 354 + <S as IntoStatic>::Output: AsRef<str>, 355 + { 356 + type Output = ResolveDid<<S as IntoStatic>::Output>; 341 357 342 358 fn into_static(self) -> Self::Output { 343 359 ResolveDid { ··· 421 437 type Err<'de> = ResolveDidError<'de>; 422 438 } 423 439 424 - impl<'a> XrpcRequest for ResolveDid<'a> { 440 + impl<S> XrpcRequest for ResolveDid<S> 441 + where 442 + S: Bos<str> + AsRef<str> + Serialize, 443 + { 425 444 const NSID: &'static str = "com.atproto.identity.resolveDid"; 426 445 const METHOD: XrpcMethod = XrpcMethod::Query; 427 446 type Response = ResolveDidResponse; ··· 439 458 #[test] 440 459 fn test_list_records_serializes() { 441 460 let req = ListRecords { 442 - repo: AtIdentifier::new("test.bsky.social") 461 + repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), 462 + collection: Nsid::new_cow("app.bsky.feed.post".into()) 443 463 .unwrap() 444 - .into_static() 445 - .into(), 446 - collection: Nsid::new("app.bsky.feed.post").unwrap().into_static(), 464 + .into_static(), 447 465 cursor: None, 448 466 limit: Some(50), 449 467 reverse: None, ··· 534 552 #[test] 535 553 fn test_types_implement_into_static() { 536 554 let list_records = ListRecords { 537 - repo: AtIdentifier::new("test.bsky.social") 555 + repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), 556 + collection: Nsid::new_cow("app.bsky.feed.post".into()) 538 557 .unwrap() 539 - .into_static() 540 - .into(), 541 - collection: Nsid::new("app.bsky.feed.post").unwrap().into_static(), 558 + .into_static(), 542 559 cursor: None, 543 560 limit: Some(50), 544 561 reverse: None, ··· 546 563 let _static = list_records.into_static(); 547 564 548 565 let get_record = GetRecord { 549 - repo: AtIdentifier::new("test.bsky.social") 566 + repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), 567 + collection: Nsid::new_cow("app.bsky.feed.post".into()) 550 568 .unwrap() 551 - .into_static() 552 - .into(), 553 - collection: Nsid::new("app.bsky.feed.post").unwrap().into_static(), 569 + .into_static(), 554 570 rkey: CowStr::from("abc123").into_static(), 555 571 cid: None, 556 572 };
+4 -4
crates/jacquard-common/src/xrpc/dyn_req.rs
··· 1 1 pub trait DynXrpcRequest { 2 - fn nsid(&self) -> Nsid<'static>; 2 + fn nsid(&self) -> Nsid; 3 3 fn method(&self) -> XrpcMethod; 4 4 fn response_type(&self) -> &'static str; 5 5 fn encode_body(&self) -> Result<Vec<u8>, EncodeError>; 6 6 } 7 7 8 8 pub trait DynXrpcResp { 9 - fn nsid(&self) -> Nsid<'static>; 9 + fn nsid(&self) -> Nsid; 10 10 fn encoding(&self) -> &'static str; 11 11 fn decode_output(&self, body: &[u8]) -> Result<Data<'_>, DecodeError>; 12 12 } ··· 15 15 where 16 16 XRPC: XrpcRequest, 17 17 { 18 - fn nsid(&self) -> Nsid<'static> { 18 + fn nsid(&self) -> Nsid { 19 19 unsafe { Nsid::new_static(XRPC::NSID).unwrap_unchecked() } 20 20 } 21 21 ··· 36 36 where 37 37 XRPC: XrpcResp, 38 38 { 39 - fn nsid(&self) -> Nsid<'static> { 39 + fn nsid(&self) -> Nsid { 40 40 unsafe { Nsid::new_static(XRPC::NSID).unwrap_unchecked() } 41 41 } 42 42