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