···1717/// `<str as ToOwned>::Owned` is `String`, and not `SmolStr`.
1818#[derive(Clone)]
1919pub enum CowStr<'s> {
2020+ /// &str varaiant
2021 Borrowed(&'s str),
2222+ /// Smolstr variant
2123 Owned(SmolStr),
2224}
23252426impl CowStr<'static> {
2527 /// Create a new `CowStr` by copying from a `&str` — this might allocate
2626- /// if the `compact_str` feature is disabled, or if the string is longer
2727- /// than `MAX_INLINE_SIZE`.
2828+ /// if the string is longer than `MAX_INLINE_SIZE`.
2829 pub fn copy_from_str(s: &str) -> Self {
2930 Self::Owned(SmolStr::from(s))
3031 }
31323333+ /// Create a new owned `CowStr` from a static &str without allocating
3234 pub fn new_static(s: &'static str) -> Self {
3335 Self::Owned(SmolStr::new_static(s))
3436 }
···36383739impl<'s> CowStr<'s> {
3840 #[inline]
4141+ /// Borrow and decode a byte slice as utf8 into a CowStr
3942 pub fn from_utf8(s: &'s [u8]) -> Result<Self, std::str::Utf8Error> {
4043 Ok(Self::Borrowed(std::str::from_utf8(s)?))
4144 }
42454346 #[inline]
4444- pub fn from_utf8_owned(s: Vec<u8>) -> Result<Self, std::str::Utf8Error> {
4545- Ok(Self::Owned(SmolStr::new(std::str::from_utf8(&s)?)))
4747+ /// Take bytes and decode them as utf8 into an owned CowStr. Might allocate.
4848+ pub fn from_utf8_owned(s: impl AsRef<[u8]>) -> Result<Self, std::str::Utf8Error> {
4949+ Ok(Self::Owned(SmolStr::new(std::str::from_utf8(&s.as_ref())?)))
4650 }
47514852 #[inline]
5353+ /// Take bytes and decode them as utf8, skipping invalid characters, taking ownership.
5454+ /// Will allocate, uses String::from_utf8_lossy() internally for now.
4955 pub fn from_utf8_lossy(s: &'s [u8]) -> Self {
5056 Self::Owned(String::from_utf8_lossy(&s).into())
5157 }
+9
crates/jacquard-common/src/lib.rs
···11+//! Common types for the jacquard implementation of atproto
22+33+#![warn(missing_docs)]
44+55+/// A copy-on-write immutable string type that uses [`SmolStr`] for
66+/// the "owned" variant.
17#[macro_use]
28pub mod cowstr;
39#[macro_use]
1010+/// trait for taking ownership of most borrowed types in jacquard.
411pub mod into_static;
1212+/// Helper macros for common patterns
513pub mod macros;
1414+/// Baseline fundamental AT Protocol data types.
615pub mod types;
716817pub use cowstr::CowStr;
+51-16
crates/jacquard-common/src/types.rs
···11use serde::{Deserialize, Serialize};
2233+/// AT Protocol URI (at://) types and validation
34pub mod aturi;
55+/// Blob references for binary data
46pub mod blob;
77+/// Content Identifier (CID) types for IPLD
58pub mod cid;
99+/// Repository collection trait for records
610pub mod collection;
1111+/// AT Protocol datetime string type
712pub mod datetime;
1313+/// Decentralized Identifier (DID) types and validation
814pub mod did;
1515+/// AT Protocol handle types and validation
916pub mod handle;
1717+/// AT Protocol identifier types (handle or DID)
1018pub mod ident;
1919+/// Integer type with validation
1120pub mod integer;
2121+/// Language tag types per BCP 47
1222pub mod language;
2323+/// CID link wrapper for JSON serialization
1324pub mod link;
2525+/// Namespaced Identifier (NSID) types and validation
1426pub mod nsid;
2727+/// Record key types and validation
1528pub mod recordkey;
2929+/// String types with format validation
1630pub mod string;
3131+/// Timestamp Identifier (TID) types and generation
1732pub mod tid;
3333+/// URI types with scheme validation
1834pub mod uri;
3535+/// Generic data value types for lexicon data model
1936pub mod value;
3737+/// XRPC protocol types and traits
2038pub mod xrpc;
21392240/// Trait for a constant string literal type
···2543 const LITERAL: &'static str;
2644}
27454646+/// top-level domains which are not allowed in at:// handles or dids
2847pub const DISALLOWED_TLDS: &[&str] = &[
2948 ".local",
3049 ".arpa",
···3958 // "should" "never" actually resolve and get registered in production
4059];
41606161+/// checks if a string ends with anything from the provided list of strings.
4262pub fn ends_with(string: impl AsRef<str>, list: &[&str]) -> bool {
4363 let string = string.as_ref();
4464 for item in list {
···51715272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
5373#[serde(rename_all = "kebab-case")]
7474+/// Valid types in the AT protocol [data model](https://atproto.com/specs/data-model). Type marker only, used in concert with `[Data<'_>]`.
5475pub enum DataModelType {
7676+ /// Null type. IPLD type `null`, JSON type `Null`, CBOR Special Value (major 7)
5577 Null,
7878+ /// Boolean type. IPLD type `boolean`, JSON type Boolean, CBOR Special Value (major 7)
5679 Boolean,
8080+ /// Integer type. IPLD type `integer`, JSON type Number, CBOR Special Value (major 7)
5781 Integer,
8282+ /// Byte type. IPLD type `bytes`, in JSON a `{ "$bytes": bytes }` Object, CBOR Byte String (major 2)
5883 Bytes,
8484+ /// CID (content identifier) link. IPLD type `link`, in JSON a `{ "$link": cid }` Object, CBOR CID (tag 42)
5985 CidLink,
8686+ /// Blob type. No special IPLD type. in JSON a `{ "$type": "blob" }` Object. in CBOR a `{ "$type": "blob" }` Map.
6087 Blob,
8888+ /// Array type. IPLD type `list`. JSON type `Array`, CBOR type Array (major 4)
6189 Array,
9090+ /// Object type. IPLD type `map`. JSON type `Object`, CBOR type Map (major 5). keys are always SmolStr.
6291 Object,
6392 #[serde(untagged)]
9393+ /// String type (lots of variants). JSON String, CBOR UTF-8 String (major 3)
6494 String(LexiconStringType),
6595}
66966767-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
6868-#[serde(rename_all = "kebab-case")]
6969-pub enum LexiconType {
7070- Params,
7171- Token,
7272- Ref,
7373- Union,
7474- Unknown,
7575- Record,
7676- Query,
7777- Procedure,
7878- Subscription,
7979- #[serde(untagged)]
8080- DataModel(DataModelType),
8181-}
8282-9797+/// Lexicon string format types for typed strings in the AT Protocol data model
8398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
8499#[serde(rename_all = "kebab-case")]
85100pub enum LexiconStringType {
101101+ /// ISO 8601 datetime string
86102 Datetime,
103103+ /// AT Protocol URI (at://)
87104 AtUri,
105105+ /// Decentralized Identifier
88106 Did,
107107+ /// AT Protocol handle
89108 Handle,
109109+ /// Handle or DID
90110 AtIdentifier,
111111+ /// Namespaced Identifier
91112 Nsid,
113113+ /// Content Identifier
92114 Cid,
115115+ /// BCP 47 language tag
93116 Language,
117117+ /// Timestamp Identifier
94118 Tid,
119119+ /// Record key
95120 RecordKey,
121121+ /// URI with type constraint
96122 Uri(UriType),
123123+ /// Plain string
97124 #[serde(untagged)]
98125 String,
99126}
100127128128+/// URI scheme types for lexicon URI format constraints
101129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102130#[serde(tag = "type")]
103131pub enum UriType {
132132+ /// DID URI (did:)
104133 Did,
134134+ /// AT Protocol URI (at://)
105135 At,
136136+ /// HTTPS URI
106137 Https,
138138+ /// WebSocket Secure URI
107139 Wss,
140140+ /// CID URI
108141 Cid,
142142+ /// DNS name
109143 Dns,
144144+ /// Any valid URI
110145 Any,
111146}
+31-4
crates/jacquard-common/src/types/aturi.rs
···1212use std::sync::LazyLock;
1313use std::{ops::Deref, str::FromStr};
14141515-/// at:// URI type
1515+/// AT Protocol URI (`at://`) for referencing records in repositories
1616+///
1717+/// AT URIs provide a way to reference records using either a DID or handle as the authority.
1818+/// They're not content-addressed, so the record's contents can change over time.
1919+///
2020+/// Format: `at://AUTHORITY[/COLLECTION[/RKEY]][#FRAGMENT]`
2121+/// - Authority: DID or handle identifying the repository (required)
2222+/// - Collection: NSID of the record type (optional)
2323+/// - Record key (rkey): specific record identifier (optional)
2424+/// - Fragment: sub-resource identifier (optional, limited support)
1625///
1717-/// based on the regex here: [](https://github.com/bluesky-social/atproto/blob/main/packages/syntax/src/aturi_validation.ts)
2626+/// Examples:
2727+/// - `at://alice.bsky.social`
2828+/// - `at://did:plc:abc123/app.bsky.feed.post/3jk5`
1829///
1919-/// Doesn't support the query segment, but then neither does the Typescript SDK.
3030+/// See: <https://atproto.com/specs/at-uri-scheme>
2031#[derive(PartialEq, Eq, Debug)]
2132pub struct AtUri<'u> {
2233 inner: Inner<'u>,
···8192 }
8293}
83948484-/// at:// URI path component (current subset)
9595+/// Path component of an AT URI (collection and optional record key)
9696+///
9797+/// Represents the `/COLLECTION[/RKEY]` portion of an AT URI.
8598#[derive(Clone, PartialEq, Eq, Hash, Debug)]
8699pub struct RepoPath<'u> {
100100+ /// Collection NSID (e.g., `app.bsky.feed.post`)
87101 pub collection: Nsid<'u>,
102102+ /// Optional record key identifying a specific record
88103 pub rkey: Option<RecordKey<Rkey<'u>>>,
89104}
90105···99114 }
100115}
101116117117+/// Owned (static lifetime) version of `RepoPath`
102118pub type UriPathBuf = RepoPath<'static>;
103119120120+/// Regex for AT URI validation per AT Protocol spec
104121pub static ATURI_REGEX: LazyLock<Regex> = LazyLock::new(|| {
105122 // Fragment allows: / and \ and other special chars. In raw string, backslashes are literal.
106123 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()
···154171 }
155172 }
156173174174+ /// Infallible constructor for when you know the URI is valid
175175+ ///
176176+ /// Panics on invalid URIs. Use this when manually constructing URIs from trusted sources.
157177 pub fn raw(uri: &'u str) -> Self {
158178 if let Some(parts) = ATURI_REGEX.captures(uri) {
159179 if let Some(authority) = parts.name("authority") {
···275295 })
276296 }
277297298298+ /// Get the full URI as a string slice
278299 pub fn as_str(&self) -> &str {
279300 {
280301 let this = &self.inner.borrow_uri();
···282303 }
283304 }
284305306306+ /// Get the authority component (DID or handle)
285307 pub fn authority(&self) -> &AtIdentifier<'_> {
286308 self.inner.borrow_authority()
287309 }
288310311311+ /// Get the path component (collection and optional rkey)
289312 pub fn path(&self) -> &Option<RepoPath<'_>> {
290313 self.inner.borrow_path()
291314 }
292315316316+ /// Get the fragment component if present
293317 pub fn fragment(&self) -> &Option<CowStr<'_>> {
294318 self.inner.borrow_fragment()
295319 }
296320321321+ /// Get the collection NSID from the path, if present
297322 pub fn collection(&self) -> Option<&Nsid<'_>> {
298323 self.inner.borrow_path().as_ref().map(|p| &p.collection)
299324 }
300325326326+ /// Get the record key from the path, if present
301327 pub fn rkey(&self) -> Option<&RecordKey<Rkey<'_>>> {
302328 self.inner
303329 .borrow_path()
···400426 }
401427 }
402428429429+ /// Fallible constructor, validates, doesn't allocate (static lifetime)
403430 pub fn new_static(uri: &'static str) -> Result<Self, AtStrError> {
404431 let uri = uri.as_ref();
405432 if let Some(parts) = ATURI_REGEX.captures(uri) {
+25-7
crates/jacquard-common/src/types/blob.rs
···1212 str::FromStr,
1313};
14141515+/// Blob reference for binary data in AT Protocol
1616+///
1717+/// Blobs represent uploaded binary data (images, videos, etc.) stored separately from records.
1818+/// They include a CID reference, MIME type, and size information.
1919+///
2020+/// Serialization differs between formats:
2121+/// - JSON: `ref` is serialized as `{"$link": "cid_string"}`
2222+/// - CBOR: `ref` is the raw CID
1523#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
1624#[serde(rename_all = "camelCase")]
1725pub struct Blob<'b> {
2626+ /// CID (Content Identifier) reference to the blob data
1827 pub r#ref: Cid<'b>,
2828+ /// MIME type of the blob (e.g., "image/png", "video/mp4")
1929 #[serde(borrow)]
2030 pub mime_type: MimeType<'b>,
3131+ /// Size of the blob in bytes
2132 pub size: usize,
2233}
2334···6576 }
6677}
67786868-/// Current, typed blob reference.
6969-/// Quite dislike this nesting, but it serves the same purpose as it did in Atrium
7070-/// Couple of helper methods and conversions to make it less annoying.
7171-/// TODO: revisit nesting and maybe hand-roll a serde impl that supports this sans nesting
7979+/// Tagged blob reference with `$type` field for serde
8080+///
8181+/// This enum provides the `{"$type": "blob"}` wrapper expected by AT Protocol's JSON format.
8282+/// Currently only contains the `Blob` variant, but the enum structure supports future extensions.
7283#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
7384#[serde(tag = "$type", rename_all = "lowercase")]
7485pub enum BlobRef<'r> {
8686+ /// Blob variant with embedded blob data
7587 #[serde(borrow)]
7688 Blob(Blob<'r>),
7789}
78907991impl<'r> BlobRef<'r> {
9292+ /// Get the inner blob reference
8093 pub fn blob(&self) -> &Blob<'r> {
8194 match self {
8295 BlobRef::Blob(blob) => blob,
···108121 }
109122}
110123111111-/// Wrapper for file type
124124+/// MIME type identifier for blob data
125125+///
126126+/// Used to specify the content type of blobs. Supports patterns like "image/*" and "*/*".
112127#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
113128#[serde(transparent)]
114129#[repr(transparent)]
···120135 Ok(Self(CowStr::Borrowed(mime_type)))
121136 }
122137138138+ /// Fallible constructor, validates, takes ownership
123139 pub fn new_owned(mime_type: impl AsRef<str>) -> Self {
124140 Self(CowStr::Owned(mime_type.as_ref().to_smolstr()))
125141 }
126142143143+ /// Fallible constructor, validates, doesn't allocate
127144 pub fn new_static(mime_type: &'static str) -> Self {
128145 Self(CowStr::new_static(mime_type))
129146 }
130147131131- /// Fallible constructor from an existing CowStr, borrows
148148+ /// Fallible constructor from an existing CowStr
132149 pub fn from_cowstr(mime_type: CowStr<'m>) -> Result<MimeType<'m>, &'static str> {
133150 Ok(Self(mime_type))
134151 }
135152136136- /// Infallible constructor
153153+ /// Infallible constructor for trusted MIME type strings
137154 pub fn raw(mime_type: &'m str) -> Self {
138155 Self(CowStr::Borrowed(mime_type))
139156 }
140157158158+ /// Get the MIME type as a string slice
141159 pub fn as_str(&self) -> &str {
142160 {
143161 let this = &self.0;
+44-10
crates/jacquard-common/src/types/cid.rs
···44use smol_str::ToSmolStr;
55use std::{convert::Infallible, fmt, marker::PhantomData, ops::Deref, str::FromStr};
6677-/// raw
77+/// CID codec for AT Protocol (raw)
88pub const ATP_CID_CODEC: u64 = 0x55;
991010-/// SHA-256
1010+/// CID hash function for AT Protocol (SHA-256)
1111pub const ATP_CID_HASH: u64 = 0x12;
12121313-/// base 32
1313+/// CID encoding base for AT Protocol (base32 lowercase)
1414pub const ATP_CID_BASE: multibase::Base = multibase::Base::Base32Lower;
15151616-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1717-/// Either the string form of a cid or the ipld form
1818-/// For the IPLD form we also cache the string representation for later use.
1616+/// Content Identifier (CID) for IPLD data in AT Protocol
1717+///
1818+/// CIDs are self-describing content addresses used to reference IPLD data.
1919+/// This type supports both string and parsed IPLD forms, with string caching
2020+/// for the parsed form to optimize serialization.
1921///
2020-/// Default on deserialization matches the format (if we get bytes, we try to decode)
2222+/// Deserialization automatically detects the format (bytes trigger IPLD parsing).
2323+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2124pub enum Cid<'c> {
2222- Ipld { cid: IpldCid, s: CowStr<'c> },
2525+ /// Parsed IPLD CID with cached string representation
2626+ Ipld {
2727+ /// Parsed CID structure
2828+ cid: IpldCid,
2929+ /// Cached base32 string form
3030+ s: CowStr<'c>,
3131+ },
3232+ /// String-only form (not yet parsed)
2333 Str(CowStr<'c>),
2434}
25353636+/// Errors that can occur when working with CIDs
2637#[derive(Debug, thiserror::Error, miette::Diagnostic)]
2738pub enum Error {
3939+ /// Invalid IPLD CID structure
2840 #[error("Invalid IPLD CID {:?}", 0)]
2941 Ipld(#[from] cid::Error),
4242+ /// Invalid UTF-8 in CID string
3043 #[error("{:?}", 0)]
3144 Utf8(#[from] std::str::Utf8Error),
3245}
33463447impl<'c> Cid<'c> {
4848+ /// Parse a CID from bytes (tries IPLD first, falls back to UTF-8 string)
3549 pub fn new(cid: &'c [u8]) -> Result<Self, Error> {
3650 if let Ok(cid) = IpldCid::try_from(cid.as_ref()) {
3751 Ok(Self::ipld(cid))
···4155 }
4256 }
43575858+ /// Parse a CID from bytes into an owned (static lifetime) value
4459 pub fn new_owned(cid: &[u8]) -> Result<Cid<'static>, Error> {
4560 if let Ok(cid) = IpldCid::try_from(cid.as_ref()) {
4661 Ok(Self::ipld(cid))
···5065 }
5166 }
52676868+ /// Construct a CID from a parsed IPLD CID
5369 pub fn ipld(cid: IpldCid) -> Cid<'static> {
5470 let s = CowStr::Owned(
5571 cid.to_string_of_base(ATP_CID_BASE)
···5975 Cid::Ipld { cid, s }
6076 }
61777878+ /// Construct a CID from a string slice (borrows)
6279 pub fn str(cid: &'c str) -> Self {
6380 Self::Str(CowStr::Borrowed(cid))
6481 }
65828383+ /// Construct a CID from a CowStr
6684 pub fn cow_str(cid: CowStr<'c>) -> Self {
6785 Self::Str(cid)
6886 }
69878888+ /// Convert to a parsed IPLD CID (parses if needed)
7089 pub fn to_ipld(&self) -> Result<IpldCid, cid::Error> {
7190 match self {
7291 Cid::Ipld { cid, s: _ } => Ok(cid.clone()),
···7493 }
7594 }
76959696+ /// Get the CID as a string slice
7797 pub fn as_str(&self) -> &str {
7898 match self {
7999 Cid::Ipld { cid: _, s } => s.as_ref(),
···218238 }
219239}
220240221221-/// CID link wrapper that serializes as {"$link": "cid"} in JSON
222222-/// and as raw CID in CBOR
241241+/// CID link wrapper for JSON `{"$link": "cid"}` serialization
242242+///
243243+/// Wraps a `Cid` and handles format-specific serialization:
244244+/// - JSON: `{"$link": "cid_string"}`
245245+/// - CBOR: raw CID bytes
246246+///
247247+/// Used in the AT Protocol data model to represent IPLD links in JSON.
223248#[derive(Debug, Clone, PartialEq, Eq, Hash)]
224249#[repr(transparent)]
225250pub struct CidLink<'c>(pub Cid<'c>);
226251227252impl<'c> CidLink<'c> {
253253+ /// Parse a CID link from bytes
228254 pub fn new(cid: &'c [u8]) -> Result<Self, Error> {
229255 Ok(Self(Cid::new(cid)?))
230256 }
231257258258+ /// Parse a CID link from bytes into an owned value
232259 pub fn new_owned(cid: &[u8]) -> Result<CidLink<'static>, Error> {
233260 Ok(CidLink(Cid::new_owned(cid)?))
234261 }
235262263263+ /// Construct a CID link from a static string
236264 pub fn new_static(cid: &'static str) -> Self {
237265 Self(Cid::str(cid))
238266 }
239267268268+ /// Construct a CID link from a parsed IPLD CID
240269 pub fn ipld(cid: IpldCid) -> CidLink<'static> {
241270 CidLink(Cid::ipld(cid))
242271 }
243272273273+ /// Construct a CID link from a string slice
244274 pub fn str(cid: &'c str) -> Self {
245275 Self(Cid::str(cid))
246276 }
247277278278+ /// Construct a CID link from a CowStr
248279 pub fn cow_str(cid: CowStr<'c>) -> Self {
249280 Self(Cid::cow_str(cid))
250281 }
251282283283+ /// Get the CID as a string slice
252284 pub fn as_str(&self) -> &str {
253285 self.0.as_str()
254286 }
255287288288+ /// Convert to a parsed IPLD CID
256289 pub fn to_ipld(&self) -> Result<IpldCid, cid::Error> {
257290 self.0.to_ipld()
258291 }
259292293293+ /// Unwrap into the inner Cid
260294 pub fn into_inner(self) -> Cid<'c> {
261295 self.0
262296 }
+14-3
crates/jacquard-common/src/types/datetime.rs
···99use crate::{CowStr, IntoStatic};
1010use regex::Regex;
11111212+/// Regex for ISO 8601 datetime validation per AT Protocol spec
1213pub static ISO8601_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1314 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()
1415});
15161616-/// A Lexicon timestamp.
1717+/// AT Protocol datetime (ISO 8601 with specific requirements)
1818+///
1919+/// Lexicon datetimes use ISO 8601 format with these requirements:
2020+/// - Must include timezone (strongly prefer UTC with 'Z')
2121+/// - Requires whole seconds precision minimum
2222+/// - Supports millisecond and microsecond precision
2323+/// - Uses uppercase 'T' to separate date and time
2424+///
2525+/// Examples: `"1985-04-12T23:20:50.123Z"`, `"2023-01-01T00:00:00+00:00"`
2626+///
2727+/// The serialized form is preserved during parsing to ensure exact round-trip serialization.
1728#[derive(Clone, Debug, Eq, Hash)]
1829pub struct Datetime {
1919- /// Serialized form. Preserved during parsing to ensure round-trip re-serialization.
3030+ /// Serialized form preserved from parsing for round-trip consistency
2031 serialized: CowStr<'static>,
2121- /// Parsed form.
3232+ /// Parsed datetime value for comparisons and operations
2233 dt: chrono::DateTime<chrono::FixedOffset>,
2334}
2435
+15
crates/jacquard-common/src/types/did.rs
···77use std::sync::LazyLock;
88use std::{ops::Deref, str::FromStr};
991010+/// Decentralized Identifier (DID) for AT Protocol accounts
1111+///
1212+/// DIDs are the persistent, long-term account identifiers in AT Protocol. Unlike handles,
1313+/// which can change, a DID permanently identifies an account across the network.
1414+///
1515+/// Supported DID methods:
1616+/// - `did:plc` - Bluesky's novel DID method
1717+/// - `did:web` - Based on HTTPS and DNS
1818+///
1919+/// Validation enforces a maximum length of 2048 characters and uses the pattern:
2020+/// `did:[method]:[method-specific-id]` where the method is lowercase ASCII and the
2121+/// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs.
2222+///
2323+/// See: <https://atproto.com/specs/did>
1024#[derive(Clone, PartialEq, Eq, Serialize, Hash)]
1125#[serde(transparent)]
1226#[repr(transparent)]
···94108 Self(CowStr::Borrowed(did))
95109 }
96110111111+ /// Get the DID as a string slice
97112 pub fn as_str(&self) -> &str {
98113 {
99114 let this = &self.0;
+19-2
crates/jacquard-common/src/types/handle.rs
···88use std::sync::LazyLock;
99use std::{ops::Deref, str::FromStr};
10101111+/// AT Protocol handle (human-readable account identifier)
1212+///
1313+/// Handles are user-friendly account identifiers that must resolve to a DID through DNS
1414+/// or HTTPS. Unlike DIDs, handles can change over time, though they remain an important
1515+/// part of user identity.
1616+///
1717+/// Format rules:
1818+/// - Maximum 253 characters
1919+/// - At least two segments separated by dots (e.g., "alice.bsky.social")
2020+/// - Each segment is 1-63 characters of ASCII letters, numbers, and hyphens
2121+/// - Segments cannot start or end with a hyphen
2222+/// - Final segment (TLD) cannot start with a digit
2323+/// - Case-insensitive (normalized to lowercase)
2424+///
2525+/// Certain TLDs are disallowed (.local, .localhost, .arpa, .invalid, .internal, .example, .alt, .onion).
2626+///
2727+/// See: <https://atproto.com/specs/handle>
1128#[derive(Clone, PartialEq, Eq, Serialize, Hash)]
1229#[serde(transparent)]
1330#[repr(transparent)]
1431pub struct Handle<'h>(CowStr<'h>);
15323333+/// Regex for handle validation per AT Protocol spec
1634pub static HANDLE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1735 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()
1836});
1919-2020-/// AT Protocol handle
2137impl<'h> Handle<'h> {
2238 /// Fallible constructor, validates, borrows from input
2339 ///
···127143 Self(CowStr::Borrowed(stripped))
128144 }
129145146146+ /// Get the handle as a string slice
130147 pub fn as_str(&self) -> &str {
131148 {
132149 let this = &self.0;
+10-1
crates/jacquard-common/src/types/ident.rs
···8899use crate::CowStr;
10101111-/// An AT Protocol identifier.
1111+/// AT Protocol identifier (either a DID or handle)
1212+///
1313+/// Represents the union of DIDs and handles, which can both be used to identify
1414+/// accounts in AT Protocol. DIDs are permanent identifiers, while handles are
1515+/// human-friendly and can change.
1616+///
1717+/// Automatically determines whether a string is a DID or a handle during parsing.
1218#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
1319#[serde(untagged)]
1420pub enum AtIdentifier<'i> {
2121+ /// DID variant
1522 #[serde(borrow)]
1623 Did(Did<'i>),
2424+ /// Handle variant
1725 Handle(Handle<'i>),
1826}
1927···7381 }
7482 }
75838484+ /// Get the identifier as a string slice
7685 pub fn as_str(&self) -> &str {
7786 match self {
7887 AtIdentifier::Did(did) => did.as_str(),
+7-2
crates/jacquard-common/src/types/language.rs
···5566use crate::CowStr;
7788-/// An IETF language tag.
88+/// IETF BCP 47 language tag for AT Protocol
99+///
1010+/// Language tags identify natural languages following the BCP 47 standard. They consist of
1111+/// a 2-3 character language code (e.g., "en", "ja") with optional regional subtags (e.g., "pt-BR").
912///
1010-/// Uses langtag crate for validation, but is stored as a SmolStr for size/avoiding allocations
1313+/// Examples: `"ja"` (Japanese), `"pt-BR"` (Brazilian Portuguese), `"en-US"` (US English)
1114///
1515+/// Language tags require semantic parsing rather than simple string comparison.
1616+/// Uses the `langtag` crate for validation but stores as `SmolStr` for efficiency.
1217/// TODO: Implement langtag-style semantic matching for this type, delegating to langtag
1318#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
1419#[serde(transparent)]
+17-3
crates/jacquard-common/src/types/nsid.rs
···88use std::sync::LazyLock;
99use std::{ops::Deref, str::FromStr};
10101111-/// Namespaced Identifier (NSID)
1111+/// Namespaced Identifier (NSID) for Lexicon schemas and XRPC endpoints
1212+///
1313+/// NSIDs provide globally unique identifiers for Lexicon schemas, record types, and XRPC methods.
1414+/// They're structured as reversed domain names with a camelCase name segment.
1515+///
1616+/// Format: `domain.authority.name` (e.g., `com.example.fooBar`)
1717+/// - Domain authority: reversed domain name (≤253 chars, lowercase, dots separate segments)
1818+/// - Name: camelCase identifier (letters and numbers only, cannot start with a digit)
1919+///
2020+/// Validation rules:
2121+/// - Minimum 3 segments
2222+/// - Maximum 317 characters total
2323+/// - Each domain segment is 1-63 characters
2424+/// - Case-sensitive
1225///
1313-/// Stored as SmolStr to ease lifetime issues and because, despite the fact that NSIDs *can* be 317 characters, most are quite short
1414-/// TODO: consider if this should go back to CowStr, or be broken up into segments
2626+/// See: <https://atproto.com/specs/nsid>
1527#[derive(Clone, PartialEq, Eq, Serialize, Hash)]
1628#[serde(transparent)]
1729#[repr(transparent)]
1830pub struct Nsid<'n>(CowStr<'n>);
19313232+/// Regex for NSID validation per AT Protocol spec
2033pub static NSID_REGEX: LazyLock<Regex> = LazyLock::new(|| {
2134 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()
2235});
···100113 &self.0[split + 1..]
101114 }
102115116116+ /// Get the NSID as a string slice
103117 pub fn as_str(&self) -> &str {
104118 {
105119 let this = &self.0;
+30-10
crates/jacquard-common/src/types/recordkey.rs
···99use std::sync::LazyLock;
1010use std::{ops::Deref, str::FromStr};
11111212-/// Trait for generic typed record keys
1212+/// Trait for typed record key implementations
1313///
1414-/// This is deliberately public (so that consumers can develop specialized record key types),
1515-/// but is marked as unsafe, because the implementer is expected to uphold the invariants
1616-/// required by this trait, namely compliance with the [spec](https://atproto.com/specs/record-key)
1717-/// as described by [`RKEY_REGEX`].
1414+/// Allows different record key types (TID, NSID, literals, generic strings) while
1515+/// maintaining validation guarantees. Implementers must ensure compliance with the
1616+/// AT Protocol [record key specification](https://atproto.com/specs/record-key).
1817///
1919-/// This crate provides implementations for TID, NSID, literals, and generic strings
1818+/// # Safety
1919+/// Implementations must ensure the string representation matches [`RKEY_REGEX`] and
2020+/// is not "." or "..". Built-in implementations: `Tid`, `Nsid`, `Literal<T>`, `Rkey<'_>`.
2021pub unsafe trait RecordKeyType: Clone + Serialize {
2222+ /// Get the record key as a string slice
2123 fn as_str(&self) -> &str;
2224}
23252626+/// Wrapper for typed record keys
2727+///
2828+/// Provides a generic container for different record key types while preserving their
2929+/// specific validation guarantees through the `RecordKeyType` trait.
2430#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Debug)]
2531#[serde(transparent)]
2632#[repr(transparent)]
···5662 }
5763}
58645959-/// ATProto Record Key (type `any`)
6060-/// Catch-all for any string meeting the overall Record Key requirements detailed [](https://atproto.com/specs/record-key)
6565+/// AT Protocol record key (generic "any" type)
6666+///
6767+/// Record keys uniquely identify records within a collection. This is the catch-all
6868+/// type for any valid record key string (1-512 characters of alphanumerics, dots,
6969+/// hyphens, underscores, colons, tildes).
7070+///
7171+/// Common record key types:
7272+/// - TID: timestamp-based (most common)
7373+/// - Literal: fixed keys like "self"
7474+/// - NSID: namespaced identifiers
7575+/// - Any: flexible strings matching the validation rules
7676+///
7777+/// See: <https://atproto.com/specs/record-key>
6178#[derive(Clone, PartialEq, Eq, Serialize, Hash)]
6279#[serde(transparent)]
6380#[repr(transparent)]
···6986 }
7087}
71888989+/// Regex for record key validation per AT Protocol spec
7290pub static RKEY_REGEX: LazyLock<Regex> =
7391 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9.\-_:~]{1,512}$").unwrap());
74927575-/// AT Protocol rkey
7693impl<'r> Rkey<'r> {
7794 /// Fallible constructor, validates, borrows from input
7895 pub fn new(rkey: &'r str) -> Result<Self, AtStrError> {
···89106 }
90107 }
911089292- /// Fallible constructor, validates, borrows from input
109109+ /// Fallible constructor, validates, takes ownership
93110 pub fn new_owned(rkey: impl AsRef<str>) -> Result<Self, AtStrError> {
94111 let rkey = rkey.as_ref();
95112 if [".", ".."].contains(&rkey) {
···140157 Self(CowStr::Borrowed(rkey))
141158 }
142159160160+ /// Get the record key as a string slice
143161 pub fn as_str(&self) -> &str {
144162 {
145163 let this = &self.0;
···265283}
266284267285#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
286286+/// Key for a record where only one of an NSID is supposed to exist
268287pub struct SelfRecord;
269288270289impl Literal for SelfRecord {
···326345 }
327346 }
328347348348+ /// Get the literal record key as a string slice
329349 pub fn as_str(&self) -> &str {
330350 T::LITERAL
331351 }
+57-3
crates/jacquard-common/src/types/string.rs
···2121 },
2222};
23232424-/// ATProto string value
2424+/// Polymorphic AT Protocol string value
2525+///
2626+/// Represents any AT Protocol string type, automatically detecting and parsing
2727+/// into the appropriate variant. Used internally for generic value handling.
2828+///
2929+/// Variants are checked in order from most specific to least specific. Note that
3030+/// record keys are intentionally NOT parsed from bare strings as the validation
3131+/// is too permissive and would catch too many values.
2532#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2633pub enum AtprotoStr<'s> {
3434+ /// ISO 8601 datetime
2735 Datetime(Datetime),
3636+ /// BCP 47 language tag
2837 Language(Language),
3838+ /// Timestamp identifier
2939 Tid(Tid),
4040+ /// Namespaced identifier
3041 Nsid(Nsid<'s>),
4242+ /// Decentralized identifier
3143 Did(Did<'s>),
4444+ /// Account handle
3245 Handle(Handle<'s>),
4646+ /// Identifier (DID or handle)
3347 AtIdentifier(AtIdentifier<'s>),
4848+ /// AT URI
3449 AtUri(AtUri<'s>),
5050+ /// Generic URI
3551 Uri(Uri<'s>),
5252+ /// Content identifier
3653 Cid(Cid<'s>),
5454+ /// Record key
3755 RecordKey(RecordKey<Rkey<'s>>),
5656+ /// Plain string (fallback)
3857 String(CowStr<'s>),
3958}
4059···7796 }
7897 }
79989999+ /// Get the string value regardless of variant
80100 pub fn as_str(&self) -> &str {
81101 match self {
82102 Self::Datetime(datetime) => datetime.as_str(),
···238258 help("if something doesn't match the spec, contact the crate author")
239259)]
240260pub struct AtStrError {
261261+ /// AT Protocol spec name this error relates to
241262 pub spec: SmolStr,
263263+ /// The source string that failed to parse
242264 #[source_code]
243265 pub source: String,
266266+ /// The specific kind of parsing error
244267 #[source]
245268 #[diagnostic_source]
246269 pub kind: StrParseKind,
247270}
248271249272impl AtStrError {
273273+ /// Create a new AT string parsing error
250274 pub fn new(spec: &'static str, source: String, kind: StrParseKind) -> Self {
251275 Self {
252276 spec: SmolStr::new_static(spec),
···255279 }
256280 }
257281282282+ /// Wrap an existing error with a new spec context
258283 pub fn wrap(spec: &'static str, source: String, error: AtStrError) -> Self {
259284 if let Some(span) = match &error.kind {
260285 StrParseKind::Disallowed { problem, .. } => problem,
···309334 }
310335 }
311336337337+ /// Create an error for a string that exceeds the maximum length
312338 pub fn too_long(spec: &'static str, source: &str, max: usize, actual: usize) -> Self {
313339 Self {
314340 spec: SmolStr::new_static(spec),
···317343 }
318344 }
319345346346+ /// Create an error for a string below the minimum length
320347 pub fn too_short(spec: &'static str, source: &str, min: usize, actual: usize) -> Self {
321348 Self {
322349 spec: SmolStr::new_static(spec),
···348375 }
349376350377 /// missing component, with the span where it was expected to be founf
378378+ /// Create an error for a missing component at a specific span
351379 pub fn missing_from(
352380 spec: &'static str,
353381 source: &str,
···364392 }
365393 }
366394395395+ /// Create an error for a regex validation failure
367396 pub fn regex(spec: &'static str, source: &str, message: SmolStr) -> Self {
368397 Self {
369398 spec: SmolStr::new_static(spec),
···376405 }
377406}
378407408408+/// Kinds of parsing errors for AT Protocol string types
379409#[derive(Debug, thiserror::Error, miette::Diagnostic)]
380410pub enum StrParseKind {
411411+ /// Regex pattern validation failed
381412 #[error("regex failure - {message}")]
382413 #[diagnostic(code(jacquard::types::string::regex_fail))]
383414 RegexFail {
415415+ /// Optional span highlighting the problem area
384416 #[label]
385417 span: Option<SourceSpan>,
418418+ /// Help message explaining the failure
386419 #[help]
387420 message: SmolStr,
388421 },
422422+ /// String exceeds maximum allowed length
389423 #[error("string too long (allowed: {max}, actual: {actual})")]
390424 #[diagnostic(code(jacquard::types::string::wrong_length))]
391391- TooLong { max: usize, actual: usize },
425425+ TooLong {
426426+ /// Maximum allowed length
427427+ max: usize,
428428+ /// Actual string length
429429+ actual: usize,
430430+ },
392431432432+ /// String is below minimum required length
393433 #[error("string too short (allowed: {min}, actual: {actual})")]
394434 #[diagnostic(code(jacquard::types::string::wrong_length))]
395395- TooShort { min: usize, actual: usize },
435435+ TooShort {
436436+ /// Minimum required length
437437+ min: usize,
438438+ /// Actual string length
439439+ actual: usize,
440440+ },
441441+ /// String contains disallowed values
396442 #[error("disallowed - {message}")]
397443 #[diagnostic(code(jacquard::types::string::disallowed))]
398444 Disallowed {
445445+ /// Optional span highlighting the disallowed content
399446 #[label]
400447 problem: Option<SourceSpan>,
448448+ /// Help message about what's disallowed
401449 #[help]
402450 message: SmolStr,
403451 },
452452+ /// Required component is missing
404453 #[error("missing - {message}")]
405454 #[diagnostic(code(jacquard::atstr::missing_component))]
406455 MissingComponent {
456456+ /// Optional span where the component should be
407457 #[label]
408458 span: Option<SourceSpan>,
459459+ /// Help message about what's missing
409460 #[help]
410461 message: SmolStr,
411462 },
463463+ /// Wraps another error with additional context
412464 #[error("{err:?}")]
413465 #[diagnostic(code(jacquard::atstr::inner))]
414466 Wrap {
467467+ /// Optional span in the outer context
415468 #[label]
416469 span: Option<SourceSpan>,
470470+ /// The wrapped inner error
417471 #[source]
418472 err: Arc<AtStrError>,
419473 },
+26-3
crates/jacquard-common/src/types/tid.rs
···2828 builder.finish()
2929}
30303131+/// Regex for TID validation per AT Protocol spec
3132static TID_REGEX: LazyLock<Regex> = LazyLock::new(|| {
3233 Regex::new(r"^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$").unwrap()
3334});
34353535-/// A [Timestamp Identifier].
3636+/// Timestamp Identifier (TID) for record keys and commit revisions
3737+///
3838+/// TIDs are compact, sortable identifiers based on timestamps. They're used as record keys
3939+/// and repository commit revision numbers in AT Protocol.
4040+///
4141+/// Format:
4242+/// - Always 13 ASCII characters
4343+/// - Base32-sortable encoding (`234567abcdefghijklmnopqrstuvwxyz`)
4444+/// - First 53 bits: microseconds since UNIX epoch
4545+/// - Final 10 bits: random clock identifier for collision resistance
4646+///
4747+/// TIDs are sortable by timestamp and suitable for use in URLs. Generate new TIDs with
4848+/// `Tid::now()` or `Tid::now_with_clock_id()`.
3649///
3737-/// [Timestamp Identifier]: https://atproto.com/specs/tid
5050+/// See: <https://atproto.com/specs/tid>
3851#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
3952#[serde(transparent)]
4053#[repr(transparent)]
···105118 Self(s32_encode(tid))
106119 }
107120121121+ /// Construct a TID from a timestamp (in microseconds) and clock ID
108122 pub fn from_time(timestamp: usize, clkid: u32) -> Self {
109123 let str = smol_str::format_smolstr!(
110124 "{0}{1:2>2}",
···114128 Self(str)
115129 }
116130131131+ /// Extract the timestamp component (microseconds since UNIX epoch)
117132 pub fn timestamp(&self) -> usize {
118133 s32decode(self.0[0..11].to_owned())
119134 }
120135121121- // newer > older
136136+ /// Compare two TIDs chronologically (newer > older)
137137+ ///
138138+ /// Returns 1 if self is newer, -1 if older, 0 if equal
122139 pub fn compare_to(&self, other: &Tid) -> i8 {
123140 if self.0 > other.0 {
124141 return 1;
···129146 0
130147 }
131148149149+ /// Check if this TID is newer than another
132150 pub fn newer_than(&self, other: &Tid) -> bool {
133151 self.compare_to(other) > 0
134152 }
135153154154+ /// Check if this TID is older than another
136155 pub fn older_than(&self, other: &Tid) -> bool {
137156 self.compare_to(other) < 0
138157 }
139158159159+ /// Generate the next TID in sequence after the given TID
140160 pub fn next_str(prev: Option<Tid>) -> Result<Self, AtStrError> {
141161 let prev = match prev {
142162 None => None,
···173193 }
174194}
175195196196+/// Decode a base32-sortable string into a usize
176197pub fn s32decode(s: String) -> usize {
177198 let mut i: usize = 0;
178199 for c in s.chars() {
···273294}
274295275296impl Ticker {
297297+ /// Create a new TID generator with random clock ID
276298 pub fn new() -> Self {
277299 let mut ticker = Self {
278300 last_timestamp: 0,
···284306 ticker
285307 }
286308309309+ /// Generate the next TID, optionally ensuring it's after the given TID
287310 pub fn next(&mut self, prev: Option<Tid>) -> Tid {
288311 let now = SystemTime::now()
289312 .duration_since(SystemTime::UNIX_EPOCH)
+19-2
crates/jacquard-common/src/types/uri.rs
···77 types::{aturi::AtUri, cid::Cid, did::Did, string::AtStrError},
88};
991010-/// URI with best-available contextual type
1111-/// TODO: figure out wtf a DNS uri should look like
1010+/// Generic URI with type-specific parsing
1111+///
1212+/// Automatically detects and parses URIs into the appropriate variant based on
1313+/// the scheme prefix. Used in lexicon where URIs can be of various types.
1414+///
1515+/// Variants are checked by prefix: `did:`, `at://`, `https://`, `wss://`, `ipld://`
1216#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1317pub enum Uri<'u> {
1818+ /// DID URI (did:)
1419 Did(Did<'u>),
2020+ /// AT Protocol URI (at://)
1521 At(AtUri<'u>),
2222+ /// HTTPS URL
1623 Https(Url),
2424+ /// WebSocket Secure URL
1725 Wss(Url),
2626+ /// IPLD CID URI
1827 Cid(Cid<'u>),
2828+ /// Unrecognized URI scheme (catch-all)
1929 Any(CowStr<'u>),
2030}
21313232+/// Errors that can occur when parsing URIs
2233#[derive(Debug, thiserror::Error, miette::Diagnostic)]
2334pub enum UriParseError {
3535+ /// AT Protocol string parsing error
2436 #[error("Invalid atproto string: {0}")]
2537 At(#[from] AtStrError),
3838+ /// Generic URL parsing error
2639 #[error(transparent)]
2740 Url(#[from] url::ParseError),
4141+ /// CID parsing error
2842 #[error(transparent)]
2943 Cid(#[from] crate::types::cid::Error),
3044}
31453246impl<'u> Uri<'u> {
4747+ /// Parse a URI from a string slice, borrowing
3348 pub fn new(uri: &'u str) -> Result<Self, UriParseError> {
3449 if uri.starts_with("did:") {
3550 Ok(Uri::Did(Did::new(uri)?))
···4661 }
4762 }
48636464+ /// Parse a URI from a string, taking ownership
4965 pub fn new_owned(uri: impl AsRef<str>) -> Result<Uri<'static>, UriParseError> {
5066 let uri = uri.as_ref();
5167 if uri.starts_with("did:") {
···6379 }
6480 }
65818282+ /// Get the URI as a string slice
6683 pub fn as_str(&self) -> &str {
6784 match self {
6885 Uri::Did(did) => did.as_str(),
+47
crates/jacquard-common/src/types/value.rs
···77use smol_str::{SmolStr, ToSmolStr};
88use std::collections::BTreeMap;
991010+/// Conversion utilities for Data types
1011pub mod convert;
1212+/// String parsing for AT Protocol types
1113pub mod parsing;
1414+/// Serde implementations for Data types
1215pub mod serde_impl;
13161417#[cfg(test)]
1518mod tests;
16192020+/// AT Protocol data model value
2121+///
2222+/// Represents any valid value in the AT Protocol data model, which supports JSON and CBOR
2323+/// serialization with specific constraints (no floats, CID links, blobs with metadata).
2424+///
2525+/// This is the generic "unknown data" type used for lexicon values, extra fields captured
2626+/// by `#[lexicon]`, and IPLD data structures.
1727#[derive(Debug, Clone, PartialEq, Eq)]
1828pub enum Data<'s> {
2929+ /// Null value
1930 Null,
3131+ /// Boolean value
2032 Boolean(bool),
3333+ /// Integer value (no floats in AT Protocol)
2134 Integer(i64),
3535+ /// String value (parsed into specific AT Protocol types when possible)
2236 String(AtprotoStr<'s>),
3737+ /// Raw bytes
2338 Bytes(Bytes),
3939+ /// CID link reference
2440 CidLink(Cid<'s>),
4141+ /// Array of values
2542 Array(Array<'s>),
4343+ /// Object/map of values
2644 Object(Object<'s>),
4545+ /// Blob reference with metadata
2746 Blob(Blob<'s>),
2847}
29484949+/// Errors that can occur when working with AT Protocol data
3050#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
3151pub enum AtDataError {
5252+ /// Floating point numbers are not allowed in AT Protocol
3253 #[error("floating point numbers not allowed in AT protocol data")]
3354 FloatNotAllowed,
3455}
35563657impl<'s> Data<'s> {
5858+ /// Get the data model type of this value
3759 pub fn data_type(&self) -> DataModelType {
3860 match self {
3961 Data::Null => DataModelType::Null,
···6991 Data::Blob(_) => DataModelType::Blob,
7092 }
7193 }
9494+ /// Parse a Data value from a JSON value
7295 pub fn from_json(json: &'s serde_json::Value) -> Result<Self, AtDataError> {
7396 Ok(if let Some(value) = json.as_bool() {
7497 Self::Boolean(value)
···87110 })
88111 }
89112113113+ /// Parse a Data value from an IPLD value (CBOR)
90114 pub fn from_cbor(cbor: &'s Ipld) -> Result<Self, AtDataError> {
91115 Ok(match cbor {
92116 Ipld::Null => Data::Null,
···121145 }
122146}
123147148148+/// Array of AT Protocol data values
124149#[derive(Debug, Clone, PartialEq, Eq)]
125150pub struct Array<'s>(pub Vec<Data<'s>>);
126151···132157}
133158134159impl<'s> Array<'s> {
160160+ /// Parse an array from JSON values
135161 pub fn from_json(json: &'s Vec<serde_json::Value>) -> Result<Self, AtDataError> {
136162 let mut array = Vec::with_capacity(json.len());
137163 for item in json {
···139165 }
140166 Ok(Self(array))
141167 }
168168+ /// Parse an array from IPLD values (CBOR)
142169 pub fn from_cbor(cbor: &'s Vec<Ipld>) -> Result<Self, AtDataError> {
143170 let mut array = Vec::with_capacity(cbor.len());
144171 for item in cbor {
···148175 }
149176}
150177178178+/// Object/map of AT Protocol data values
151179#[derive(Debug, Clone, PartialEq, Eq)]
152180pub struct Object<'s>(pub BTreeMap<SmolStr, Data<'s>>);
153181···159187}
160188161189impl<'s> Object<'s> {
190190+ /// Parse an object from a JSON map with type inference
191191+ ///
192192+ /// Uses key names to infer the appropriate AT Protocol types for values.
162193 pub fn from_json(
163194 json: &'s serde_json::Map<String, serde_json::Value>,
164195 ) -> Result<Data<'s>, AtDataError> {
···232263 Ok(Data::Object(Object(map)))
233264 }
234265266266+ /// Parse an object from IPLD (CBOR) with type inference
267267+ ///
268268+ /// Uses key names to infer the appropriate AT Protocol types for values.
235269 pub fn from_cbor(cbor: &'s BTreeMap<String, Ipld>) -> Result<Data<'s>, AtDataError> {
236270 if let Some(Ipld::String(type_field)) = cbor.get("$type") {
237271 if parsing::infer_from_type(type_field) == DataModelType::Blob {
···288322/// E.g. lower-level services, PDS implementations, firehose indexers, relay implementations.
289323#[derive(Debug, Clone, PartialEq, Eq)]
290324pub enum RawData<'s> {
325325+ /// Null value
291326 Null,
327327+ /// Boolean value
292328 Boolean(bool),
329329+ /// Signed integer
293330 SignedInt(i64),
331331+ /// Unsigned integer
294332 UnsignedInt(u64),
333333+ /// String value (no type inference)
295334 String(CowStr<'s>),
335335+ /// Raw bytes
296336 Bytes(Bytes),
337337+ /// CID link reference
297338 CidLink(Cid<'s>),
339339+ /// Array of raw values
298340 Array(Vec<RawData<'s>>),
341341+ /// Object/map of raw values
299342 Object(BTreeMap<SmolStr, RawData<'s>>),
343343+ /// Valid blob reference
300344 Blob(Blob<'s>),
345345+ /// Invalid blob structure (captured for debugging)
301346 InvalidBlob(Box<RawData<'s>>),
347347+ /// Invalid number format, generally a floating point number (captured as bytes)
302348 InvalidNumber(Bytes),
349349+ /// Invalid/unknown data (captured as bytes)
303350 InvalidData(Bytes),
304351}
+6
crates/jacquard-common/src/types/value/parsing.rs
···1717use std::{collections::BTreeMap, str::FromStr};
1818use url::Url;
19192020+/// Insert a string into an at:// `Data<'_>` map, inferring its type.
2021pub fn insert_string<'s>(
2122 map: &mut BTreeMap<SmolStr, Data<'s>>,
2223 key: &'s str,
···231232 }
232233}
233234235235+/// Convert an ipld map to a atproto data model blob if it matches the format
234236pub fn cbor_to_blob<'b>(blob: &'b BTreeMap<String, Ipld>) -> Option<Blob<'b>> {
235237 let mime_type = blob.get("mimeType").and_then(|o| {
236238 if let Ipld::String(string) = o {
···267269 None
268270}
269271272272+/// convert a JSON object to an atproto data model blob if it matches the format
270273pub fn json_to_blob<'b>(blob: &'b serde_json::Map<String, serde_json::Value>) -> Option<Blob<'b>> {
271274 let mime_type = blob.get("mimeType").and_then(|v| v.as_str());
272275 if let Some(value) = blob.get("ref") {
···297300 None
298301}
299302303303+/// Infer if something with a "$type" field is a blob or an object
300304pub fn infer_from_type(type_field: &str) -> DataModelType {
301305 match type_field {
302306 "blob" => DataModelType::Blob,
···304308 }
305309}
306310311311+/// decode a base64 byte string into atproto data
307312pub fn decode_bytes<'s>(bytes: &str) -> Data<'s> {
308313 // First one should just work. rest are insurance.
309314 if let Ok(bytes) = BASE64_STANDARD.decode(bytes) {
···319324 }
320325}
321326327327+/// decode a base64 byte string into atproto raw unvalidated data
322328pub fn decode_raw_bytes<'s>(bytes: &str) -> RawData<'s> {
323329 // First one should just work. rest are insurance.
324330 if let Ok(bytes) = BASE64_STANDARD.decode(bytes) {
+1
crates/jacquard-common/src/types/xrpc.rs
···4545 }
4646 }
47474848+ /// Get the body encoding type for this method (procedures only)
4849 pub const fn body_encoding(&self) -> Option<&'static str> {
4950 match self {
5051 Self::Query => None,
+9-4
crates/jacquard/Cargo.toml
···11[package]
22-authors.workspace = true
33-# If you change the name here, you must also do it in flake.nix (and run `cargo generate-lockfile` afterwards)
42name = "jacquard"
55-description = "A simple Rust project using Nix"
33+description = "Simple and powerful AT Procotol implementation"
44+edition.workspace = true
65version.workspace = true
77-edition.workspace = true
66+authors.workspace = true
77+repository.workspace = true
88+keywords.workspace = true
99+categories.workspace = true
1010+readme.workspace = true
1111+documentation.workspace = true
1212+exclude.workspace = true
813914[features]
1015default = ["api_all"]
+45-7
crates/jacquard/src/client.rs
···11+//! XRPC client implementation for AT Protocol
22+//!
33+//! This module provides HTTP and XRPC client traits along with an authenticated
44+//! client implementation that manages session tokens.
55+16mod error;
27mod response;
38···5661 }
5762}
58636464+/// HTTP client trait for sending raw HTTP requests
5965pub trait HttpClient {
6666+ /// Error type returned by the HTTP client
6067 type Error: std::error::Error + Display + Send + Sync + 'static;
6168 /// Send an HTTP request and return the response.
6269 fn send_http(
···6471 request: Request<Vec<u8>>,
6572 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>>;
6673}
6767-/// XRPC client trait
7474+/// XRPC client trait for AT Protocol RPC calls
6875pub trait XrpcClient: HttpClient {
7676+ /// Get the base URI for XRPC requests (e.g., "https://bsky.social")
6977 fn base_uri(&self) -> CowStr<'_>;
7878+ /// Get the authorization token for XRPC requests
7079 #[allow(unused_variables)]
7180 fn authorization_token(
7281 &self,
···9310294103pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession";
95104105105+/// Authorization token types for XRPC requests
96106pub enum AuthorizationToken<'s> {
107107+ /// Bearer token (access JWT, refresh JWT to refresh the session)
97108 Bearer(CowStr<'s>),
109109+ /// DPoP token (proof-of-possession) for OAuth
98110 Dpop(CowStr<'s>),
99111}
100112···109121 }
110122}
111123112112-/// HTTP headers which can be used in XPRC requests.
124124+/// HTTP headers commonly used in XRPC requests
113125pub enum Header {
126126+ /// Content-Type header
114127 ContentType,
128128+ /// Authorization header
115129 Authorization,
130130+ /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate.
131131+ ///
132132+ /// See: <https://atproto.com/specs/xrpc#service-proxying>
116133 AtprotoProxy,
134134+ /// `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.
117135 AtprotoAcceptLabelers,
118136}
119137···210228 Ok(Response::new(buffer, status))
211229}
212230213213-/// Session information from createSession
231231+/// Session information from `com.atproto.server.createSession`
232232+///
233233+/// Contains the access and refresh tokens along with user identity information.
214234#[derive(Debug, Clone)]
215235pub struct Session {
236236+ /// Access token (JWT) used for authenticated requests
216237 pub access_jwt: CowStr<'static>,
238238+ /// Refresh token (JWT) used to obtain new access tokens
217239 pub refresh_jwt: CowStr<'static>,
240240+ /// User's DID (Decentralized Identifier)
218241 pub did: Did<'static>,
242242+ /// User's handle (e.g., "alice.bsky.social")
219243 pub handle: Handle<'static>,
220244}
221245···232256 }
233257}
234258235235-/// Authenticated XRPC client that includes session tokens
259259+/// Authenticated XRPC client wrapper that manages session tokens
260260+///
261261+/// Wraps an HTTP client and adds automatic Bearer token authentication for XRPC requests.
262262+/// Handles both access tokens for regular requests and refresh tokens for session refresh.
236263pub struct AuthenticatedClient<C> {
237264 client: C,
238265 base_uri: CowStr<'static>,
···241268242269impl<C> AuthenticatedClient<C> {
243270 /// Create a new authenticated client with a base URI
271271+ ///
272272+ /// # Example
273273+ /// ```ignore
274274+ /// let client = AuthenticatedClient::new(
275275+ /// reqwest::Client::new(),
276276+ /// CowStr::from("https://bsky.social")
277277+ /// );
278278+ /// ```
244279 pub fn new(client: C, base_uri: CowStr<'static>) -> Self {
245280 Self {
246281 client,
···249284 }
250285 }
251286252252- /// Set the session
287287+ /// Set the session obtained from `createSession` or `refreshSession`
253288 pub fn set_session(&mut self, session: Session) {
254289 self.session = Some(session);
255290 }
256291257257- /// Get the current session
292292+ /// Get the current session if one exists
258293 pub fn session(&self) -> Option<&Session> {
259294 self.session.as_ref()
260295 }
261296262262- /// Clear the session
297297+ /// Clear the current session locally
298298+ ///
299299+ /// Note: This only clears the local session state. To properly revoke the session
300300+ /// server-side, use `com.atproto.server.deleteSession` before calling this.
263301 pub fn clear_session(&mut self) {
264302 self.session = None;
265303 }
+23-1
crates/jacquard/src/client/error.rs
···11+//! Error types for XRPC client operations
22+13use bytes::Bytes;
2433-/// Client error type
55+/// Client error type wrapping all possible error conditions
46#[derive(Debug, thiserror::Error, miette::Diagnostic)]
57pub enum ClientError {
68 /// HTTP transport error
···4446 ),
4547}
46484949+/// Transport-level errors that occur during HTTP communication
4750#[derive(Debug, thiserror::Error, miette::Diagnostic)]
4851pub enum TransportError {
5252+ /// Failed to establish connection to server
4953 #[error("Connection error: {0}")]
5054 Connect(String),
51555656+ /// Request timed out
5257 #[error("Request timeout")]
5358 Timeout,
54596060+ /// Request construction failed (malformed URI, headers, etc.)
5561 #[error("Invalid request: {0}")]
5662 InvalidRequest(String),
57636464+ /// Other transport error
5865 #[error("Transport error: {0}")]
5966 Other(Box<dyn std::error::Error + Send + Sync>),
6067}
···6269// Re-export EncodeError from common
6370pub use jacquard_common::types::xrpc::EncodeError;
64717272+/// Response deserialization errors
6573#[derive(Debug, thiserror::Error, miette::Diagnostic)]
6674pub enum DecodeError {
7575+ /// JSON deserialization failed
6776 #[error("Failed to deserialize JSON: {0}")]
6877 Json(
6978 #[from]
7079 #[source]
7180 serde_json::Error,
7281 ),
8282+ /// CBOR deserialization failed (local I/O)
7383 #[error("Failed to deserialize CBOR: {0}")]
7484 CborLocal(
7585 #[from]
7686 #[source]
7787 serde_ipld_dagcbor::DecodeError<std::io::Error>,
7888 ),
8989+ /// CBOR deserialization failed (remote/reqwest)
7990 #[error("Failed to deserialize CBOR: {0}")]
8091 CborRemote(
8192 #[from]
···8495 ),
8596}
86979898+/// HTTP error response (non-200 status codes outside of XRPC error handling)
8799#[derive(Debug, thiserror::Error, miette::Diagnostic)]
88100pub struct HttpError {
101101+ /// HTTP status code
89102 pub status: http::StatusCode,
103103+ /// Response body if available
90104 pub body: Option<Bytes>,
91105}
92106···102116 }
103117}
104118119119+/// Authentication and authorization errors
105120#[derive(Debug, thiserror::Error, miette::Diagnostic)]
106121pub enum AuthError {
122122+ /// Access token has expired (use refresh token to get a new one)
107123 #[error("Access token expired")]
108124 TokenExpired,
109125126126+ /// Access token is invalid or malformed
110127 #[error("Invalid access token")]
111128 InvalidToken,
112129130130+ /// Token refresh request failed
113131 #[error("Token refresh failed")]
114132 RefreshFailed,
115133134134+ /// Request requires authentication but none was provided
116135 #[error("No authentication provided")]
117136 NotAuthenticated,
137137+138138+ /// Other authentication error
118139 #[error("Authentication error: {0:?}")]
119140 Other(http::HeaderValue),
120141}
121142143143+/// Result type for client operations
122144pub type Result<T> = std::result::Result<T, ClientError>;
123145124146impl From<reqwest::Error> for TransportError {
+29-21
crates/jacquard/src/client/response.rs
···11+//! XRPC response parsing and error handling
22+13use bytes::Bytes;
24use http::StatusCode;
35use jacquard_common::IntoStatic;
66+use jacquard_common::smol_str::SmolStr;
47use jacquard_common::types::xrpc::XrpcRequest;
58use serde::Deserialize;
69use std::marker::PhantomData;
···1013/// XRPC response wrapper that owns the response buffer
1114///
1215/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations.
1616+/// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`).
1317pub struct Response<R: XrpcRequest> {
1418 buffer: Bytes,
1519 status: StatusCode,
···7478 // 401: always auth error
7579 } else {
7680 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
7777- Ok(generic) => {
7878- match generic.error.as_str() {
7979- "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
8080- "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
8181- _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
8282- }
8383- }
8181+ Ok(generic) => match generic.error.as_str() {
8282+ "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
8383+ "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
8484+ _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
8585+ },
8486 Err(e) => Err(XrpcError::Decode(e)),
8587 }
8688 }
···120122 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
121123 Ok(generic) => {
122124 // Map auth-related errors to AuthError
123123- match generic.error.as_str() {
125125+ match generic.error.as_ref() {
124126 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
125127 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
126128 _ => Err(XrpcError::Generic(generic)),
···133135 // 401: always auth error
134136 } else {
135137 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
136136- Ok(generic) => {
137137- match generic.error.as_str() {
138138- "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
139139- "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
140140- _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
141141- }
142142- }
138138+ Ok(generic) => match generic.error.as_ref() {
139139+ "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
140140+ "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
141141+ _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
142142+ },
143143 Err(e) => Err(XrpcError::Decode(e)),
144144 }
145145 }
···151151 }
152152}
153153154154-/// Generic XRPC error format (for InvalidRequest, etc.)
154154+/// Generic XRPC error format for untyped errors like InvalidRequest
155155+///
156156+/// Used when the error doesn't match the endpoint's specific error enum
155157#[derive(Debug, Clone, Deserialize)]
156158pub struct GenericXrpcError {
157157- pub error: String,
158158- pub message: Option<String>,
159159+ /// Error code (e.g., "InvalidRequest")
160160+ pub error: SmolStr,
161161+ /// Optional error message with details
162162+ pub message: Option<SmolStr>,
159163}
160164161165impl std::fmt::Display for GenericXrpcError {
···170174171175impl std::error::Error for GenericXrpcError {}
172176177177+/// XRPC-specific errors returned from endpoints
178178+///
179179+/// Represents errors returned in the response body
180180+/// Type parameter `E` is the endpoint's specific error enum type.
173181#[derive(Debug, thiserror::Error, miette::Diagnostic)]
174182pub enum XrpcError<E: std::error::Error + IntoStatic> {
175175- /// Typed XRPC error from the endpoint's error enum
183183+ /// Typed XRPC error from the endpoint's specific error enum
176184 #[error("XRPC error: {0}")]
177185 Xrpc(E),
178186···180188 #[error("Authentication error: {0}")]
181189 Auth(#[from] AuthError),
182190183183- /// Generic XRPC error (InvalidRequest, etc.)
191191+ /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest)
184192 #[error("XRPC error: {0}")]
185193 Generic(GenericXrpcError),
186194187187- /// Failed to decode response
195195+ /// Failed to decode the response body
188196 #[error("Failed to decode response: {0}")]
189197 Decode(#[from] serde_json::Error),
190198}
+7-1
crates/jacquard/src/lib.rs
···11+#![doc = include_str!("../../../README.md")]
22+#![warn(missing_docs)]
33+44+/// XRPC client traits and basic implementation
15pub mod client;
2633-// Re-export common types
47#[cfg(feature = "api")]
88+/// If enabled, re-export the generated api crate
59pub use jacquard_api as api;
1010+/// Re-export common types
611pub use jacquard_common::*;
712813#[cfg(feature = "derive")]
1414+/// if enabled, reexport the attribute macros
915pub use jacquard_derive::*;
+1-1
crates/jacquard/src/main.rs
···27272828 // Create HTTP client
2929 let http = reqwest::Client::new();
3030- let mut client = AuthenticatedClient::new(http, CowStr::from(args.pds));
3030+ let mut client = AuthenticatedClient::new(http, args.pds);
31313232 // Create session
3333 println!("logging in as {}...", args.username);