docs pass.

claude may have helped some. i made them read the specs first.

Orual 30ecb131 42f14e72

+10 -4
crates/jacquard-common/src/cowstr.rs
··· 17 /// `<str as ToOwned>::Owned` is `String`, and not `SmolStr`. 18 #[derive(Clone)] 19 pub enum CowStr<'s> { 20 Borrowed(&'s str), 21 Owned(SmolStr), 22 } 23 24 impl CowStr<'static> { 25 /// Create a new `CowStr` by copying from a `&str` — this might allocate 26 - /// if the `compact_str` feature is disabled, or if the string is longer 27 - /// than `MAX_INLINE_SIZE`. 28 pub fn copy_from_str(s: &str) -> Self { 29 Self::Owned(SmolStr::from(s)) 30 } 31 32 pub fn new_static(s: &'static str) -> Self { 33 Self::Owned(SmolStr::new_static(s)) 34 } ··· 36 37 impl<'s> CowStr<'s> { 38 #[inline] 39 pub fn from_utf8(s: &'s [u8]) -> Result<Self, std::str::Utf8Error> { 40 Ok(Self::Borrowed(std::str::from_utf8(s)?)) 41 } 42 43 #[inline] 44 - pub fn from_utf8_owned(s: Vec<u8>) -> Result<Self, std::str::Utf8Error> { 45 - Ok(Self::Owned(SmolStr::new(std::str::from_utf8(&s)?))) 46 } 47 48 #[inline] 49 pub fn from_utf8_lossy(s: &'s [u8]) -> Self { 50 Self::Owned(String::from_utf8_lossy(&s).into()) 51 }
··· 17 /// `<str as ToOwned>::Owned` is `String`, and not `SmolStr`. 18 #[derive(Clone)] 19 pub enum CowStr<'s> { 20 + /// &str varaiant 21 Borrowed(&'s str), 22 + /// Smolstr variant 23 Owned(SmolStr), 24 } 25 26 impl CowStr<'static> { 27 /// Create a new `CowStr` by copying from a `&str` — this might allocate 28 + /// if the string is longer than `MAX_INLINE_SIZE`. 29 pub fn copy_from_str(s: &str) -> Self { 30 Self::Owned(SmolStr::from(s)) 31 } 32 33 + /// Create a new owned `CowStr` from a static &str without allocating 34 pub fn new_static(s: &'static str) -> Self { 35 Self::Owned(SmolStr::new_static(s)) 36 } ··· 38 39 impl<'s> CowStr<'s> { 40 #[inline] 41 + /// Borrow and decode a byte slice as utf8 into a CowStr 42 pub fn from_utf8(s: &'s [u8]) -> Result<Self, std::str::Utf8Error> { 43 Ok(Self::Borrowed(std::str::from_utf8(s)?)) 44 } 45 46 #[inline] 47 + /// Take bytes and decode them as utf8 into an owned CowStr. Might allocate. 48 + pub fn from_utf8_owned(s: impl AsRef<[u8]>) -> Result<Self, std::str::Utf8Error> { 49 + Ok(Self::Owned(SmolStr::new(std::str::from_utf8(&s.as_ref())?))) 50 } 51 52 #[inline] 53 + /// Take bytes and decode them as utf8, skipping invalid characters, taking ownership. 54 + /// Will allocate, uses String::from_utf8_lossy() internally for now. 55 pub fn from_utf8_lossy(s: &'s [u8]) -> Self { 56 Self::Owned(String::from_utf8_lossy(&s).into()) 57 }
+9
crates/jacquard-common/src/lib.rs
··· 1 #[macro_use] 2 pub mod cowstr; 3 #[macro_use] 4 pub mod into_static; 5 pub mod macros; 6 pub mod types; 7 8 pub use cowstr::CowStr;
··· 1 + //! Common types for the jacquard implementation of atproto 2 + 3 + #![warn(missing_docs)] 4 + 5 + /// A copy-on-write immutable string type that uses [`SmolStr`] for 6 + /// the "owned" variant. 7 #[macro_use] 8 pub mod cowstr; 9 #[macro_use] 10 + /// trait for taking ownership of most borrowed types in jacquard. 11 pub mod into_static; 12 + /// Helper macros for common patterns 13 pub mod macros; 14 + /// Baseline fundamental AT Protocol data types. 15 pub mod types; 16 17 pub use cowstr::CowStr;
+51 -16
crates/jacquard-common/src/types.rs
··· 1 use serde::{Deserialize, Serialize}; 2 3 pub mod aturi; 4 pub mod blob; 5 pub mod cid; 6 pub mod collection; 7 pub mod datetime; 8 pub mod did; 9 pub mod handle; 10 pub mod ident; 11 pub mod integer; 12 pub mod language; 13 pub mod link; 14 pub mod nsid; 15 pub mod recordkey; 16 pub mod string; 17 pub mod tid; 18 pub mod uri; 19 pub mod value; 20 pub mod xrpc; 21 22 /// Trait for a constant string literal type ··· 25 const LITERAL: &'static str; 26 } 27 28 pub const DISALLOWED_TLDS: &[&str] = &[ 29 ".local", 30 ".arpa", ··· 39 // "should" "never" actually resolve and get registered in production 40 ]; 41 42 pub fn ends_with(string: impl AsRef<str>, list: &[&str]) -> bool { 43 let string = string.as_ref(); 44 for item in list { ··· 51 52 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] 53 #[serde(rename_all = "kebab-case")] 54 pub enum DataModelType { 55 Null, 56 Boolean, 57 Integer, 58 Bytes, 59 CidLink, 60 Blob, 61 Array, 62 Object, 63 #[serde(untagged)] 64 String(LexiconStringType), 65 } 66 67 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] 68 - #[serde(rename_all = "kebab-case")] 69 - pub enum LexiconType { 70 - Params, 71 - Token, 72 - Ref, 73 - Union, 74 - Unknown, 75 - Record, 76 - Query, 77 - Procedure, 78 - Subscription, 79 - #[serde(untagged)] 80 - DataModel(DataModelType), 81 - } 82 - 83 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] 84 #[serde(rename_all = "kebab-case")] 85 pub enum LexiconStringType { 86 Datetime, 87 AtUri, 88 Did, 89 Handle, 90 AtIdentifier, 91 Nsid, 92 Cid, 93 Language, 94 Tid, 95 RecordKey, 96 Uri(UriType), 97 #[serde(untagged)] 98 String, 99 } 100 101 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 102 #[serde(tag = "type")] 103 pub enum UriType { 104 Did, 105 At, 106 Https, 107 Wss, 108 Cid, 109 Dns, 110 Any, 111 }
··· 1 use serde::{Deserialize, Serialize}; 2 3 + /// AT Protocol URI (at://) types and validation 4 pub mod aturi; 5 + /// Blob references for binary data 6 pub mod blob; 7 + /// Content Identifier (CID) types for IPLD 8 pub mod cid; 9 + /// Repository collection trait for records 10 pub mod collection; 11 + /// AT Protocol datetime string type 12 pub mod datetime; 13 + /// Decentralized Identifier (DID) types and validation 14 pub mod did; 15 + /// AT Protocol handle types and validation 16 pub mod handle; 17 + /// AT Protocol identifier types (handle or DID) 18 pub mod ident; 19 + /// Integer type with validation 20 pub mod integer; 21 + /// Language tag types per BCP 47 22 pub mod language; 23 + /// CID link wrapper for JSON serialization 24 pub mod link; 25 + /// Namespaced Identifier (NSID) types and validation 26 pub mod nsid; 27 + /// Record key types and validation 28 pub mod recordkey; 29 + /// String types with format validation 30 pub mod string; 31 + /// Timestamp Identifier (TID) types and generation 32 pub mod tid; 33 + /// URI types with scheme validation 34 pub mod uri; 35 + /// Generic data value types for lexicon data model 36 pub mod value; 37 + /// XRPC protocol types and traits 38 pub mod xrpc; 39 40 /// Trait for a constant string literal type ··· 43 const LITERAL: &'static str; 44 } 45 46 + /// top-level domains which are not allowed in at:// handles or dids 47 pub const DISALLOWED_TLDS: &[&str] = &[ 48 ".local", 49 ".arpa", ··· 58 // "should" "never" actually resolve and get registered in production 59 ]; 60 61 + /// checks if a string ends with anything from the provided list of strings. 62 pub fn ends_with(string: impl AsRef<str>, list: &[&str]) -> bool { 63 let string = string.as_ref(); 64 for item in list { ··· 71 72 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] 73 #[serde(rename_all = "kebab-case")] 74 + /// Valid types in the AT protocol [data model](https://atproto.com/specs/data-model). Type marker only, used in concert with `[Data<'_>]`. 75 pub enum DataModelType { 76 + /// Null type. IPLD type `null`, JSON type `Null`, CBOR Special Value (major 7) 77 Null, 78 + /// Boolean type. IPLD type `boolean`, JSON type Boolean, CBOR Special Value (major 7) 79 Boolean, 80 + /// Integer type. IPLD type `integer`, JSON type Number, CBOR Special Value (major 7) 81 Integer, 82 + /// Byte type. IPLD type `bytes`, in JSON a `{ "$bytes": bytes }` Object, CBOR Byte String (major 2) 83 Bytes, 84 + /// CID (content identifier) link. IPLD type `link`, in JSON a `{ "$link": cid }` Object, CBOR CID (tag 42) 85 CidLink, 86 + /// Blob type. No special IPLD type. in JSON a `{ "$type": "blob" }` Object. in CBOR a `{ "$type": "blob" }` Map. 87 Blob, 88 + /// Array type. IPLD type `list`. JSON type `Array`, CBOR type Array (major 4) 89 Array, 90 + /// Object type. IPLD type `map`. JSON type `Object`, CBOR type Map (major 5). keys are always SmolStr. 91 Object, 92 #[serde(untagged)] 93 + /// String type (lots of variants). JSON String, CBOR UTF-8 String (major 3) 94 String(LexiconStringType), 95 } 96 97 + /// Lexicon string format types for typed strings in the AT Protocol data model 98 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] 99 #[serde(rename_all = "kebab-case")] 100 pub enum LexiconStringType { 101 + /// ISO 8601 datetime string 102 Datetime, 103 + /// AT Protocol URI (at://) 104 AtUri, 105 + /// Decentralized Identifier 106 Did, 107 + /// AT Protocol handle 108 Handle, 109 + /// Handle or DID 110 AtIdentifier, 111 + /// Namespaced Identifier 112 Nsid, 113 + /// Content Identifier 114 Cid, 115 + /// BCP 47 language tag 116 Language, 117 + /// Timestamp Identifier 118 Tid, 119 + /// Record key 120 RecordKey, 121 + /// URI with type constraint 122 Uri(UriType), 123 + /// Plain string 124 #[serde(untagged)] 125 String, 126 } 127 128 + /// URI scheme types for lexicon URI format constraints 129 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 130 #[serde(tag = "type")] 131 pub enum UriType { 132 + /// DID URI (did:) 133 Did, 134 + /// AT Protocol URI (at://) 135 At, 136 + /// HTTPS URI 137 Https, 138 + /// WebSocket Secure URI 139 Wss, 140 + /// CID URI 141 Cid, 142 + /// DNS name 143 Dns, 144 + /// Any valid URI 145 Any, 146 }
+31 -4
crates/jacquard-common/src/types/aturi.rs
··· 12 use std::sync::LazyLock; 13 use std::{ops::Deref, str::FromStr}; 14 15 - /// at:// URI type 16 /// 17 - /// based on the regex here: [](https://github.com/bluesky-social/atproto/blob/main/packages/syntax/src/aturi_validation.ts) 18 /// 19 - /// Doesn't support the query segment, but then neither does the Typescript SDK. 20 #[derive(PartialEq, Eq, Debug)] 21 pub struct AtUri<'u> { 22 inner: Inner<'u>, ··· 81 } 82 } 83 84 - /// at:// URI path component (current subset) 85 #[derive(Clone, PartialEq, Eq, Hash, Debug)] 86 pub struct RepoPath<'u> { 87 pub collection: Nsid<'u>, 88 pub rkey: Option<RecordKey<Rkey<'u>>>, 89 } 90 ··· 99 } 100 } 101 102 pub type UriPathBuf = RepoPath<'static>; 103 104 pub static ATURI_REGEX: LazyLock<Regex> = LazyLock::new(|| { 105 // Fragment allows: / and \ and other special chars. In raw string, backslashes are literal. 106 Regex::new(r##"^at://(?<authority>[a-zA-Z0-9._:%-]+)(/(?<collection>[a-zA-Z0-9-.]+)(/(?<rkey>[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?<fragment>/[a-zA-Z0-9._~:@!$&%')(*+,;=\-\[\]/\\]*))?$"##).unwrap() ··· 154 } 155 } 156 157 pub fn raw(uri: &'u str) -> Self { 158 if let Some(parts) = ATURI_REGEX.captures(uri) { 159 if let Some(authority) = parts.name("authority") { ··· 275 }) 276 } 277 278 pub fn as_str(&self) -> &str { 279 { 280 let this = &self.inner.borrow_uri(); ··· 282 } 283 } 284 285 pub fn authority(&self) -> &AtIdentifier<'_> { 286 self.inner.borrow_authority() 287 } 288 289 pub fn path(&self) -> &Option<RepoPath<'_>> { 290 self.inner.borrow_path() 291 } 292 293 pub fn fragment(&self) -> &Option<CowStr<'_>> { 294 self.inner.borrow_fragment() 295 } 296 297 pub fn collection(&self) -> Option<&Nsid<'_>> { 298 self.inner.borrow_path().as_ref().map(|p| &p.collection) 299 } 300 301 pub fn rkey(&self) -> Option<&RecordKey<Rkey<'_>>> { 302 self.inner 303 .borrow_path() ··· 400 } 401 } 402 403 pub fn new_static(uri: &'static str) -> Result<Self, AtStrError> { 404 let uri = uri.as_ref(); 405 if let Some(parts) = ATURI_REGEX.captures(uri) {
··· 12 use std::sync::LazyLock; 13 use std::{ops::Deref, str::FromStr}; 14 15 + /// AT Protocol URI (`at://`) for referencing records in repositories 16 + /// 17 + /// AT URIs provide a way to reference records using either a DID or handle as the authority. 18 + /// They're not content-addressed, so the record's contents can change over time. 19 + /// 20 + /// Format: `at://AUTHORITY[/COLLECTION[/RKEY]][#FRAGMENT]` 21 + /// - Authority: DID or handle identifying the repository (required) 22 + /// - Collection: NSID of the record type (optional) 23 + /// - Record key (rkey): specific record identifier (optional) 24 + /// - Fragment: sub-resource identifier (optional, limited support) 25 /// 26 + /// Examples: 27 + /// - `at://alice.bsky.social` 28 + /// - `at://did:plc:abc123/app.bsky.feed.post/3jk5` 29 /// 30 + /// See: <https://atproto.com/specs/at-uri-scheme> 31 #[derive(PartialEq, Eq, Debug)] 32 pub struct AtUri<'u> { 33 inner: Inner<'u>, ··· 92 } 93 } 94 95 + /// Path component of an AT URI (collection and optional record key) 96 + /// 97 + /// Represents the `/COLLECTION[/RKEY]` portion of an AT URI. 98 #[derive(Clone, PartialEq, Eq, Hash, Debug)] 99 pub struct RepoPath<'u> { 100 + /// Collection NSID (e.g., `app.bsky.feed.post`) 101 pub collection: Nsid<'u>, 102 + /// Optional record key identifying a specific record 103 pub rkey: Option<RecordKey<Rkey<'u>>>, 104 } 105 ··· 114 } 115 } 116 117 + /// Owned (static lifetime) version of `RepoPath` 118 pub type UriPathBuf = RepoPath<'static>; 119 120 + /// Regex for AT URI validation per AT Protocol spec 121 pub static ATURI_REGEX: LazyLock<Regex> = LazyLock::new(|| { 122 // Fragment allows: / and \ and other special chars. In raw string, backslashes are literal. 123 Regex::new(r##"^at://(?<authority>[a-zA-Z0-9._:%-]+)(/(?<collection>[a-zA-Z0-9-.]+)(/(?<rkey>[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?<fragment>/[a-zA-Z0-9._~:@!$&%')(*+,;=\-\[\]/\\]*))?$"##).unwrap() ··· 171 } 172 } 173 174 + /// Infallible constructor for when you know the URI is valid 175 + /// 176 + /// Panics on invalid URIs. Use this when manually constructing URIs from trusted sources. 177 pub fn raw(uri: &'u str) -> Self { 178 if let Some(parts) = ATURI_REGEX.captures(uri) { 179 if let Some(authority) = parts.name("authority") { ··· 295 }) 296 } 297 298 + /// Get the full URI as a string slice 299 pub fn as_str(&self) -> &str { 300 { 301 let this = &self.inner.borrow_uri(); ··· 303 } 304 } 305 306 + /// Get the authority component (DID or handle) 307 pub fn authority(&self) -> &AtIdentifier<'_> { 308 self.inner.borrow_authority() 309 } 310 311 + /// Get the path component (collection and optional rkey) 312 pub fn path(&self) -> &Option<RepoPath<'_>> { 313 self.inner.borrow_path() 314 } 315 316 + /// Get the fragment component if present 317 pub fn fragment(&self) -> &Option<CowStr<'_>> { 318 self.inner.borrow_fragment() 319 } 320 321 + /// Get the collection NSID from the path, if present 322 pub fn collection(&self) -> Option<&Nsid<'_>> { 323 self.inner.borrow_path().as_ref().map(|p| &p.collection) 324 } 325 326 + /// Get the record key from the path, if present 327 pub fn rkey(&self) -> Option<&RecordKey<Rkey<'_>>> { 328 self.inner 329 .borrow_path() ··· 426 } 427 } 428 429 + /// Fallible constructor, validates, doesn't allocate (static lifetime) 430 pub fn new_static(uri: &'static str) -> Result<Self, AtStrError> { 431 let uri = uri.as_ref(); 432 if let Some(parts) = ATURI_REGEX.captures(uri) {
+25 -7
crates/jacquard-common/src/types/blob.rs
··· 12 str::FromStr, 13 }; 14 15 #[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] 16 #[serde(rename_all = "camelCase")] 17 pub struct Blob<'b> { 18 pub r#ref: Cid<'b>, 19 #[serde(borrow)] 20 pub mime_type: MimeType<'b>, 21 pub size: usize, 22 } 23 ··· 65 } 66 } 67 68 - /// Current, typed blob reference. 69 - /// Quite dislike this nesting, but it serves the same purpose as it did in Atrium 70 - /// Couple of helper methods and conversions to make it less annoying. 71 - /// TODO: revisit nesting and maybe hand-roll a serde impl that supports this sans nesting 72 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 73 #[serde(tag = "$type", rename_all = "lowercase")] 74 pub enum BlobRef<'r> { 75 #[serde(borrow)] 76 Blob(Blob<'r>), 77 } 78 79 impl<'r> BlobRef<'r> { 80 pub fn blob(&self) -> &Blob<'r> { 81 match self { 82 BlobRef::Blob(blob) => blob, ··· 108 } 109 } 110 111 - /// Wrapper for file type 112 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] 113 #[serde(transparent)] 114 #[repr(transparent)] ··· 120 Ok(Self(CowStr::Borrowed(mime_type))) 121 } 122 123 pub fn new_owned(mime_type: impl AsRef<str>) -> Self { 124 Self(CowStr::Owned(mime_type.as_ref().to_smolstr())) 125 } 126 127 pub fn new_static(mime_type: &'static str) -> Self { 128 Self(CowStr::new_static(mime_type)) 129 } 130 131 - /// Fallible constructor from an existing CowStr, borrows 132 pub fn from_cowstr(mime_type: CowStr<'m>) -> Result<MimeType<'m>, &'static str> { 133 Ok(Self(mime_type)) 134 } 135 136 - /// Infallible constructor 137 pub fn raw(mime_type: &'m str) -> Self { 138 Self(CowStr::Borrowed(mime_type)) 139 } 140 141 pub fn as_str(&self) -> &str { 142 { 143 let this = &self.0;
··· 12 str::FromStr, 13 }; 14 15 + /// Blob reference for binary data in AT Protocol 16 + /// 17 + /// Blobs represent uploaded binary data (images, videos, etc.) stored separately from records. 18 + /// They include a CID reference, MIME type, and size information. 19 + /// 20 + /// Serialization differs between formats: 21 + /// - JSON: `ref` is serialized as `{"$link": "cid_string"}` 22 + /// - CBOR: `ref` is the raw CID 23 #[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] 24 #[serde(rename_all = "camelCase")] 25 pub struct Blob<'b> { 26 + /// CID (Content Identifier) reference to the blob data 27 pub r#ref: Cid<'b>, 28 + /// MIME type of the blob (e.g., "image/png", "video/mp4") 29 #[serde(borrow)] 30 pub mime_type: MimeType<'b>, 31 + /// Size of the blob in bytes 32 pub size: usize, 33 } 34 ··· 76 } 77 } 78 79 + /// Tagged blob reference with `$type` field for serde 80 + /// 81 + /// This enum provides the `{"$type": "blob"}` wrapper expected by AT Protocol's JSON format. 82 + /// Currently only contains the `Blob` variant, but the enum structure supports future extensions. 83 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 84 #[serde(tag = "$type", rename_all = "lowercase")] 85 pub enum BlobRef<'r> { 86 + /// Blob variant with embedded blob data 87 #[serde(borrow)] 88 Blob(Blob<'r>), 89 } 90 91 impl<'r> BlobRef<'r> { 92 + /// Get the inner blob reference 93 pub fn blob(&self) -> &Blob<'r> { 94 match self { 95 BlobRef::Blob(blob) => blob, ··· 121 } 122 } 123 124 + /// MIME type identifier for blob data 125 + /// 126 + /// Used to specify the content type of blobs. Supports patterns like "image/*" and "*/*". 127 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] 128 #[serde(transparent)] 129 #[repr(transparent)] ··· 135 Ok(Self(CowStr::Borrowed(mime_type))) 136 } 137 138 + /// Fallible constructor, validates, takes ownership 139 pub fn new_owned(mime_type: impl AsRef<str>) -> Self { 140 Self(CowStr::Owned(mime_type.as_ref().to_smolstr())) 141 } 142 143 + /// Fallible constructor, validates, doesn't allocate 144 pub fn new_static(mime_type: &'static str) -> Self { 145 Self(CowStr::new_static(mime_type)) 146 } 147 148 + /// Fallible constructor from an existing CowStr 149 pub fn from_cowstr(mime_type: CowStr<'m>) -> Result<MimeType<'m>, &'static str> { 150 Ok(Self(mime_type)) 151 } 152 153 + /// Infallible constructor for trusted MIME type strings 154 pub fn raw(mime_type: &'m str) -> Self { 155 Self(CowStr::Borrowed(mime_type)) 156 } 157 158 + /// Get the MIME type as a string slice 159 pub fn as_str(&self) -> &str { 160 { 161 let this = &self.0;
+44 -10
crates/jacquard-common/src/types/cid.rs
··· 4 use smol_str::ToSmolStr; 5 use std::{convert::Infallible, fmt, marker::PhantomData, ops::Deref, str::FromStr}; 6 7 - /// raw 8 pub const ATP_CID_CODEC: u64 = 0x55; 9 10 - /// SHA-256 11 pub const ATP_CID_HASH: u64 = 0x12; 12 13 - /// base 32 14 pub const ATP_CID_BASE: multibase::Base = multibase::Base::Base32Lower; 15 16 - #[derive(Debug, Clone, PartialEq, Eq, Hash)] 17 - /// Either the string form of a cid or the ipld form 18 - /// For the IPLD form we also cache the string representation for later use. 19 /// 20 - /// Default on deserialization matches the format (if we get bytes, we try to decode) 21 pub enum Cid<'c> { 22 - Ipld { cid: IpldCid, s: CowStr<'c> }, 23 Str(CowStr<'c>), 24 } 25 26 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 27 pub enum Error { 28 #[error("Invalid IPLD CID {:?}", 0)] 29 Ipld(#[from] cid::Error), 30 #[error("{:?}", 0)] 31 Utf8(#[from] std::str::Utf8Error), 32 } 33 34 impl<'c> Cid<'c> { 35 pub fn new(cid: &'c [u8]) -> Result<Self, Error> { 36 if let Ok(cid) = IpldCid::try_from(cid.as_ref()) { 37 Ok(Self::ipld(cid)) ··· 41 } 42 } 43 44 pub fn new_owned(cid: &[u8]) -> Result<Cid<'static>, Error> { 45 if let Ok(cid) = IpldCid::try_from(cid.as_ref()) { 46 Ok(Self::ipld(cid)) ··· 50 } 51 } 52 53 pub fn ipld(cid: IpldCid) -> Cid<'static> { 54 let s = CowStr::Owned( 55 cid.to_string_of_base(ATP_CID_BASE) ··· 59 Cid::Ipld { cid, s } 60 } 61 62 pub fn str(cid: &'c str) -> Self { 63 Self::Str(CowStr::Borrowed(cid)) 64 } 65 66 pub fn cow_str(cid: CowStr<'c>) -> Self { 67 Self::Str(cid) 68 } 69 70 pub fn to_ipld(&self) -> Result<IpldCid, cid::Error> { 71 match self { 72 Cid::Ipld { cid, s: _ } => Ok(cid.clone()), ··· 74 } 75 } 76 77 pub fn as_str(&self) -> &str { 78 match self { 79 Cid::Ipld { cid: _, s } => s.as_ref(), ··· 218 } 219 } 220 221 - /// CID link wrapper that serializes as {"$link": "cid"} in JSON 222 - /// and as raw CID in CBOR 223 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 224 #[repr(transparent)] 225 pub struct CidLink<'c>(pub Cid<'c>); 226 227 impl<'c> CidLink<'c> { 228 pub fn new(cid: &'c [u8]) -> Result<Self, Error> { 229 Ok(Self(Cid::new(cid)?)) 230 } 231 232 pub fn new_owned(cid: &[u8]) -> Result<CidLink<'static>, Error> { 233 Ok(CidLink(Cid::new_owned(cid)?)) 234 } 235 236 pub fn new_static(cid: &'static str) -> Self { 237 Self(Cid::str(cid)) 238 } 239 240 pub fn ipld(cid: IpldCid) -> CidLink<'static> { 241 CidLink(Cid::ipld(cid)) 242 } 243 244 pub fn str(cid: &'c str) -> Self { 245 Self(Cid::str(cid)) 246 } 247 248 pub fn cow_str(cid: CowStr<'c>) -> Self { 249 Self(Cid::cow_str(cid)) 250 } 251 252 pub fn as_str(&self) -> &str { 253 self.0.as_str() 254 } 255 256 pub fn to_ipld(&self) -> Result<IpldCid, cid::Error> { 257 self.0.to_ipld() 258 } 259 260 pub fn into_inner(self) -> Cid<'c> { 261 self.0 262 }
··· 4 use smol_str::ToSmolStr; 5 use std::{convert::Infallible, fmt, marker::PhantomData, ops::Deref, str::FromStr}; 6 7 + /// CID codec for AT Protocol (raw) 8 pub const ATP_CID_CODEC: u64 = 0x55; 9 10 + /// CID hash function for AT Protocol (SHA-256) 11 pub const ATP_CID_HASH: u64 = 0x12; 12 13 + /// CID encoding base for AT Protocol (base32 lowercase) 14 pub const ATP_CID_BASE: multibase::Base = multibase::Base::Base32Lower; 15 16 + /// Content Identifier (CID) for IPLD data in AT Protocol 17 + /// 18 + /// CIDs are self-describing content addresses used to reference IPLD data. 19 + /// This type supports both string and parsed IPLD forms, with string caching 20 + /// for the parsed form to optimize serialization. 21 /// 22 + /// Deserialization automatically detects the format (bytes trigger IPLD parsing). 23 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 24 pub enum Cid<'c> { 25 + /// Parsed IPLD CID with cached string representation 26 + Ipld { 27 + /// Parsed CID structure 28 + cid: IpldCid, 29 + /// Cached base32 string form 30 + s: CowStr<'c>, 31 + }, 32 + /// String-only form (not yet parsed) 33 Str(CowStr<'c>), 34 } 35 36 + /// Errors that can occur when working with CIDs 37 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 38 pub enum Error { 39 + /// Invalid IPLD CID structure 40 #[error("Invalid IPLD CID {:?}", 0)] 41 Ipld(#[from] cid::Error), 42 + /// Invalid UTF-8 in CID string 43 #[error("{:?}", 0)] 44 Utf8(#[from] std::str::Utf8Error), 45 } 46 47 impl<'c> Cid<'c> { 48 + /// Parse a CID from bytes (tries IPLD first, falls back to UTF-8 string) 49 pub fn new(cid: &'c [u8]) -> Result<Self, Error> { 50 if let Ok(cid) = IpldCid::try_from(cid.as_ref()) { 51 Ok(Self::ipld(cid)) ··· 55 } 56 } 57 58 + /// Parse a CID from bytes into an owned (static lifetime) value 59 pub fn new_owned(cid: &[u8]) -> Result<Cid<'static>, Error> { 60 if let Ok(cid) = IpldCid::try_from(cid.as_ref()) { 61 Ok(Self::ipld(cid)) ··· 65 } 66 } 67 68 + /// Construct a CID from a parsed IPLD CID 69 pub fn ipld(cid: IpldCid) -> Cid<'static> { 70 let s = CowStr::Owned( 71 cid.to_string_of_base(ATP_CID_BASE) ··· 75 Cid::Ipld { cid, s } 76 } 77 78 + /// Construct a CID from a string slice (borrows) 79 pub fn str(cid: &'c str) -> Self { 80 Self::Str(CowStr::Borrowed(cid)) 81 } 82 83 + /// Construct a CID from a CowStr 84 pub fn cow_str(cid: CowStr<'c>) -> Self { 85 Self::Str(cid) 86 } 87 88 + /// Convert to a parsed IPLD CID (parses if needed) 89 pub fn to_ipld(&self) -> Result<IpldCid, cid::Error> { 90 match self { 91 Cid::Ipld { cid, s: _ } => Ok(cid.clone()), ··· 93 } 94 } 95 96 + /// Get the CID as a string slice 97 pub fn as_str(&self) -> &str { 98 match self { 99 Cid::Ipld { cid: _, s } => s.as_ref(), ··· 238 } 239 } 240 241 + /// CID link wrapper for JSON `{"$link": "cid"}` serialization 242 + /// 243 + /// Wraps a `Cid` and handles format-specific serialization: 244 + /// - JSON: `{"$link": "cid_string"}` 245 + /// - CBOR: raw CID bytes 246 + /// 247 + /// Used in the AT Protocol data model to represent IPLD links in JSON. 248 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 249 #[repr(transparent)] 250 pub struct CidLink<'c>(pub Cid<'c>); 251 252 impl<'c> CidLink<'c> { 253 + /// Parse a CID link from bytes 254 pub fn new(cid: &'c [u8]) -> Result<Self, Error> { 255 Ok(Self(Cid::new(cid)?)) 256 } 257 258 + /// Parse a CID link from bytes into an owned value 259 pub fn new_owned(cid: &[u8]) -> Result<CidLink<'static>, Error> { 260 Ok(CidLink(Cid::new_owned(cid)?)) 261 } 262 263 + /// Construct a CID link from a static string 264 pub fn new_static(cid: &'static str) -> Self { 265 Self(Cid::str(cid)) 266 } 267 268 + /// Construct a CID link from a parsed IPLD CID 269 pub fn ipld(cid: IpldCid) -> CidLink<'static> { 270 CidLink(Cid::ipld(cid)) 271 } 272 273 + /// Construct a CID link from a string slice 274 pub fn str(cid: &'c str) -> Self { 275 Self(Cid::str(cid)) 276 } 277 278 + /// Construct a CID link from a CowStr 279 pub fn cow_str(cid: CowStr<'c>) -> Self { 280 Self(Cid::cow_str(cid)) 281 } 282 283 + /// Get the CID as a string slice 284 pub fn as_str(&self) -> &str { 285 self.0.as_str() 286 } 287 288 + /// Convert to a parsed IPLD CID 289 pub fn to_ipld(&self) -> Result<IpldCid, cid::Error> { 290 self.0.to_ipld() 291 } 292 293 + /// Unwrap into the inner Cid 294 pub fn into_inner(self) -> Cid<'c> { 295 self.0 296 }
+14 -3
crates/jacquard-common/src/types/datetime.rs
··· 9 use crate::{CowStr, IntoStatic}; 10 use regex::Regex; 11 12 pub static ISO8601_REGEX: LazyLock<Regex> = LazyLock::new(|| { 13 Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+[0-9]{2}|\-[0-9][1-9]):[0-9]{2})$").unwrap() 14 }); 15 16 - /// A Lexicon timestamp. 17 #[derive(Clone, Debug, Eq, Hash)] 18 pub struct Datetime { 19 - /// Serialized form. Preserved during parsing to ensure round-trip re-serialization. 20 serialized: CowStr<'static>, 21 - /// Parsed form. 22 dt: chrono::DateTime<chrono::FixedOffset>, 23 } 24
··· 9 use crate::{CowStr, IntoStatic}; 10 use regex::Regex; 11 12 + /// Regex for ISO 8601 datetime validation per AT Protocol spec 13 pub static ISO8601_REGEX: LazyLock<Regex> = LazyLock::new(|| { 14 Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+[0-9]{2}|\-[0-9][1-9]):[0-9]{2})$").unwrap() 15 }); 16 17 + /// AT Protocol datetime (ISO 8601 with specific requirements) 18 + /// 19 + /// Lexicon datetimes use ISO 8601 format with these requirements: 20 + /// - Must include timezone (strongly prefer UTC with 'Z') 21 + /// - Requires whole seconds precision minimum 22 + /// - Supports millisecond and microsecond precision 23 + /// - Uses uppercase 'T' to separate date and time 24 + /// 25 + /// Examples: `"1985-04-12T23:20:50.123Z"`, `"2023-01-01T00:00:00+00:00"` 26 + /// 27 + /// The serialized form is preserved during parsing to ensure exact round-trip serialization. 28 #[derive(Clone, Debug, Eq, Hash)] 29 pub struct Datetime { 30 + /// Serialized form preserved from parsing for round-trip consistency 31 serialized: CowStr<'static>, 32 + /// Parsed datetime value for comparisons and operations 33 dt: chrono::DateTime<chrono::FixedOffset>, 34 } 35
+15
crates/jacquard-common/src/types/did.rs
··· 7 use std::sync::LazyLock; 8 use std::{ops::Deref, str::FromStr}; 9 10 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 11 #[serde(transparent)] 12 #[repr(transparent)] ··· 94 Self(CowStr::Borrowed(did)) 95 } 96 97 pub fn as_str(&self) -> &str { 98 { 99 let this = &self.0;
··· 7 use std::sync::LazyLock; 8 use std::{ops::Deref, str::FromStr}; 9 10 + /// Decentralized Identifier (DID) for AT Protocol accounts 11 + /// 12 + /// DIDs are the persistent, long-term account identifiers in AT Protocol. Unlike handles, 13 + /// which can change, a DID permanently identifies an account across the network. 14 + /// 15 + /// Supported DID methods: 16 + /// - `did:plc` - Bluesky's novel DID method 17 + /// - `did:web` - Based on HTTPS and DNS 18 + /// 19 + /// Validation enforces a maximum length of 2048 characters and uses the pattern: 20 + /// `did:[method]:[method-specific-id]` where the method is lowercase ASCII and the 21 + /// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs. 22 + /// 23 + /// See: <https://atproto.com/specs/did> 24 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 25 #[serde(transparent)] 26 #[repr(transparent)] ··· 108 Self(CowStr::Borrowed(did)) 109 } 110 111 + /// Get the DID as a string slice 112 pub fn as_str(&self) -> &str { 113 { 114 let this = &self.0;
+19 -2
crates/jacquard-common/src/types/handle.rs
··· 8 use std::sync::LazyLock; 9 use std::{ops::Deref, str::FromStr}; 10 11 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 12 #[serde(transparent)] 13 #[repr(transparent)] 14 pub struct Handle<'h>(CowStr<'h>); 15 16 pub static HANDLE_REGEX: LazyLock<Regex> = LazyLock::new(|| { 17 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() 18 }); 19 - 20 - /// AT Protocol handle 21 impl<'h> Handle<'h> { 22 /// Fallible constructor, validates, borrows from input 23 /// ··· 127 Self(CowStr::Borrowed(stripped)) 128 } 129 130 pub fn as_str(&self) -> &str { 131 { 132 let this = &self.0;
··· 8 use std::sync::LazyLock; 9 use std::{ops::Deref, str::FromStr}; 10 11 + /// AT Protocol handle (human-readable account identifier) 12 + /// 13 + /// Handles are user-friendly account identifiers that must resolve to a DID through DNS 14 + /// or HTTPS. Unlike DIDs, handles can change over time, though they remain an important 15 + /// part of user identity. 16 + /// 17 + /// Format rules: 18 + /// - Maximum 253 characters 19 + /// - At least two segments separated by dots (e.g., "alice.bsky.social") 20 + /// - Each segment is 1-63 characters of ASCII letters, numbers, and hyphens 21 + /// - Segments cannot start or end with a hyphen 22 + /// - Final segment (TLD) cannot start with a digit 23 + /// - Case-insensitive (normalized to lowercase) 24 + /// 25 + /// Certain TLDs are disallowed (.local, .localhost, .arpa, .invalid, .internal, .example, .alt, .onion). 26 + /// 27 + /// See: <https://atproto.com/specs/handle> 28 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 29 #[serde(transparent)] 30 #[repr(transparent)] 31 pub struct Handle<'h>(CowStr<'h>); 32 33 + /// Regex for handle validation per AT Protocol spec 34 pub static HANDLE_REGEX: LazyLock<Regex> = LazyLock::new(|| { 35 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() 36 }); 37 impl<'h> Handle<'h> { 38 /// Fallible constructor, validates, borrows from input 39 /// ··· 143 Self(CowStr::Borrowed(stripped)) 144 } 145 146 + /// Get the handle as a string slice 147 pub fn as_str(&self) -> &str { 148 { 149 let this = &self.0;
+10 -1
crates/jacquard-common/src/types/ident.rs
··· 8 9 use crate::CowStr; 10 11 - /// An AT Protocol identifier. 12 #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] 13 #[serde(untagged)] 14 pub enum AtIdentifier<'i> { 15 #[serde(borrow)] 16 Did(Did<'i>), 17 Handle(Handle<'i>), 18 } 19 ··· 73 } 74 } 75 76 pub fn as_str(&self) -> &str { 77 match self { 78 AtIdentifier::Did(did) => did.as_str(),
··· 8 9 use crate::CowStr; 10 11 + /// AT Protocol identifier (either a DID or handle) 12 + /// 13 + /// Represents the union of DIDs and handles, which can both be used to identify 14 + /// accounts in AT Protocol. DIDs are permanent identifiers, while handles are 15 + /// human-friendly and can change. 16 + /// 17 + /// Automatically determines whether a string is a DID or a handle during parsing. 18 #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] 19 #[serde(untagged)] 20 pub enum AtIdentifier<'i> { 21 + /// DID variant 22 #[serde(borrow)] 23 Did(Did<'i>), 24 + /// Handle variant 25 Handle(Handle<'i>), 26 } 27 ··· 81 } 82 } 83 84 + /// Get the identifier as a string slice 85 pub fn as_str(&self) -> &str { 86 match self { 87 AtIdentifier::Did(did) => did.as_str(),
+7 -2
crates/jacquard-common/src/types/language.rs
··· 5 6 use crate::CowStr; 7 8 - /// An IETF language tag. 9 /// 10 - /// Uses langtag crate for validation, but is stored as a SmolStr for size/avoiding allocations 11 /// 12 /// TODO: Implement langtag-style semantic matching for this type, delegating to langtag 13 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] 14 #[serde(transparent)]
··· 5 6 use crate::CowStr; 7 8 + /// IETF BCP 47 language tag for AT Protocol 9 + /// 10 + /// Language tags identify natural languages following the BCP 47 standard. They consist of 11 + /// a 2-3 character language code (e.g., "en", "ja") with optional regional subtags (e.g., "pt-BR"). 12 /// 13 + /// Examples: `"ja"` (Japanese), `"pt-BR"` (Brazilian Portuguese), `"en-US"` (US English) 14 /// 15 + /// Language tags require semantic parsing rather than simple string comparison. 16 + /// Uses the `langtag` crate for validation but stores as `SmolStr` for efficiency. 17 /// TODO: Implement langtag-style semantic matching for this type, delegating to langtag 18 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] 19 #[serde(transparent)]
+17 -3
crates/jacquard-common/src/types/nsid.rs
··· 8 use std::sync::LazyLock; 9 use std::{ops::Deref, str::FromStr}; 10 11 - /// Namespaced Identifier (NSID) 12 /// 13 - /// Stored as SmolStr to ease lifetime issues and because, despite the fact that NSIDs *can* be 317 characters, most are quite short 14 - /// TODO: consider if this should go back to CowStr, or be broken up into segments 15 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 16 #[serde(transparent)] 17 #[repr(transparent)] 18 pub struct Nsid<'n>(CowStr<'n>); 19 20 pub static NSID_REGEX: LazyLock<Regex> = LazyLock::new(|| { 21 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() 22 }); ··· 100 &self.0[split + 1..] 101 } 102 103 pub fn as_str(&self) -> &str { 104 { 105 let this = &self.0;
··· 8 use std::sync::LazyLock; 9 use std::{ops::Deref, str::FromStr}; 10 11 + /// Namespaced Identifier (NSID) for Lexicon schemas and XRPC endpoints 12 + /// 13 + /// NSIDs provide globally unique identifiers for Lexicon schemas, record types, and XRPC methods. 14 + /// They're structured as reversed domain names with a camelCase name segment. 15 + /// 16 + /// Format: `domain.authority.name` (e.g., `com.example.fooBar`) 17 + /// - Domain authority: reversed domain name (≤253 chars, lowercase, dots separate segments) 18 + /// - Name: camelCase identifier (letters and numbers only, cannot start with a digit) 19 + /// 20 + /// Validation rules: 21 + /// - Minimum 3 segments 22 + /// - Maximum 317 characters total 23 + /// - Each domain segment is 1-63 characters 24 + /// - Case-sensitive 25 /// 26 + /// See: <https://atproto.com/specs/nsid> 27 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 28 #[serde(transparent)] 29 #[repr(transparent)] 30 pub struct Nsid<'n>(CowStr<'n>); 31 32 + /// Regex for NSID validation per AT Protocol spec 33 pub static NSID_REGEX: LazyLock<Regex> = LazyLock::new(|| { 34 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() 35 }); ··· 113 &self.0[split + 1..] 114 } 115 116 + /// Get the NSID as a string slice 117 pub fn as_str(&self) -> &str { 118 { 119 let this = &self.0;
+30 -10
crates/jacquard-common/src/types/recordkey.rs
··· 9 use std::sync::LazyLock; 10 use std::{ops::Deref, str::FromStr}; 11 12 - /// Trait for generic typed record keys 13 /// 14 - /// This is deliberately public (so that consumers can develop specialized record key types), 15 - /// but is marked as unsafe, because the implementer is expected to uphold the invariants 16 - /// required by this trait, namely compliance with the [spec](https://atproto.com/specs/record-key) 17 - /// as described by [`RKEY_REGEX`]. 18 /// 19 - /// This crate provides implementations for TID, NSID, literals, and generic strings 20 pub unsafe trait RecordKeyType: Clone + Serialize { 21 fn as_str(&self) -> &str; 22 } 23 24 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Debug)] 25 #[serde(transparent)] 26 #[repr(transparent)] ··· 56 } 57 } 58 59 - /// ATProto Record Key (type `any`) 60 - /// Catch-all for any string meeting the overall Record Key requirements detailed [](https://atproto.com/specs/record-key) 61 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 62 #[serde(transparent)] 63 #[repr(transparent)] ··· 69 } 70 } 71 72 pub static RKEY_REGEX: LazyLock<Regex> = 73 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9.\-_:~]{1,512}$").unwrap()); 74 75 - /// AT Protocol rkey 76 impl<'r> Rkey<'r> { 77 /// Fallible constructor, validates, borrows from input 78 pub fn new(rkey: &'r str) -> Result<Self, AtStrError> { ··· 89 } 90 } 91 92 - /// Fallible constructor, validates, borrows from input 93 pub fn new_owned(rkey: impl AsRef<str>) -> Result<Self, AtStrError> { 94 let rkey = rkey.as_ref(); 95 if [".", ".."].contains(&rkey) { ··· 140 Self(CowStr::Borrowed(rkey)) 141 } 142 143 pub fn as_str(&self) -> &str { 144 { 145 let this = &self.0; ··· 265 } 266 267 #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] 268 pub struct SelfRecord; 269 270 impl Literal for SelfRecord { ··· 326 } 327 } 328 329 pub fn as_str(&self) -> &str { 330 T::LITERAL 331 }
··· 9 use std::sync::LazyLock; 10 use std::{ops::Deref, str::FromStr}; 11 12 + /// Trait for typed record key implementations 13 /// 14 + /// Allows different record key types (TID, NSID, literals, generic strings) while 15 + /// maintaining validation guarantees. Implementers must ensure compliance with the 16 + /// AT Protocol [record key specification](https://atproto.com/specs/record-key). 17 /// 18 + /// # Safety 19 + /// Implementations must ensure the string representation matches [`RKEY_REGEX`] and 20 + /// is not "." or "..". Built-in implementations: `Tid`, `Nsid`, `Literal<T>`, `Rkey<'_>`. 21 pub unsafe trait RecordKeyType: Clone + Serialize { 22 + /// Get the record key as a string slice 23 fn as_str(&self) -> &str; 24 } 25 26 + /// Wrapper for typed record keys 27 + /// 28 + /// Provides a generic container for different record key types while preserving their 29 + /// specific validation guarantees through the `RecordKeyType` trait. 30 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Debug)] 31 #[serde(transparent)] 32 #[repr(transparent)] ··· 62 } 63 } 64 65 + /// AT Protocol record key (generic "any" type) 66 + /// 67 + /// Record keys uniquely identify records within a collection. This is the catch-all 68 + /// type for any valid record key string (1-512 characters of alphanumerics, dots, 69 + /// hyphens, underscores, colons, tildes). 70 + /// 71 + /// Common record key types: 72 + /// - TID: timestamp-based (most common) 73 + /// - Literal: fixed keys like "self" 74 + /// - NSID: namespaced identifiers 75 + /// - Any: flexible strings matching the validation rules 76 + /// 77 + /// See: <https://atproto.com/specs/record-key> 78 #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 79 #[serde(transparent)] 80 #[repr(transparent)] ··· 86 } 87 } 88 89 + /// Regex for record key validation per AT Protocol spec 90 pub static RKEY_REGEX: LazyLock<Regex> = 91 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9.\-_:~]{1,512}$").unwrap()); 92 93 impl<'r> Rkey<'r> { 94 /// Fallible constructor, validates, borrows from input 95 pub fn new(rkey: &'r str) -> Result<Self, AtStrError> { ··· 106 } 107 } 108 109 + /// Fallible constructor, validates, takes ownership 110 pub fn new_owned(rkey: impl AsRef<str>) -> Result<Self, AtStrError> { 111 let rkey = rkey.as_ref(); 112 if [".", ".."].contains(&rkey) { ··· 157 Self(CowStr::Borrowed(rkey)) 158 } 159 160 + /// Get the record key as a string slice 161 pub fn as_str(&self) -> &str { 162 { 163 let this = &self.0; ··· 283 } 284 285 #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] 286 + /// Key for a record where only one of an NSID is supposed to exist 287 pub struct SelfRecord; 288 289 impl Literal for SelfRecord { ··· 345 } 346 } 347 348 + /// Get the literal record key as a string slice 349 pub fn as_str(&self) -> &str { 350 T::LITERAL 351 }
+57 -3
crates/jacquard-common/src/types/string.rs
··· 21 }, 22 }; 23 24 - /// ATProto string value 25 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 26 pub enum AtprotoStr<'s> { 27 Datetime(Datetime), 28 Language(Language), 29 Tid(Tid), 30 Nsid(Nsid<'s>), 31 Did(Did<'s>), 32 Handle(Handle<'s>), 33 AtIdentifier(AtIdentifier<'s>), 34 AtUri(AtUri<'s>), 35 Uri(Uri<'s>), 36 Cid(Cid<'s>), 37 RecordKey(RecordKey<Rkey<'s>>), 38 String(CowStr<'s>), 39 } 40 ··· 77 } 78 } 79 80 pub fn as_str(&self) -> &str { 81 match self { 82 Self::Datetime(datetime) => datetime.as_str(), ··· 238 help("if something doesn't match the spec, contact the crate author") 239 )] 240 pub struct AtStrError { 241 pub spec: SmolStr, 242 #[source_code] 243 pub source: String, 244 #[source] 245 #[diagnostic_source] 246 pub kind: StrParseKind, 247 } 248 249 impl AtStrError { 250 pub fn new(spec: &'static str, source: String, kind: StrParseKind) -> Self { 251 Self { 252 spec: SmolStr::new_static(spec), ··· 255 } 256 } 257 258 pub fn wrap(spec: &'static str, source: String, error: AtStrError) -> Self { 259 if let Some(span) = match &error.kind { 260 StrParseKind::Disallowed { problem, .. } => problem, ··· 309 } 310 } 311 312 pub fn too_long(spec: &'static str, source: &str, max: usize, actual: usize) -> Self { 313 Self { 314 spec: SmolStr::new_static(spec), ··· 317 } 318 } 319 320 pub fn too_short(spec: &'static str, source: &str, min: usize, actual: usize) -> Self { 321 Self { 322 spec: SmolStr::new_static(spec), ··· 348 } 349 350 /// missing component, with the span where it was expected to be founf 351 pub fn missing_from( 352 spec: &'static str, 353 source: &str, ··· 364 } 365 } 366 367 pub fn regex(spec: &'static str, source: &str, message: SmolStr) -> Self { 368 Self { 369 spec: SmolStr::new_static(spec), ··· 376 } 377 } 378 379 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 380 pub enum StrParseKind { 381 #[error("regex failure - {message}")] 382 #[diagnostic(code(jacquard::types::string::regex_fail))] 383 RegexFail { 384 #[label] 385 span: Option<SourceSpan>, 386 #[help] 387 message: SmolStr, 388 }, 389 #[error("string too long (allowed: {max}, actual: {actual})")] 390 #[diagnostic(code(jacquard::types::string::wrong_length))] 391 - TooLong { max: usize, actual: usize }, 392 393 #[error("string too short (allowed: {min}, actual: {actual})")] 394 #[diagnostic(code(jacquard::types::string::wrong_length))] 395 - TooShort { min: usize, actual: usize }, 396 #[error("disallowed - {message}")] 397 #[diagnostic(code(jacquard::types::string::disallowed))] 398 Disallowed { 399 #[label] 400 problem: Option<SourceSpan>, 401 #[help] 402 message: SmolStr, 403 }, 404 #[error("missing - {message}")] 405 #[diagnostic(code(jacquard::atstr::missing_component))] 406 MissingComponent { 407 #[label] 408 span: Option<SourceSpan>, 409 #[help] 410 message: SmolStr, 411 }, 412 #[error("{err:?}")] 413 #[diagnostic(code(jacquard::atstr::inner))] 414 Wrap { 415 #[label] 416 span: Option<SourceSpan>, 417 #[source] 418 err: Arc<AtStrError>, 419 },
··· 21 }, 22 }; 23 24 + /// Polymorphic AT Protocol string value 25 + /// 26 + /// Represents any AT Protocol string type, automatically detecting and parsing 27 + /// into the appropriate variant. Used internally for generic value handling. 28 + /// 29 + /// Variants are checked in order from most specific to least specific. Note that 30 + /// record keys are intentionally NOT parsed from bare strings as the validation 31 + /// is too permissive and would catch too many values. 32 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 33 pub enum AtprotoStr<'s> { 34 + /// ISO 8601 datetime 35 Datetime(Datetime), 36 + /// BCP 47 language tag 37 Language(Language), 38 + /// Timestamp identifier 39 Tid(Tid), 40 + /// Namespaced identifier 41 Nsid(Nsid<'s>), 42 + /// Decentralized identifier 43 Did(Did<'s>), 44 + /// Account handle 45 Handle(Handle<'s>), 46 + /// Identifier (DID or handle) 47 AtIdentifier(AtIdentifier<'s>), 48 + /// AT URI 49 AtUri(AtUri<'s>), 50 + /// Generic URI 51 Uri(Uri<'s>), 52 + /// Content identifier 53 Cid(Cid<'s>), 54 + /// Record key 55 RecordKey(RecordKey<Rkey<'s>>), 56 + /// Plain string (fallback) 57 String(CowStr<'s>), 58 } 59 ··· 96 } 97 } 98 99 + /// Get the string value regardless of variant 100 pub fn as_str(&self) -> &str { 101 match self { 102 Self::Datetime(datetime) => datetime.as_str(), ··· 258 help("if something doesn't match the spec, contact the crate author") 259 )] 260 pub struct AtStrError { 261 + /// AT Protocol spec name this error relates to 262 pub spec: SmolStr, 263 + /// The source string that failed to parse 264 #[source_code] 265 pub source: String, 266 + /// The specific kind of parsing error 267 #[source] 268 #[diagnostic_source] 269 pub kind: StrParseKind, 270 } 271 272 impl AtStrError { 273 + /// Create a new AT string parsing error 274 pub fn new(spec: &'static str, source: String, kind: StrParseKind) -> Self { 275 Self { 276 spec: SmolStr::new_static(spec), ··· 279 } 280 } 281 282 + /// Wrap an existing error with a new spec context 283 pub fn wrap(spec: &'static str, source: String, error: AtStrError) -> Self { 284 if let Some(span) = match &error.kind { 285 StrParseKind::Disallowed { problem, .. } => problem, ··· 334 } 335 } 336 337 + /// Create an error for a string that exceeds the maximum length 338 pub fn too_long(spec: &'static str, source: &str, max: usize, actual: usize) -> Self { 339 Self { 340 spec: SmolStr::new_static(spec), ··· 343 } 344 } 345 346 + /// Create an error for a string below the minimum length 347 pub fn too_short(spec: &'static str, source: &str, min: usize, actual: usize) -> Self { 348 Self { 349 spec: SmolStr::new_static(spec), ··· 375 } 376 377 /// missing component, with the span where it was expected to be founf 378 + /// Create an error for a missing component at a specific span 379 pub fn missing_from( 380 spec: &'static str, 381 source: &str, ··· 392 } 393 } 394 395 + /// Create an error for a regex validation failure 396 pub fn regex(spec: &'static str, source: &str, message: SmolStr) -> Self { 397 Self { 398 spec: SmolStr::new_static(spec), ··· 405 } 406 } 407 408 + /// Kinds of parsing errors for AT Protocol string types 409 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 410 pub enum StrParseKind { 411 + /// Regex pattern validation failed 412 #[error("regex failure - {message}")] 413 #[diagnostic(code(jacquard::types::string::regex_fail))] 414 RegexFail { 415 + /// Optional span highlighting the problem area 416 #[label] 417 span: Option<SourceSpan>, 418 + /// Help message explaining the failure 419 #[help] 420 message: SmolStr, 421 }, 422 + /// String exceeds maximum allowed length 423 #[error("string too long (allowed: {max}, actual: {actual})")] 424 #[diagnostic(code(jacquard::types::string::wrong_length))] 425 + TooLong { 426 + /// Maximum allowed length 427 + max: usize, 428 + /// Actual string length 429 + actual: usize, 430 + }, 431 432 + /// String is below minimum required length 433 #[error("string too short (allowed: {min}, actual: {actual})")] 434 #[diagnostic(code(jacquard::types::string::wrong_length))] 435 + TooShort { 436 + /// Minimum required length 437 + min: usize, 438 + /// Actual string length 439 + actual: usize, 440 + }, 441 + /// String contains disallowed values 442 #[error("disallowed - {message}")] 443 #[diagnostic(code(jacquard::types::string::disallowed))] 444 Disallowed { 445 + /// Optional span highlighting the disallowed content 446 #[label] 447 problem: Option<SourceSpan>, 448 + /// Help message about what's disallowed 449 #[help] 450 message: SmolStr, 451 }, 452 + /// Required component is missing 453 #[error("missing - {message}")] 454 #[diagnostic(code(jacquard::atstr::missing_component))] 455 MissingComponent { 456 + /// Optional span where the component should be 457 #[label] 458 span: Option<SourceSpan>, 459 + /// Help message about what's missing 460 #[help] 461 message: SmolStr, 462 }, 463 + /// Wraps another error with additional context 464 #[error("{err:?}")] 465 #[diagnostic(code(jacquard::atstr::inner))] 466 Wrap { 467 + /// Optional span in the outer context 468 #[label] 469 span: Option<SourceSpan>, 470 + /// The wrapped inner error 471 #[source] 472 err: Arc<AtStrError>, 473 },
+26 -3
crates/jacquard-common/src/types/tid.rs
··· 28 builder.finish() 29 } 30 31 static TID_REGEX: LazyLock<Regex> = LazyLock::new(|| { 32 Regex::new(r"^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$").unwrap() 33 }); 34 35 - /// A [Timestamp Identifier]. 36 /// 37 - /// [Timestamp Identifier]: https://atproto.com/specs/tid 38 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] 39 #[serde(transparent)] 40 #[repr(transparent)] ··· 105 Self(s32_encode(tid)) 106 } 107 108 pub fn from_time(timestamp: usize, clkid: u32) -> Self { 109 let str = smol_str::format_smolstr!( 110 "{0}{1:2>2}", ··· 114 Self(str) 115 } 116 117 pub fn timestamp(&self) -> usize { 118 s32decode(self.0[0..11].to_owned()) 119 } 120 121 - // newer > older 122 pub fn compare_to(&self, other: &Tid) -> i8 { 123 if self.0 > other.0 { 124 return 1; ··· 129 0 130 } 131 132 pub fn newer_than(&self, other: &Tid) -> bool { 133 self.compare_to(other) > 0 134 } 135 136 pub fn older_than(&self, other: &Tid) -> bool { 137 self.compare_to(other) < 0 138 } 139 140 pub fn next_str(prev: Option<Tid>) -> Result<Self, AtStrError> { 141 let prev = match prev { 142 None => None, ··· 173 } 174 } 175 176 pub fn s32decode(s: String) -> usize { 177 let mut i: usize = 0; 178 for c in s.chars() { ··· 273 } 274 275 impl Ticker { 276 pub fn new() -> Self { 277 let mut ticker = Self { 278 last_timestamp: 0, ··· 284 ticker 285 } 286 287 pub fn next(&mut self, prev: Option<Tid>) -> Tid { 288 let now = SystemTime::now() 289 .duration_since(SystemTime::UNIX_EPOCH)
··· 28 builder.finish() 29 } 30 31 + /// Regex for TID validation per AT Protocol spec 32 static TID_REGEX: LazyLock<Regex> = LazyLock::new(|| { 33 Regex::new(r"^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$").unwrap() 34 }); 35 36 + /// Timestamp Identifier (TID) for record keys and commit revisions 37 + /// 38 + /// TIDs are compact, sortable identifiers based on timestamps. They're used as record keys 39 + /// and repository commit revision numbers in AT Protocol. 40 + /// 41 + /// Format: 42 + /// - Always 13 ASCII characters 43 + /// - Base32-sortable encoding (`234567abcdefghijklmnopqrstuvwxyz`) 44 + /// - First 53 bits: microseconds since UNIX epoch 45 + /// - Final 10 bits: random clock identifier for collision resistance 46 + /// 47 + /// TIDs are sortable by timestamp and suitable for use in URLs. Generate new TIDs with 48 + /// `Tid::now()` or `Tid::now_with_clock_id()`. 49 /// 50 + /// See: <https://atproto.com/specs/tid> 51 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] 52 #[serde(transparent)] 53 #[repr(transparent)] ··· 118 Self(s32_encode(tid)) 119 } 120 121 + /// Construct a TID from a timestamp (in microseconds) and clock ID 122 pub fn from_time(timestamp: usize, clkid: u32) -> Self { 123 let str = smol_str::format_smolstr!( 124 "{0}{1:2>2}", ··· 128 Self(str) 129 } 130 131 + /// Extract the timestamp component (microseconds since UNIX epoch) 132 pub fn timestamp(&self) -> usize { 133 s32decode(self.0[0..11].to_owned()) 134 } 135 136 + /// Compare two TIDs chronologically (newer > older) 137 + /// 138 + /// Returns 1 if self is newer, -1 if older, 0 if equal 139 pub fn compare_to(&self, other: &Tid) -> i8 { 140 if self.0 > other.0 { 141 return 1; ··· 146 0 147 } 148 149 + /// Check if this TID is newer than another 150 pub fn newer_than(&self, other: &Tid) -> bool { 151 self.compare_to(other) > 0 152 } 153 154 + /// Check if this TID is older than another 155 pub fn older_than(&self, other: &Tid) -> bool { 156 self.compare_to(other) < 0 157 } 158 159 + /// Generate the next TID in sequence after the given TID 160 pub fn next_str(prev: Option<Tid>) -> Result<Self, AtStrError> { 161 let prev = match prev { 162 None => None, ··· 193 } 194 } 195 196 + /// Decode a base32-sortable string into a usize 197 pub fn s32decode(s: String) -> usize { 198 let mut i: usize = 0; 199 for c in s.chars() { ··· 294 } 295 296 impl Ticker { 297 + /// Create a new TID generator with random clock ID 298 pub fn new() -> Self { 299 let mut ticker = Self { 300 last_timestamp: 0, ··· 306 ticker 307 } 308 309 + /// Generate the next TID, optionally ensuring it's after the given TID 310 pub fn next(&mut self, prev: Option<Tid>) -> Tid { 311 let now = SystemTime::now() 312 .duration_since(SystemTime::UNIX_EPOCH)
+19 -2
crates/jacquard-common/src/types/uri.rs
··· 7 types::{aturi::AtUri, cid::Cid, did::Did, string::AtStrError}, 8 }; 9 10 - /// URI with best-available contextual type 11 - /// TODO: figure out wtf a DNS uri should look like 12 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 13 pub enum Uri<'u> { 14 Did(Did<'u>), 15 At(AtUri<'u>), 16 Https(Url), 17 Wss(Url), 18 Cid(Cid<'u>), 19 Any(CowStr<'u>), 20 } 21 22 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 23 pub enum UriParseError { 24 #[error("Invalid atproto string: {0}")] 25 At(#[from] AtStrError), 26 #[error(transparent)] 27 Url(#[from] url::ParseError), 28 #[error(transparent)] 29 Cid(#[from] crate::types::cid::Error), 30 } 31 32 impl<'u> Uri<'u> { 33 pub fn new(uri: &'u str) -> Result<Self, UriParseError> { 34 if uri.starts_with("did:") { 35 Ok(Uri::Did(Did::new(uri)?)) ··· 46 } 47 } 48 49 pub fn new_owned(uri: impl AsRef<str>) -> Result<Uri<'static>, UriParseError> { 50 let uri = uri.as_ref(); 51 if uri.starts_with("did:") { ··· 63 } 64 } 65 66 pub fn as_str(&self) -> &str { 67 match self { 68 Uri::Did(did) => did.as_str(),
··· 7 types::{aturi::AtUri, cid::Cid, did::Did, string::AtStrError}, 8 }; 9 10 + /// Generic URI with type-specific parsing 11 + /// 12 + /// Automatically detects and parses URIs into the appropriate variant based on 13 + /// the scheme prefix. Used in lexicon where URIs can be of various types. 14 + /// 15 + /// Variants are checked by prefix: `did:`, `at://`, `https://`, `wss://`, `ipld://` 16 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 17 pub enum Uri<'u> { 18 + /// DID URI (did:) 19 Did(Did<'u>), 20 + /// AT Protocol URI (at://) 21 At(AtUri<'u>), 22 + /// HTTPS URL 23 Https(Url), 24 + /// WebSocket Secure URL 25 Wss(Url), 26 + /// IPLD CID URI 27 Cid(Cid<'u>), 28 + /// Unrecognized URI scheme (catch-all) 29 Any(CowStr<'u>), 30 } 31 32 + /// Errors that can occur when parsing URIs 33 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 34 pub enum UriParseError { 35 + /// AT Protocol string parsing error 36 #[error("Invalid atproto string: {0}")] 37 At(#[from] AtStrError), 38 + /// Generic URL parsing error 39 #[error(transparent)] 40 Url(#[from] url::ParseError), 41 + /// CID parsing error 42 #[error(transparent)] 43 Cid(#[from] crate::types::cid::Error), 44 } 45 46 impl<'u> Uri<'u> { 47 + /// Parse a URI from a string slice, borrowing 48 pub fn new(uri: &'u str) -> Result<Self, UriParseError> { 49 if uri.starts_with("did:") { 50 Ok(Uri::Did(Did::new(uri)?)) ··· 61 } 62 } 63 64 + /// Parse a URI from a string, taking ownership 65 pub fn new_owned(uri: impl AsRef<str>) -> Result<Uri<'static>, UriParseError> { 66 let uri = uri.as_ref(); 67 if uri.starts_with("did:") { ··· 79 } 80 } 81 82 + /// Get the URI as a string slice 83 pub fn as_str(&self) -> &str { 84 match self { 85 Uri::Did(did) => did.as_str(),
+47
crates/jacquard-common/src/types/value.rs
··· 7 use smol_str::{SmolStr, ToSmolStr}; 8 use std::collections::BTreeMap; 9 10 pub mod convert; 11 pub mod parsing; 12 pub mod serde_impl; 13 14 #[cfg(test)] 15 mod tests; 16 17 #[derive(Debug, Clone, PartialEq, Eq)] 18 pub enum Data<'s> { 19 Null, 20 Boolean(bool), 21 Integer(i64), 22 String(AtprotoStr<'s>), 23 Bytes(Bytes), 24 CidLink(Cid<'s>), 25 Array(Array<'s>), 26 Object(Object<'s>), 27 Blob(Blob<'s>), 28 } 29 30 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 31 pub enum AtDataError { 32 #[error("floating point numbers not allowed in AT protocol data")] 33 FloatNotAllowed, 34 } 35 36 impl<'s> Data<'s> { 37 pub fn data_type(&self) -> DataModelType { 38 match self { 39 Data::Null => DataModelType::Null, ··· 69 Data::Blob(_) => DataModelType::Blob, 70 } 71 } 72 pub fn from_json(json: &'s serde_json::Value) -> Result<Self, AtDataError> { 73 Ok(if let Some(value) = json.as_bool() { 74 Self::Boolean(value) ··· 87 }) 88 } 89 90 pub fn from_cbor(cbor: &'s Ipld) -> Result<Self, AtDataError> { 91 Ok(match cbor { 92 Ipld::Null => Data::Null, ··· 121 } 122 } 123 124 #[derive(Debug, Clone, PartialEq, Eq)] 125 pub struct Array<'s>(pub Vec<Data<'s>>); 126 ··· 132 } 133 134 impl<'s> Array<'s> { 135 pub fn from_json(json: &'s Vec<serde_json::Value>) -> Result<Self, AtDataError> { 136 let mut array = Vec::with_capacity(json.len()); 137 for item in json { ··· 139 } 140 Ok(Self(array)) 141 } 142 pub fn from_cbor(cbor: &'s Vec<Ipld>) -> Result<Self, AtDataError> { 143 let mut array = Vec::with_capacity(cbor.len()); 144 for item in cbor { ··· 148 } 149 } 150 151 #[derive(Debug, Clone, PartialEq, Eq)] 152 pub struct Object<'s>(pub BTreeMap<SmolStr, Data<'s>>); 153 ··· 159 } 160 161 impl<'s> Object<'s> { 162 pub fn from_json( 163 json: &'s serde_json::Map<String, serde_json::Value>, 164 ) -> Result<Data<'s>, AtDataError> { ··· 232 Ok(Data::Object(Object(map))) 233 } 234 235 pub fn from_cbor(cbor: &'s BTreeMap<String, Ipld>) -> Result<Data<'s>, AtDataError> { 236 if let Some(Ipld::String(type_field)) = cbor.get("$type") { 237 if parsing::infer_from_type(type_field) == DataModelType::Blob { ··· 288 /// E.g. lower-level services, PDS implementations, firehose indexers, relay implementations. 289 #[derive(Debug, Clone, PartialEq, Eq)] 290 pub enum RawData<'s> { 291 Null, 292 Boolean(bool), 293 SignedInt(i64), 294 UnsignedInt(u64), 295 String(CowStr<'s>), 296 Bytes(Bytes), 297 CidLink(Cid<'s>), 298 Array(Vec<RawData<'s>>), 299 Object(BTreeMap<SmolStr, RawData<'s>>), 300 Blob(Blob<'s>), 301 InvalidBlob(Box<RawData<'s>>), 302 InvalidNumber(Bytes), 303 InvalidData(Bytes), 304 }
··· 7 use smol_str::{SmolStr, ToSmolStr}; 8 use std::collections::BTreeMap; 9 10 + /// Conversion utilities for Data types 11 pub mod convert; 12 + /// String parsing for AT Protocol types 13 pub mod parsing; 14 + /// Serde implementations for Data types 15 pub mod serde_impl; 16 17 #[cfg(test)] 18 mod tests; 19 20 + /// AT Protocol data model value 21 + /// 22 + /// Represents any valid value in the AT Protocol data model, which supports JSON and CBOR 23 + /// serialization with specific constraints (no floats, CID links, blobs with metadata). 24 + /// 25 + /// This is the generic "unknown data" type used for lexicon values, extra fields captured 26 + /// by `#[lexicon]`, and IPLD data structures. 27 #[derive(Debug, Clone, PartialEq, Eq)] 28 pub enum Data<'s> { 29 + /// Null value 30 Null, 31 + /// Boolean value 32 Boolean(bool), 33 + /// Integer value (no floats in AT Protocol) 34 Integer(i64), 35 + /// String value (parsed into specific AT Protocol types when possible) 36 String(AtprotoStr<'s>), 37 + /// Raw bytes 38 Bytes(Bytes), 39 + /// CID link reference 40 CidLink(Cid<'s>), 41 + /// Array of values 42 Array(Array<'s>), 43 + /// Object/map of values 44 Object(Object<'s>), 45 + /// Blob reference with metadata 46 Blob(Blob<'s>), 47 } 48 49 + /// Errors that can occur when working with AT Protocol data 50 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 51 pub enum AtDataError { 52 + /// Floating point numbers are not allowed in AT Protocol 53 #[error("floating point numbers not allowed in AT protocol data")] 54 FloatNotAllowed, 55 } 56 57 impl<'s> Data<'s> { 58 + /// Get the data model type of this value 59 pub fn data_type(&self) -> DataModelType { 60 match self { 61 Data::Null => DataModelType::Null, ··· 91 Data::Blob(_) => DataModelType::Blob, 92 } 93 } 94 + /// Parse a Data value from a JSON value 95 pub fn from_json(json: &'s serde_json::Value) -> Result<Self, AtDataError> { 96 Ok(if let Some(value) = json.as_bool() { 97 Self::Boolean(value) ··· 110 }) 111 } 112 113 + /// Parse a Data value from an IPLD value (CBOR) 114 pub fn from_cbor(cbor: &'s Ipld) -> Result<Self, AtDataError> { 115 Ok(match cbor { 116 Ipld::Null => Data::Null, ··· 145 } 146 } 147 148 + /// Array of AT Protocol data values 149 #[derive(Debug, Clone, PartialEq, Eq)] 150 pub struct Array<'s>(pub Vec<Data<'s>>); 151 ··· 157 } 158 159 impl<'s> Array<'s> { 160 + /// Parse an array from JSON values 161 pub fn from_json(json: &'s Vec<serde_json::Value>) -> Result<Self, AtDataError> { 162 let mut array = Vec::with_capacity(json.len()); 163 for item in json { ··· 165 } 166 Ok(Self(array)) 167 } 168 + /// Parse an array from IPLD values (CBOR) 169 pub fn from_cbor(cbor: &'s Vec<Ipld>) -> Result<Self, AtDataError> { 170 let mut array = Vec::with_capacity(cbor.len()); 171 for item in cbor { ··· 175 } 176 } 177 178 + /// Object/map of AT Protocol data values 179 #[derive(Debug, Clone, PartialEq, Eq)] 180 pub struct Object<'s>(pub BTreeMap<SmolStr, Data<'s>>); 181 ··· 187 } 188 189 impl<'s> Object<'s> { 190 + /// Parse an object from a JSON map with type inference 191 + /// 192 + /// Uses key names to infer the appropriate AT Protocol types for values. 193 pub fn from_json( 194 json: &'s serde_json::Map<String, serde_json::Value>, 195 ) -> Result<Data<'s>, AtDataError> { ··· 263 Ok(Data::Object(Object(map))) 264 } 265 266 + /// Parse an object from IPLD (CBOR) with type inference 267 + /// 268 + /// Uses key names to infer the appropriate AT Protocol types for values. 269 pub fn from_cbor(cbor: &'s BTreeMap<String, Ipld>) -> Result<Data<'s>, AtDataError> { 270 if let Some(Ipld::String(type_field)) = cbor.get("$type") { 271 if parsing::infer_from_type(type_field) == DataModelType::Blob { ··· 322 /// E.g. lower-level services, PDS implementations, firehose indexers, relay implementations. 323 #[derive(Debug, Clone, PartialEq, Eq)] 324 pub enum RawData<'s> { 325 + /// Null value 326 Null, 327 + /// Boolean value 328 Boolean(bool), 329 + /// Signed integer 330 SignedInt(i64), 331 + /// Unsigned integer 332 UnsignedInt(u64), 333 + /// String value (no type inference) 334 String(CowStr<'s>), 335 + /// Raw bytes 336 Bytes(Bytes), 337 + /// CID link reference 338 CidLink(Cid<'s>), 339 + /// Array of raw values 340 Array(Vec<RawData<'s>>), 341 + /// Object/map of raw values 342 Object(BTreeMap<SmolStr, RawData<'s>>), 343 + /// Valid blob reference 344 Blob(Blob<'s>), 345 + /// Invalid blob structure (captured for debugging) 346 InvalidBlob(Box<RawData<'s>>), 347 + /// Invalid number format, generally a floating point number (captured as bytes) 348 InvalidNumber(Bytes), 349 + /// Invalid/unknown data (captured as bytes) 350 InvalidData(Bytes), 351 }
+6
crates/jacquard-common/src/types/value/parsing.rs
··· 17 use std::{collections::BTreeMap, str::FromStr}; 18 use url::Url; 19 20 pub fn insert_string<'s>( 21 map: &mut BTreeMap<SmolStr, Data<'s>>, 22 key: &'s str, ··· 231 } 232 } 233 234 pub fn cbor_to_blob<'b>(blob: &'b BTreeMap<String, Ipld>) -> Option<Blob<'b>> { 235 let mime_type = blob.get("mimeType").and_then(|o| { 236 if let Ipld::String(string) = o { ··· 267 None 268 } 269 270 pub fn json_to_blob<'b>(blob: &'b serde_json::Map<String, serde_json::Value>) -> Option<Blob<'b>> { 271 let mime_type = blob.get("mimeType").and_then(|v| v.as_str()); 272 if let Some(value) = blob.get("ref") { ··· 297 None 298 } 299 300 pub fn infer_from_type(type_field: &str) -> DataModelType { 301 match type_field { 302 "blob" => DataModelType::Blob, ··· 304 } 305 } 306 307 pub fn decode_bytes<'s>(bytes: &str) -> Data<'s> { 308 // First one should just work. rest are insurance. 309 if let Ok(bytes) = BASE64_STANDARD.decode(bytes) { ··· 319 } 320 } 321 322 pub fn decode_raw_bytes<'s>(bytes: &str) -> RawData<'s> { 323 // First one should just work. rest are insurance. 324 if let Ok(bytes) = BASE64_STANDARD.decode(bytes) {
··· 17 use std::{collections::BTreeMap, str::FromStr}; 18 use url::Url; 19 20 + /// Insert a string into an at:// `Data<'_>` map, inferring its type. 21 pub fn insert_string<'s>( 22 map: &mut BTreeMap<SmolStr, Data<'s>>, 23 key: &'s str, ··· 232 } 233 } 234 235 + /// Convert an ipld map to a atproto data model blob if it matches the format 236 pub fn cbor_to_blob<'b>(blob: &'b BTreeMap<String, Ipld>) -> Option<Blob<'b>> { 237 let mime_type = blob.get("mimeType").and_then(|o| { 238 if let Ipld::String(string) = o { ··· 269 None 270 } 271 272 + /// convert a JSON object to an atproto data model blob if it matches the format 273 pub fn json_to_blob<'b>(blob: &'b serde_json::Map<String, serde_json::Value>) -> Option<Blob<'b>> { 274 let mime_type = blob.get("mimeType").and_then(|v| v.as_str()); 275 if let Some(value) = blob.get("ref") { ··· 300 None 301 } 302 303 + /// Infer if something with a "$type" field is a blob or an object 304 pub fn infer_from_type(type_field: &str) -> DataModelType { 305 match type_field { 306 "blob" => DataModelType::Blob, ··· 308 } 309 } 310 311 + /// decode a base64 byte string into atproto data 312 pub fn decode_bytes<'s>(bytes: &str) -> Data<'s> { 313 // First one should just work. rest are insurance. 314 if let Ok(bytes) = BASE64_STANDARD.decode(bytes) { ··· 324 } 325 } 326 327 + /// decode a base64 byte string into atproto raw unvalidated data 328 pub fn decode_raw_bytes<'s>(bytes: &str) -> RawData<'s> { 329 // First one should just work. rest are insurance. 330 if let Ok(bytes) = BASE64_STANDARD.decode(bytes) {
+1
crates/jacquard-common/src/types/xrpc.rs
··· 45 } 46 } 47 48 pub const fn body_encoding(&self) -> Option<&'static str> { 49 match self { 50 Self::Query => None,
··· 45 } 46 } 47 48 + /// Get the body encoding type for this method (procedures only) 49 pub const fn body_encoding(&self) -> Option<&'static str> { 50 match self { 51 Self::Query => None,
+9 -4
crates/jacquard/Cargo.toml
··· 1 [package] 2 - authors.workspace = true 3 - # If you change the name here, you must also do it in flake.nix (and run `cargo generate-lockfile` afterwards) 4 name = "jacquard" 5 - description = "A simple Rust project using Nix" 6 version.workspace = true 7 - edition.workspace = true 8 9 [features] 10 default = ["api_all"]
··· 1 [package] 2 name = "jacquard" 3 + description = "Simple and powerful AT Procotol implementation" 4 + edition.workspace = true 5 version.workspace = true 6 + authors.workspace = true 7 + repository.workspace = true 8 + keywords.workspace = true 9 + categories.workspace = true 10 + readme.workspace = true 11 + documentation.workspace = true 12 + exclude.workspace = true 13 14 [features] 15 default = ["api_all"]
+45 -7
crates/jacquard/src/client.rs
··· 1 mod error; 2 mod response; 3 ··· 56 } 57 } 58 59 pub trait HttpClient { 60 type Error: std::error::Error + Display + Send + Sync + 'static; 61 /// Send an HTTP request and return the response. 62 fn send_http( ··· 64 request: Request<Vec<u8>>, 65 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>>; 66 } 67 - /// XRPC client trait 68 pub trait XrpcClient: HttpClient { 69 fn base_uri(&self) -> CowStr<'_>; 70 #[allow(unused_variables)] 71 fn authorization_token( 72 &self, ··· 93 94 pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession"; 95 96 pub enum AuthorizationToken<'s> { 97 Bearer(CowStr<'s>), 98 Dpop(CowStr<'s>), 99 } 100 ··· 109 } 110 } 111 112 - /// HTTP headers which can be used in XPRC requests. 113 pub enum Header { 114 ContentType, 115 Authorization, 116 AtprotoProxy, 117 AtprotoAcceptLabelers, 118 } 119 ··· 210 Ok(Response::new(buffer, status)) 211 } 212 213 - /// Session information from createSession 214 #[derive(Debug, Clone)] 215 pub struct Session { 216 pub access_jwt: CowStr<'static>, 217 pub refresh_jwt: CowStr<'static>, 218 pub did: Did<'static>, 219 pub handle: Handle<'static>, 220 } 221 ··· 232 } 233 } 234 235 - /// Authenticated XRPC client that includes session tokens 236 pub struct AuthenticatedClient<C> { 237 client: C, 238 base_uri: CowStr<'static>, ··· 241 242 impl<C> AuthenticatedClient<C> { 243 /// Create a new authenticated client with a base URI 244 pub fn new(client: C, base_uri: CowStr<'static>) -> Self { 245 Self { 246 client, ··· 249 } 250 } 251 252 - /// Set the session 253 pub fn set_session(&mut self, session: Session) { 254 self.session = Some(session); 255 } 256 257 - /// Get the current session 258 pub fn session(&self) -> Option<&Session> { 259 self.session.as_ref() 260 } 261 262 - /// Clear the session 263 pub fn clear_session(&mut self) { 264 self.session = None; 265 }
··· 1 + //! XRPC client implementation for AT Protocol 2 + //! 3 + //! This module provides HTTP and XRPC client traits along with an authenticated 4 + //! client implementation that manages session tokens. 5 + 6 mod error; 7 mod response; 8 ··· 61 } 62 } 63 64 + /// HTTP client trait for sending raw HTTP requests 65 pub trait HttpClient { 66 + /// Error type returned by the HTTP client 67 type Error: std::error::Error + Display + Send + Sync + 'static; 68 /// Send an HTTP request and return the response. 69 fn send_http( ··· 71 request: Request<Vec<u8>>, 72 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>>; 73 } 74 + /// XRPC client trait for AT Protocol RPC calls 75 pub trait XrpcClient: HttpClient { 76 + /// Get the base URI for XRPC requests (e.g., "https://bsky.social") 77 fn base_uri(&self) -> CowStr<'_>; 78 + /// Get the authorization token for XRPC requests 79 #[allow(unused_variables)] 80 fn authorization_token( 81 &self, ··· 102 103 pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession"; 104 105 + /// Authorization token types for XRPC requests 106 pub enum AuthorizationToken<'s> { 107 + /// Bearer token (access JWT, refresh JWT to refresh the session) 108 Bearer(CowStr<'s>), 109 + /// DPoP token (proof-of-possession) for OAuth 110 Dpop(CowStr<'s>), 111 } 112 ··· 121 } 122 } 123 124 + /// HTTP headers commonly used in XRPC requests 125 pub enum Header { 126 + /// Content-Type header 127 ContentType, 128 + /// Authorization header 129 Authorization, 130 + /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. 131 + /// 132 + /// See: <https://atproto.com/specs/xrpc#service-proxying> 133 AtprotoProxy, 134 + /// `atproto-accept-labelers` header used by clients to request labels from specific labelers to be included and applied in the response. See [label](https://atproto.com/specs/label) specification for details. 135 AtprotoAcceptLabelers, 136 } 137 ··· 228 Ok(Response::new(buffer, status)) 229 } 230 231 + /// Session information from `com.atproto.server.createSession` 232 + /// 233 + /// Contains the access and refresh tokens along with user identity information. 234 #[derive(Debug, Clone)] 235 pub struct Session { 236 + /// Access token (JWT) used for authenticated requests 237 pub access_jwt: CowStr<'static>, 238 + /// Refresh token (JWT) used to obtain new access tokens 239 pub refresh_jwt: CowStr<'static>, 240 + /// User's DID (Decentralized Identifier) 241 pub did: Did<'static>, 242 + /// User's handle (e.g., "alice.bsky.social") 243 pub handle: Handle<'static>, 244 } 245 ··· 256 } 257 } 258 259 + /// Authenticated XRPC client wrapper that manages session tokens 260 + /// 261 + /// Wraps an HTTP client and adds automatic Bearer token authentication for XRPC requests. 262 + /// Handles both access tokens for regular requests and refresh tokens for session refresh. 263 pub struct AuthenticatedClient<C> { 264 client: C, 265 base_uri: CowStr<'static>, ··· 268 269 impl<C> AuthenticatedClient<C> { 270 /// Create a new authenticated client with a base URI 271 + /// 272 + /// # Example 273 + /// ```ignore 274 + /// let client = AuthenticatedClient::new( 275 + /// reqwest::Client::new(), 276 + /// CowStr::from("https://bsky.social") 277 + /// ); 278 + /// ``` 279 pub fn new(client: C, base_uri: CowStr<'static>) -> Self { 280 Self { 281 client, ··· 284 } 285 } 286 287 + /// Set the session obtained from `createSession` or `refreshSession` 288 pub fn set_session(&mut self, session: Session) { 289 self.session = Some(session); 290 } 291 292 + /// Get the current session if one exists 293 pub fn session(&self) -> Option<&Session> { 294 self.session.as_ref() 295 } 296 297 + /// Clear the current session locally 298 + /// 299 + /// Note: This only clears the local session state. To properly revoke the session 300 + /// server-side, use `com.atproto.server.deleteSession` before calling this. 301 pub fn clear_session(&mut self) { 302 self.session = None; 303 }
+23 -1
crates/jacquard/src/client/error.rs
··· 1 use bytes::Bytes; 2 3 - /// Client error type 4 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 5 pub enum ClientError { 6 /// HTTP transport error ··· 44 ), 45 } 46 47 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 48 pub enum TransportError { 49 #[error("Connection error: {0}")] 50 Connect(String), 51 52 #[error("Request timeout")] 53 Timeout, 54 55 #[error("Invalid request: {0}")] 56 InvalidRequest(String), 57 58 #[error("Transport error: {0}")] 59 Other(Box<dyn std::error::Error + Send + Sync>), 60 } ··· 62 // Re-export EncodeError from common 63 pub use jacquard_common::types::xrpc::EncodeError; 64 65 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 66 pub enum DecodeError { 67 #[error("Failed to deserialize JSON: {0}")] 68 Json( 69 #[from] 70 #[source] 71 serde_json::Error, 72 ), 73 #[error("Failed to deserialize CBOR: {0}")] 74 CborLocal( 75 #[from] 76 #[source] 77 serde_ipld_dagcbor::DecodeError<std::io::Error>, 78 ), 79 #[error("Failed to deserialize CBOR: {0}")] 80 CborRemote( 81 #[from] ··· 84 ), 85 } 86 87 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 88 pub struct HttpError { 89 pub status: http::StatusCode, 90 pub body: Option<Bytes>, 91 } 92 ··· 102 } 103 } 104 105 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 106 pub enum AuthError { 107 #[error("Access token expired")] 108 TokenExpired, 109 110 #[error("Invalid access token")] 111 InvalidToken, 112 113 #[error("Token refresh failed")] 114 RefreshFailed, 115 116 #[error("No authentication provided")] 117 NotAuthenticated, 118 #[error("Authentication error: {0:?}")] 119 Other(http::HeaderValue), 120 } 121 122 pub type Result<T> = std::result::Result<T, ClientError>; 123 124 impl From<reqwest::Error> for TransportError {
··· 1 + //! Error types for XRPC client operations 2 + 3 use bytes::Bytes; 4 5 + /// Client error type wrapping all possible error conditions 6 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 7 pub enum ClientError { 8 /// HTTP transport error ··· 46 ), 47 } 48 49 + /// Transport-level errors that occur during HTTP communication 50 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 51 pub enum TransportError { 52 + /// Failed to establish connection to server 53 #[error("Connection error: {0}")] 54 Connect(String), 55 56 + /// Request timed out 57 #[error("Request timeout")] 58 Timeout, 59 60 + /// Request construction failed (malformed URI, headers, etc.) 61 #[error("Invalid request: {0}")] 62 InvalidRequest(String), 63 64 + /// Other transport error 65 #[error("Transport error: {0}")] 66 Other(Box<dyn std::error::Error + Send + Sync>), 67 } ··· 69 // Re-export EncodeError from common 70 pub use jacquard_common::types::xrpc::EncodeError; 71 72 + /// Response deserialization errors 73 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 74 pub enum DecodeError { 75 + /// JSON deserialization failed 76 #[error("Failed to deserialize JSON: {0}")] 77 Json( 78 #[from] 79 #[source] 80 serde_json::Error, 81 ), 82 + /// CBOR deserialization failed (local I/O) 83 #[error("Failed to deserialize CBOR: {0}")] 84 CborLocal( 85 #[from] 86 #[source] 87 serde_ipld_dagcbor::DecodeError<std::io::Error>, 88 ), 89 + /// CBOR deserialization failed (remote/reqwest) 90 #[error("Failed to deserialize CBOR: {0}")] 91 CborRemote( 92 #[from] ··· 95 ), 96 } 97 98 + /// HTTP error response (non-200 status codes outside of XRPC error handling) 99 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 100 pub struct HttpError { 101 + /// HTTP status code 102 pub status: http::StatusCode, 103 + /// Response body if available 104 pub body: Option<Bytes>, 105 } 106 ··· 116 } 117 } 118 119 + /// Authentication and authorization errors 120 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 121 pub enum AuthError { 122 + /// Access token has expired (use refresh token to get a new one) 123 #[error("Access token expired")] 124 TokenExpired, 125 126 + /// Access token is invalid or malformed 127 #[error("Invalid access token")] 128 InvalidToken, 129 130 + /// Token refresh request failed 131 #[error("Token refresh failed")] 132 RefreshFailed, 133 134 + /// Request requires authentication but none was provided 135 #[error("No authentication provided")] 136 NotAuthenticated, 137 + 138 + /// Other authentication error 139 #[error("Authentication error: {0:?}")] 140 Other(http::HeaderValue), 141 } 142 143 + /// Result type for client operations 144 pub type Result<T> = std::result::Result<T, ClientError>; 145 146 impl From<reqwest::Error> for TransportError {
+29 -21
crates/jacquard/src/client/response.rs
··· 1 use bytes::Bytes; 2 use http::StatusCode; 3 use jacquard_common::IntoStatic; 4 use jacquard_common::types::xrpc::XrpcRequest; 5 use serde::Deserialize; 6 use std::marker::PhantomData; ··· 10 /// XRPC response wrapper that owns the response buffer 11 /// 12 /// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. 13 pub struct Response<R: XrpcRequest> { 14 buffer: Bytes, 15 status: StatusCode, ··· 74 // 401: always auth error 75 } else { 76 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 77 - Ok(generic) => { 78 - match generic.error.as_str() { 79 - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 80 - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 81 - _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 82 - } 83 - } 84 Err(e) => Err(XrpcError::Decode(e)), 85 } 86 } ··· 120 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 121 Ok(generic) => { 122 // Map auth-related errors to AuthError 123 - match generic.error.as_str() { 124 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 125 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 126 _ => Err(XrpcError::Generic(generic)), ··· 133 // 401: always auth error 134 } else { 135 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 136 - Ok(generic) => { 137 - match generic.error.as_str() { 138 - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 139 - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 140 - _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 141 - } 142 - } 143 Err(e) => Err(XrpcError::Decode(e)), 144 } 145 } ··· 151 } 152 } 153 154 - /// Generic XRPC error format (for InvalidRequest, etc.) 155 #[derive(Debug, Clone, Deserialize)] 156 pub struct GenericXrpcError { 157 - pub error: String, 158 - pub message: Option<String>, 159 } 160 161 impl std::fmt::Display for GenericXrpcError { ··· 170 171 impl std::error::Error for GenericXrpcError {} 172 173 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 174 pub enum XrpcError<E: std::error::Error + IntoStatic> { 175 - /// Typed XRPC error from the endpoint's error enum 176 #[error("XRPC error: {0}")] 177 Xrpc(E), 178 ··· 180 #[error("Authentication error: {0}")] 181 Auth(#[from] AuthError), 182 183 - /// Generic XRPC error (InvalidRequest, etc.) 184 #[error("XRPC error: {0}")] 185 Generic(GenericXrpcError), 186 187 - /// Failed to decode response 188 #[error("Failed to decode response: {0}")] 189 Decode(#[from] serde_json::Error), 190 }
··· 1 + //! XRPC response parsing and error handling 2 + 3 use bytes::Bytes; 4 use http::StatusCode; 5 use jacquard_common::IntoStatic; 6 + use jacquard_common::smol_str::SmolStr; 7 use jacquard_common::types::xrpc::XrpcRequest; 8 use serde::Deserialize; 9 use std::marker::PhantomData; ··· 13 /// XRPC response wrapper that owns the response buffer 14 /// 15 /// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. 16 + /// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`). 17 pub struct Response<R: XrpcRequest> { 18 buffer: Bytes, 19 status: StatusCode, ··· 78 // 401: always auth error 79 } else { 80 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 81 + Ok(generic) => match generic.error.as_str() { 82 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 83 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 84 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 85 + }, 86 Err(e) => Err(XrpcError::Decode(e)), 87 } 88 } ··· 122 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 123 Ok(generic) => { 124 // Map auth-related errors to AuthError 125 + match generic.error.as_ref() { 126 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 127 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 128 _ => Err(XrpcError::Generic(generic)), ··· 135 // 401: always auth error 136 } else { 137 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 138 + Ok(generic) => match generic.error.as_ref() { 139 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 140 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 141 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 142 + }, 143 Err(e) => Err(XrpcError::Decode(e)), 144 } 145 } ··· 151 } 152 } 153 154 + /// Generic XRPC error format for untyped errors like InvalidRequest 155 + /// 156 + /// Used when the error doesn't match the endpoint's specific error enum 157 #[derive(Debug, Clone, Deserialize)] 158 pub struct GenericXrpcError { 159 + /// Error code (e.g., "InvalidRequest") 160 + pub error: SmolStr, 161 + /// Optional error message with details 162 + pub message: Option<SmolStr>, 163 } 164 165 impl std::fmt::Display for GenericXrpcError { ··· 174 175 impl std::error::Error for GenericXrpcError {} 176 177 + /// XRPC-specific errors returned from endpoints 178 + /// 179 + /// Represents errors returned in the response body 180 + /// Type parameter `E` is the endpoint's specific error enum type. 181 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 182 pub enum XrpcError<E: std::error::Error + IntoStatic> { 183 + /// Typed XRPC error from the endpoint's specific error enum 184 #[error("XRPC error: {0}")] 185 Xrpc(E), 186 ··· 188 #[error("Authentication error: {0}")] 189 Auth(#[from] AuthError), 190 191 + /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) 192 #[error("XRPC error: {0}")] 193 Generic(GenericXrpcError), 194 195 + /// Failed to decode the response body 196 #[error("Failed to decode response: {0}")] 197 Decode(#[from] serde_json::Error), 198 }
+7 -1
crates/jacquard/src/lib.rs
··· 1 pub mod client; 2 3 - // Re-export common types 4 #[cfg(feature = "api")] 5 pub use jacquard_api as api; 6 pub use jacquard_common::*; 7 8 #[cfg(feature = "derive")] 9 pub use jacquard_derive::*;
··· 1 + #![doc = include_str!("../../../README.md")] 2 + #![warn(missing_docs)] 3 + 4 + /// XRPC client traits and basic implementation 5 pub mod client; 6 7 #[cfg(feature = "api")] 8 + /// If enabled, re-export the generated api crate 9 pub use jacquard_api as api; 10 + /// Re-export common types 11 pub use jacquard_common::*; 12 13 #[cfg(feature = "derive")] 14 + /// if enabled, reexport the attribute macros 15 pub use jacquard_derive::*;
+1 -1
crates/jacquard/src/main.rs
··· 27 28 // Create HTTP client 29 let http = reqwest::Client::new(); 30 - let mut client = AuthenticatedClient::new(http, CowStr::from(args.pds)); 31 32 // Create session 33 println!("logging in as {}...", args.username);
··· 27 28 // Create HTTP client 29 let http = reqwest::Client::new(); 30 + let mut client = AuthenticatedClient::new(http, args.pds); 31 32 // Create session 33 println!("logging in as {}...", args.username);