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