···100 ListRule(Box<crate::app_bsky::feed::threadgate::ListRule<'a>>),
101}
102000000000000000000000000000000103impl jacquard_common::types::collection::Collection for Threadgate<'_> {
104 const NSID: &'static str = "app.bsky.feed.threadgate";
00000000105}
106107///Allow replies from actors mentioned in your post.
···100 ListRule(Box<crate::app_bsky::feed::threadgate::ListRule<'a>>),
101}
102103+/// Typed wrapper for GetRecord response with this collection's record type.
104+#[derive(
105+ serde::Serialize,
106+ serde::Deserialize,
107+ Debug,
108+ Clone,
109+ PartialEq,
110+ Eq,
111+ jacquard_derive::IntoStatic
112+)]
113+#[serde(rename_all = "camelCase")]
114+pub struct ThreadgateGetRecordOutput<'a> {
115+ #[serde(skip_serializing_if = "std::option::Option::is_none")]
116+ #[serde(borrow)]
117+ pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>,
118+ #[serde(borrow)]
119+ pub uri: jacquard_common::types::string::AtUri<'a>,
120+ #[serde(borrow)]
121+ pub value: Threadgate<'a>,
122+}
123+124+/// Marker type for deserializing records from this collection.
125+pub struct ThreadgateRecord;
126+impl jacquard_common::xrpc::XrpcResp for ThreadgateRecord {
127+ const NSID: &'static str = "app.bsky.feed.threadgate";
128+ const ENCODING: &'static str = "application/json";
129+ type Output<'de> = ThreadgateGetRecordOutput<'de>;
130+ type Err<'de> = jacquard_common::types::collection::RecordError<'de>;
131+}
132+133impl jacquard_common::types::collection::Collection for Threadgate<'_> {
134 const NSID: &'static str = "app.bsky.feed.threadgate";
135+ type Record = ThreadgateRecord;
136+}
137+138+impl From<ThreadgateGetRecordOutput<'_>> for Threadgate<'static> {
139+ fn from(output: ThreadgateGetRecordOutput<'_>) -> Self {
140+ use jacquard_common::IntoStatic;
141+ output.value.into_static()
142+ }
143}
144145///Allow replies from actors mentioned in your post.
···1-//! Common types for the jacquard implementation of atproto
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000023#![warn(missing_docs)]
004pub use cowstr::CowStr;
5pub use into_static::IntoStatic;
6pub use smol_str;
···21pub mod session;
22/// Baseline fundamental AT Protocol data types.
23pub mod types;
24-/// XRPC protocol types and traits
25pub mod xrpc;
2627/// Authorization token types for XRPC requests.
···1+//! # Common types for the jacquard implementation of atproto
2+//!
3+//! ## Working with Lifetimes and Zero-Copy Deserialization
4+//!
5+//! Jacquard is designed around zero-copy deserialization: types like `Post<'de>` can borrow
6+//! strings and other data directly from the response buffer instead of allocating owned copies.
7+//! This is great for performance, but it creates some interesting challenges when combined with
8+//! async Rust and trait bounds.
9+//!
10+//! ### The Problem: Lifetimes + Async + Traits
11+//!
12+//! The naive approach would be to put a lifetime parameter on the trait itself:
13+//!
14+//! ```ignore
15+//! trait XrpcRequest<'de> {
16+//! type Output: Deserialize<'de>;
17+//! // ...
18+//! }
19+//! ```
20+//!
21+//! This looks reasonable until you try to use it in a generic context. If you have a function
22+//! that works with *any* lifetime, you need a Higher-Ranked Trait Bound (HRTB):
23+//!
24+//! ```ignore
25+//! fn foo<R>(response: &[u8])
26+//! where
27+//! R: for<'any> XrpcRequest<'any>
28+//! {
29+//! // deserialize from response...
30+//! }
31+//! ```
32+//!
33+//! The `for<'any>` bound says "this type must implement `XrpcRequest` for *every possible lifetime*",
34+//! which is effectively the same as requiring `DeserializeOwned`. You've just thrown away your
35+//! zero-copy optimization, and this also won't work on most of the types in jacquard. The vast
36+//! majority of them have either a custom Deserialize implementation which will borrow if it
37+//! can, a #[serde(borrow)] attribute on one or more fields, or an equivalent lifetime bound
38+//! attribute, associated with the Deserialize derive macro.
39+//!
40+//! It gets worse with async. If you want to return borrowed data from an async method, where does
41+//! the lifetime come from? The response buffer needs to outlive the borrow, but the buffer is
42+//! consumed by the HTTP call. You end up with "cannot infer appropriate lifetime" errors or even
43+//! more confusing errors because the compiler can't prove the buffer will stay alive. You *could*
44+//! do some lifetime laundering with `unsafe`, but you don't actually *need* to tell rustc to "trust
45+//! me, bro", you can, with some cleverness, explain this to the compiler in a way that it can
46+//! reason about perfectly well.
47+//!
48+//! ### Explaining where the buffer goes to `rustc`: GATs + Method-Level Lifetimes
49+//!
50+//! The fix is to use Generic Associated Types (GATs) on the trait's associated types, while keeping
51+//! the trait itself lifetime-free:
52+//!
53+//! ```ignore
54+//! trait XrpcResp {
55+//! const NSID: &'static str;
56+//!
57+//! // GATs: lifetime is on the associated type, not the trait
58+//! type Output<'de>: Deserialize<'de> + IntoStatic;
59+//! type Err<'de>: Deserialize<'de> + IntoStatic;
60+//! }
61+//! ```
62+//!
63+//! Now you can write trait bounds without HRTBs:
64+//!
65+//! ```ignore
66+//! fn foo<R: XrpcResp>(response: &[u8]) {
67+//! // Compiler can pick a concrete lifetime for R::Output<'_>
68+//! }
69+//! ```
70+//!
71+//! Methods that need lifetimes use method-level generic parameters:
72+//!
73+//! ```ignore
74+//! // This is part of a trait from jacquard itself, used to genericize updates to the Bluesky
75+//! // preferences union, so that if you implement a similar lexicon type in your AppView or App
76+//! // Server API, you don't have to special-case it.
77+//!
78+//! trait VecUpdate {
79+//! type GetRequest<'de>: XrpcRequest<'de>; // GAT
80+//! type PutRequest<'de>: XrpcRequest<'de>; // GAT
81+//!
82+//! // Method-level lifetime, not trait-level
83+//! fn extract_vec<'s>(
84+//! output: <Self::GetRequest<'s> as XrpcRequest<'s>>::Output<'s>
85+//! ) -> Vec<Self::Item>;
86+//! }
87+//! ```
88+//!
89+//! The compiler can monomorphize for concrete lifetimes instead of trying to prove bounds hold
90+//! for *all* lifetimes at once.
91+//!
92+//! ### Handling Async with `Response<R: XrpcResp>`
93+//!
94+//! For the async problem, we use a wrapper type that owns the response buffer:
95+//!
96+//! ```ignore
97+//! pub struct Response<R: XrpcResp> {
98+//! buffer: Bytes, // Refcounted, cheap to clone
99+//! status: StatusCode,
100+//! _marker: PhantomData<R>,
101+//! }
102+//! ```
103+//!
104+//! This lets async methods return a `Response` that owns its buffer, then the *caller* decides
105+//! the lifetime strategy:
106+//!
107+//! ```ignore
108+//! // Zero-copy: borrow from the owned buffer
109+//! let output: R::Output<'_> = response.parse()?;
110+//!
111+//! // Owned: convert to 'static via IntoStatic
112+//! let output: R::Output<'static> = response.into_output()?;
113+//! ```
114+//!
115+//! The async method doesn't need to know or care about lifetimes - it just returns the `Response`.
116+//! The caller gets full control over whether to use borrowed or owned data. It can even decide
117+//! after the fact that it doesn't want to parse out the API response type that it asked for. Instead
118+//! it can call `.parse_data()` or `.parse_raw()` on the response to get loosely typed, validated
119+//! data or minimally typed maximally accepting data values out.
120+//!
121+//! ### Example: XRPC Traits in Practice
122+//!
123+//! Here's how the pattern works with the XRPC layer:
124+//!
125+//! ```ignore
126+//! // XrpcResp uses GATs, not trait-level lifetime
127+//! trait XrpcResp {
128+//! const NSID: &'static str;
129+//! type Output<'de>: Deserialize<'de> + IntoStatic;
130+//! type Err<'de>: Deserialize<'de> + IntoStatic;
131+//! }
132+//!
133+//! // Response owns the buffer (Bytes is refcounted)
134+//! pub struct Response<R: XrpcResp> {
135+//! buffer: Bytes,
136+//! status: StatusCode,
137+//! _marker: PhantomData<R>,
138+//! }
139+//!
140+//! impl<R: XrpcResp> Response<R> {
141+//! // Borrow from owned buffer
142+//! pub fn parse(&self) -> XrpcResult<R::Output<'_>> {
143+//! serde_json::from_slice(&self.buffer)
144+//! }
145+//!
146+//! // Convert to fully owned
147+//! pub fn into_output(self) -> XrpcResult<R::Output<'static>> {
148+//! let borrowed = self.parse()?;
149+//! Ok(borrowed.into_static())
150+//! }
151+//! }
152+//!
153+//! // Async method returns Response, caller chooses strategy
154+//! async fn send_xrpc<Req>(&self, req: Req) -> Result<Response<Req::Response>>
155+//! where
156+//! Req: XrpcRequest<'_>
157+//! {
158+//! // Do HTTP call, get Bytes buffer
159+//! // Return Response wrapping that buffer
160+//! // No lifetime issues - Response owns the buffer
161+//! }
162+//!
163+//! // Usage:
164+//! let response = send_xrpc(request).await?;
165+//!
166+//! // Zero-copy: borrow from response buffer
167+//! let output = response.parse()?; // Output<'_> borrows from response
168+//!
169+//! // Or owned: convert to 'static
170+//! let output = response.into_output()?; // Output<'static> is fully owned
171+//! ```
172+//!
173+//! When you see types like `Response<R: XrpcResp>` or methods with lifetime parameters,
174+//! this is the pattern at work. It looks a bit funky, but it's solving a specific problem
175+//! in a way that doesn't require unsafe code or much actual work from you, if you're using it.
176+//! It's also not too bad to write, once you're aware of the pattern and why it works. If you run
177+//! into a lifetime/borrowing inference issue in jacquard, please contact the crate author. She'd
178+//! be happy to debug, and if it's using a method from one of the jacquard crates and seems like
179+//! it *should* just work, that is a bug in jacquard, and you should [file an issue](https://tangled.org/@nonbinary.computer/jacquard/).
180181#![warn(missing_docs)]
182+pub use bytes;
183+pub use chrono;
184pub use cowstr::CowStr;
185pub use into_static::IntoStatic;
186pub use smol_str;
···201pub mod session;
202/// Baseline fundamental AT Protocol data types.
203pub mod types;
204+// XRPC protocol types and traits
205pub mod xrpc;
206207/// Authorization token types for XRPC requests.
+38-1
crates/jacquard-common/src/types/collection.rs
···1use core::fmt;
23-use serde::Serialize;
4005use crate::types::{
6 aturi::RepoPath,
7 nsid::Nsid,
8 recordkey::{RecordKey, RecordKeyType, Rkey},
9};
01011/// Trait for a collection of records that can be stored in a repository.
12///
···16pub trait Collection: fmt::Debug + Serialize {
17 /// The NSID for the Lexicon that defines the schema of records in this collection.
18 const NSID: &'static str;
00001920 /// Returns the [`Nsid`] for the Lexicon that defines the schema of records in this
21 /// collection.
···49 }
50 }
51}
000000000000000000000000000000
···1use core::fmt;
23+use serde::{Deserialize, Serialize};
45+use crate::IntoStatic;
6+use crate::types::value::Data;
7use crate::types::{
8 aturi::RepoPath,
9 nsid::Nsid,
10 recordkey::{RecordKey, RecordKeyType, Rkey},
11};
12+use crate::xrpc::XrpcResp;
1314/// Trait for a collection of records that can be stored in a repository.
15///
···19pub trait Collection: fmt::Debug + Serialize {
20 /// The NSID for the Lexicon that defines the schema of records in this collection.
21 const NSID: &'static str;
22+23+ /// A marker type implementing [`XrpcResp`] that allows typed deserialization of records
24+ /// from this collection. Used by [`Agent::get_record`] to return properly typed responses.
25+ type Record: XrpcResp;
2627 /// Returns the [`Nsid`] for the Lexicon that defines the schema of records in this
28 /// collection.
···56 }
57 }
58}
59+60+/// Generic error type for record retrieval operations.
61+///
62+/// Used by generated collection marker types as their error type.
63+#[derive(
64+ Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error, miette::Diagnostic,
65+)]
66+#[serde(tag = "error", content = "message")]
67+pub enum RecordError<'a> {
68+ /// The requested record was not found
69+ #[error("RecordNotFound")]
70+ #[serde(rename = "RecordNotFound")]
71+ RecordNotFound(Option<String>),
72+ /// An unknown error occurred
73+ #[error("Unknown")]
74+ #[serde(rename = "Unknown")]
75+ #[serde(borrow)]
76+ Unknown(Data<'a>),
77+}
78+79+impl IntoStatic for RecordError<'_> {
80+ type Output = RecordError<'static>;
81+82+ fn into_static(self) -> Self::Output {
83+ match self {
84+ RecordError::RecordNotFound(msg) => RecordError::RecordNotFound(msg),
85+ RecordError::Unknown(data) => RecordError::Unknown(data.into_static()),
86+ }
87+ }
88+}
···358/// similar to `serde_json::from_value()`.
359///
360/// # Example
361-/// ```ignore
362-/// use jacquard_common::types::value::{Data, from_data};
363-/// use serde::Deserialize;
364-///
365/// #[derive(Deserialize)]
366/// struct Post<'a> {
367/// #[serde(borrow)]
···370/// author: &'a str,
371/// }
372///
373-/// let data: Data = /* ... */;
00374/// let post: Post = from_data(&data)?;
00375/// ```
376pub fn from_data<'de, T>(data: &'de Data<'de>) -> Result<T, DataDeserializerError>
377where
···385/// Allows extracting strongly-typed structures from untyped `RawData` values.
386///
387/// # Example
388-/// ```ignore
389-/// use jacquard_common::types::value::{RawData, from_raw_data};
390-/// use serde::Deserialize;
391-///
392-/// #[derive(Deserialize)]
393-/// struct Post<'a> {
394-/// #[serde(borrow)]
395-/// text: &'a str,
396-/// #[serde(borrow)]
397-/// author: &'a str,
398/// }
399///
400-/// let data: RawData = /* ... */;
00401/// let post: Post = from_raw_data(&data)?;
00402/// ```
403pub fn from_raw_data<'de, T>(data: &'de RawData<'de>) -> Result<T, DataDeserializerError>
404where
···412/// Allows converting strongly-typed structures into untyped `RawData` values.
413///
414/// # Example
415-/// ```ignore
416-/// use jacquard_common::types::value::{RawData, to_raw_data};
417-/// use serde::Serialize;
418-///
419/// #[derive(Serialize)]
420/// struct Post {
421/// text: String,
422/// likes: i64,
423/// }
424///
0425/// let post = Post { text: "hello".to_string(), likes: 42 };
426/// let data: RawData = to_raw_data(&post)?;
00427/// ```
428pub fn to_raw_data<T>(value: &T) -> Result<RawData<'static>, serde_impl::RawDataSerializerError>
429where
···437/// Combines `to_raw_data()` and validation/type inference in one step.
438///
439/// # Example
440-/// ```ignore
441-/// use jacquard_common::types::value::{Data, to_data};
442-/// use serde::Serialize;
443-///
444/// #[derive(Serialize)]
445/// struct Post {
446/// text: String,
447/// did: String, // Will be inferred as Did if valid
448/// }
449///
0450/// let post = Post {
451/// text: "hello".to_string(),
452/// did: "did:plc:abc123".to_string()
453/// };
454/// let data: Data = to_data(&post)?;
00455/// ```
456pub fn to_data<T>(value: &T) -> Result<Data<'static>, convert::ConversionError>
457where
···358/// similar to `serde_json::from_value()`.
359///
360/// # Example
361+/// ```
362+/// # use jacquard_common::types::value::{Data, from_data};
363+/// # use serde::Deserialize;
364+/// #
365/// #[derive(Deserialize)]
366/// struct Post<'a> {
367/// #[serde(borrow)]
···370/// author: &'a str,
371/// }
372///
373+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
374+/// # let json = serde_json::json!({"text": "hello", "author": "alice"});
375+/// # let data = Data::from_json(&json)?;
376/// let post: Post = from_data(&data)?;
377+/// # Ok(())
378+/// # }
379/// ```
380pub fn from_data<'de, T>(data: &'de Data<'de>) -> Result<T, DataDeserializerError>
381where
···389/// Allows extracting strongly-typed structures from untyped `RawData` values.
390///
391/// # Example
392+/// ```
393+/// # use jacquard_common::types::value::{RawData, from_raw_data, to_raw_data};
394+/// # use serde::{Serialize, Deserialize};
395+/// #
396+/// #[derive(Serialize, Deserialize)]
397+/// struct Post {
398+/// text: String,
399+/// author: String,
00400/// }
401///
402+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
403+/// # let orig = Post { text: "hello".to_string(), author: "alice".to_string() };
404+/// # let data = to_raw_data(&orig)?;
405/// let post: Post = from_raw_data(&data)?;
406+/// # Ok(())
407+/// # }
408/// ```
409pub fn from_raw_data<'de, T>(data: &'de RawData<'de>) -> Result<T, DataDeserializerError>
410where
···418/// Allows converting strongly-typed structures into untyped `RawData` values.
419///
420/// # Example
421+/// ```
422+/// # use jacquard_common::types::value::{RawData, to_raw_data};
423+/// # use serde::Serialize;
424+/// #
425/// #[derive(Serialize)]
426/// struct Post {
427/// text: String,
428/// likes: i64,
429/// }
430///
431+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
432/// let post = Post { text: "hello".to_string(), likes: 42 };
433/// let data: RawData = to_raw_data(&post)?;
434+/// # Ok(())
435+/// # }
436/// ```
437pub fn to_raw_data<T>(value: &T) -> Result<RawData<'static>, serde_impl::RawDataSerializerError>
438where
···446/// Combines `to_raw_data()` and validation/type inference in one step.
447///
448/// # Example
449+/// ```
450+/// # use jacquard_common::types::value::{Data, to_data};
451+/// # use serde::Serialize;
452+/// #
453/// #[derive(Serialize)]
454/// struct Post {
455/// text: String,
456/// did: String, // Will be inferred as Did if valid
457/// }
458///
459+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
460/// let post = Post {
461/// text: "hello".to_string(),
462/// did: "did:plc:abc123".to_string()
463/// };
464/// let data: Data = to_data(&post)?;
465+/// # Ok(())
466+/// # }
467/// ```
468pub fn to_data<T>(value: &T) -> Result<Data<'static>, convert::ConversionError>
469where
+46-51
crates/jacquard-common/src/xrpc.rs
···1-//! Stateless XRPC utilities and request/response mapping
2//!
3//! Mapping overview:
4//! - Success (2xx): parse body into the endpoint's typed output.
···9//! can inspect `error="invalid_token"` or `error="use_dpop_nonce"` and refresh/retry.
10//! If the header is absent, parse the body and map auth errors to
11//! `AuthError::TokenExpired`/`InvalidToken`.
12-//!
13use bytes::Bytes;
14use http::{
15 HeaderName, HeaderValue, Request, StatusCode,
···135///
136/// It is implemented by the code generation on a marker struct, like the client-side [XrpcResp] trait.
137pub trait XrpcEndpoint {
138- /// Fully-qualified path ('/xrpc/[nsid]') where this endpoint should live on the server
139 const PATH: &'static str;
140 /// XRPC method (query/GET or procedure/POST)
141 const METHOD: XrpcMethod;
···195/// Extension for stateless XRPC calls on any `HttpClient`.
196///
197/// Example
198-/// ```ignore
199-/// use jacquard::client::XrpcExt;
200-/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
201-/// use jacquard::types::ident::AtIdentifier;
202-/// use miette::IntoDiagnostic;
203///
204-/// #[tokio::main]
205-/// async fn main() -> miette::Result<()> {
206-/// let http = reqwest::Client::new();
207-/// let base = url::Url::parse("https://public.api.bsky.app")?;
208-/// let resp = http
209-/// .xrpc(base)
210-/// .send(
211-/// GetAuthorFeed::new()
212-/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
213-/// .limit(5)
214-/// .build(),
215-/// )
216-/// .await?;
217-/// let out = resp.into_output()?;
218-/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
219-/// Ok(())
220-/// }
221/// ```
222pub trait XrpcExt: HttpClient {
223 /// Start building an XRPC call for the given base URL.
···256/// Stateless XRPC call builder.
257///
258/// Example (per-request overrides)
259-/// ```ignore
260-/// use jacquard::client::{XrpcExt, AuthorizationToken};
261-/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
262-/// use jacquard::types::ident::AtIdentifier;
263-/// use jacquard::CowStr;
264-/// use miette::IntoDiagnostic;
265///
266-/// #[tokio::main]
267-/// async fn main() -> miette::Result<()> {
268-/// let http = reqwest::Client::new();
269-/// let base = url::Url::parse("https://public.api.bsky.app")?;
270-/// let resp = http
271-/// .xrpc(base)
272-/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
273-/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
274-/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
275-/// .send(
276-/// GetAuthorFeed::new()
277-/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
278-/// .limit(5)
279-/// .build(),
280-/// )
281-/// .await?;
282-/// let out = resp.into_output()?;
283-/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
284-/// Ok(())
285-/// }
286/// ```
287pub struct XrpcCall<'a, C: HttpClient> {
288 pub(crate) client: &'a C,
···684 }
685 Err(e) => Err(XrpcError::Decode(e)),
686 }
0000000000000000000687 }
688 }
689}
···1+//! # Stateless XRPC utilities and request/response mapping
2//!
3//! Mapping overview:
4//! - Success (2xx): parse body into the endpoint's typed output.
···9//! can inspect `error="invalid_token"` or `error="use_dpop_nonce"` and refresh/retry.
10//! If the header is absent, parse the body and map auth errors to
11//! `AuthError::TokenExpired`/`InvalidToken`.
012use bytes::Bytes;
13use http::{
14 HeaderName, HeaderValue, Request, StatusCode,
···134///
135/// It is implemented by the code generation on a marker struct, like the client-side [XrpcResp] trait.
136pub trait XrpcEndpoint {
137+ /// Fully-qualified path ('/xrpc/\[nsid\]') where this endpoint should live on the server
138 const PATH: &'static str;
139 /// XRPC method (query/GET or procedure/POST)
140 const METHOD: XrpcMethod;
···194/// Extension for stateless XRPC calls on any `HttpClient`.
195///
196/// Example
197+/// ```no_run
198+/// # #[tokio::main]
199+/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
200+/// use jacquard_common::xrpc::XrpcExt;
201+/// use jacquard_common::http_client::HttpClient;
202///
203+/// let http = reqwest::Client::new();
204+/// let base = url::Url::parse("https://public.api.bsky.app")?;
205+/// // let resp = http.xrpc(base).send(&request).await?;
206+/// # Ok(())
207+/// # }
000000000000208/// ```
209pub trait XrpcExt: HttpClient {
210 /// Start building an XRPC call for the given base URL.
···243/// Stateless XRPC call builder.
244///
245/// Example (per-request overrides)
246+/// ```no_run
247+/// # #[tokio::main]
248+/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
249+/// use jacquard_common::xrpc::XrpcExt;
250+/// use jacquard_common::{AuthorizationToken, CowStr};
0251///
252+/// let http = reqwest::Client::new();
253+/// let base = url::Url::parse("https://public.api.bsky.app")?;
254+/// let call = http
255+/// .xrpc(base)
256+/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
257+/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
258+/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"));
259+/// // let resp = call.send(&request).await?;
260+/// # Ok(())
261+/// # }
0000000000262/// ```
263pub struct XrpcCall<'a, C: HttpClient> {
264 pub(crate) client: &'a C,
···660 }
661 Err(e) => Err(XrpcError::Decode(e)),
662 }
663+ }
664+ }
665+666+ /// Reinterpret this response as a different response type.
667+ ///
668+ /// This transmutes the response by keeping the same buffer and status code,
669+ /// but changing the type-level marker. Useful for converting generic XRPC responses
670+ /// into collection-specific typed responses.
671+ ///
672+ /// # Safety
673+ ///
674+ /// This is safe in the sense that no memory unsafety occurs, but logical correctness
675+ /// depends on ensuring the buffer actually contains data that can deserialize to `NEW`.
676+ /// Incorrect conversion will cause deserialization errors at runtime.
677+ pub fn transmute<NEW: XrpcResp>(self) -> Response<NEW> {
678+ Response {
679+ buffer: self.buffer,
680+ status: self.status,
681+ _marker: PhantomData,
682 }
683 }
684}
+77-25
crates/jacquard-derive/src/lib.rs
···000000000000000000000000000000000000000000000000000000001use proc_macro::TokenStream;
2use quote::quote;
3use syn::{Data, DeriveInput, Fields, GenericParam, parse_macro_input};
···160 let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
161162 // Build the Output type with all lifetimes replaced by 'static
163- let output_generics = generics.params.iter().map(|param| {
164- match param {
165- GenericParam::Lifetime(_) => quote! { 'static },
166- GenericParam::Type(ty) => {
167- let ident = &ty.ident;
168- quote! { #ident }
169- }
170- GenericParam::Const(c) => {
171- let ident = &c.ident;
172- quote! { #ident }
173- }
174 }
175 });
176···182183 // Generate the conversion body based on struct/enum
184 let conversion = match &input.data {
185- Data::Struct(data_struct) => {
186- generate_struct_conversion(name, &data_struct.fields)
187- }
188- Data::Enum(data_enum) => {
189- generate_enum_conversion(name, data_enum)
190- }
191 Data::Union(_) => {
192- return syn::Error::new_spanned(
193- input,
194- "IntoStatic cannot be derived for unions"
195- )
196- .to_compile_error()
197- .into();
198 }
199 };
200···239 }
240}
241242-fn generate_enum_conversion(name: &syn::Ident, data_enum: &syn::DataEnum) -> proc_macro2::TokenStream {
000243 let variants = data_enum.variants.iter().map(|variant| {
244 let variant_name = &variant.ident;
245 match &variant.fields {
···258 }
259 Fields::Unnamed(fields) => {
260 let field_bindings: Vec<_> = (0..fields.unnamed.len())
261- .map(|i| syn::Ident::new(&format!("field_{}", i), proc_macro2::Span::call_site()))
00262 .collect();
263 let field_conversions = field_bindings.iter().map(|binding| {
264 quote! { #binding.into_static() }
···1+//! # Derive macros for jacquard lexicon types
2+//!
3+//! This crate provides attribute macros that the code generator uses to add lexicon-specific
4+//! behavior to generated types. You'll rarely need to use these directly unless you're writing
5+//! custom lexicon types by hand. However, deriving IntoStatic will likely be very useful.
6+//!
7+//! ## Macros
8+//!
9+//! ### `#[lexicon]`
10+//!
11+//! Adds an `extra_data` field to structs to capture unknown fields during deserialization.
12+//! This makes objects "open" - they'll accept and preserve fields not defined in the schema.
13+//!
14+//! ```ignore
15+//! #[lexicon]
16+//! struct Post<'s> {
17+//! text: &'s str,
18+//! }
19+//! // Expands to add:
20+//! // #[serde(flatten)]
21+//! // pub extra_data: BTreeMap<SmolStr, Data<'s>>
22+//! ```
23+//!
24+//! ### `#[open_union]`
25+//!
26+//! Adds an `Unknown(Data)` variant to enums to make them extensible unions. This lets
27+//! enums accept variants not defined in your code, storing them as loosely typed atproto `Data`.
28+//!
29+//! ```ignore
30+//! #[open_union]
31+//! enum RecordEmbed<'s> {
32+//! #[serde(rename = "app.bsky.embed.images")]
33+//! Images(Images),
34+//! }
35+//! // Expands to add:
36+//! // #[serde(untagged)]
37+//! // Unknown(Data<'s>)
38+//! ```
39+//!
40+//! ### `#[derive(IntoStatic)]`
41+//!
42+//! Derives conversion from borrowed (`'a`) to owned (`'static`) types by recursively calling
43+//! `.into_static()` on all fields. Works with structs and enums.
44+//!
45+//! ```ignore
46+//! #[derive(IntoStatic)]
47+//! struct Post<'a> {
48+//! text: CowStr<'a>,
49+//! }
50+//! // Generates:
51+//! // impl IntoStatic for Post<'_> {
52+//! // type Output = Post<'static>;
53+//! // fn into_static(self) -> Self::Output { ... }
54+//! // }
55+//! ```
56+57use proc_macro::TokenStream;
58use quote::quote;
59use syn::{Data, DeriveInput, Fields, GenericParam, parse_macro_input};
···216 let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
217218 // Build the Output type with all lifetimes replaced by 'static
219+ let output_generics = generics.params.iter().map(|param| match param {
220+ GenericParam::Lifetime(_) => quote! { 'static },
221+ GenericParam::Type(ty) => {
222+ let ident = &ty.ident;
223+ quote! { #ident }
224+ }
225+ GenericParam::Const(c) => {
226+ let ident = &c.ident;
227+ quote! { #ident }
00228 }
229 });
230···236237 // Generate the conversion body based on struct/enum
238 let conversion = match &input.data {
239+ Data::Struct(data_struct) => generate_struct_conversion(name, &data_struct.fields),
240+ Data::Enum(data_enum) => generate_enum_conversion(name, data_enum),
0000241 Data::Union(_) => {
242+ return syn::Error::new_spanned(input, "IntoStatic cannot be derived for unions")
243+ .to_compile_error()
244+ .into();
000245 }
246 };
247···286 }
287}
288289+fn generate_enum_conversion(
290+ name: &syn::Ident,
291+ data_enum: &syn::DataEnum,
292+) -> proc_macro2::TokenStream {
293 let variants = data_enum.variants.iter().map(|variant| {
294 let variant_name = &variant.ident;
295 match &variant.fields {
···308 }
309 Fields::Unnamed(fields) => {
310 let field_bindings: Vec<_> = (0..fields.unnamed.len())
311+ .map(|i| {
312+ syn::Ident::new(&format!("field_{}", i), proc_macro2::Span::call_site())
313+ })
314 .collect();
315 let field_conversions = field_bindings.iter().map(|binding| {
316 quote! { #binding.into_static() }
+64-11
crates/jacquard-identity/src/lib.rs
···1-//! Identity resolution utilities: DID and handle resolution, DID document fetch,
2-//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
3-//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
000000000000000000004//!
5-//! Fallback order (default):
6-//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC
7-//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured).
8-//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured),
9-//! then Slingshot mini‑doc (partial) if configured.
10//!
11-//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
12-//! and optionally validate the document `id` against the requested DID.
000000000000000000000000000000000000001314// use crate::CowStr; // not currently needed directly here
15pub mod resolver;
···387 }
388 PlcSource::Slingshot { base } => base.join(did.as_str())?,
389 };
390- println!("Fetching DID document from {}", url);
391 if let Ok((buf, status)) = self.get_json_bytes(url).await {
392 return Ok(DidDocResponse {
393 buffer: buf,
···1+//! Identity resolution for the AT Protocol
2+//!
3+//! Jacquard's handle-to-DID and DID-to-document resolution with configurable
4+//! fallback chains.
5+//!
6+//! ## Quick start
7+//!
8+//! ```no_run
9+//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
10+//! use jacquard_identity::{PublicResolver, resolver::IdentityResolver};
11+//! use jacquard_common::types::string::Handle;
12+//!
13+//! let resolver = PublicResolver::default();
14+//!
15+//! // Resolve handle to DID
16+//! let did = resolver.resolve_handle(&Handle::new("alice.bsky.social")?).await?;
17+//!
18+//! // Fetch DID document
19+//! let doc_response = resolver.resolve_did_doc(&did).await?;
20+//! let doc = doc_response.parse()?; // Borrow from response buffer
21+//! # Ok(())
22+//! # }
23+//! ```
24//!
25+//! ## Resolution fallback order
000026//!
27+//! **Handle → DID** (configurable via [`resolver::HandleStep`]):
28+//! 1. DNS TXT record at `_atproto.{handle}` (if `dns` feature enabled)
29+//! 2. HTTPS well-known at `https://{handle}/.well-known/atproto-did`
30+//! 3. PDS XRPC `com.atproto.identity.resolveHandle` (if PDS configured)
31+//! 4. Public API fallback (`https://public.api.bsky.app`)
32+//! 5. Slingshot `resolveHandle` (if configured)
33+//!
34+//! **DID → Document** (configurable via [`resolver::DidStep`]):
35+//! 1. `did:web` HTTPS well-known
36+//! 2. PLC directory HTTP (for `did:plc`)
37+//! 3. PDS XRPC `com.atproto.identity.resolveDid` (if PDS configured)
38+//! 4. Slingshot mini-doc (partial document)
39+//!
40+//! ## Customization
41+//!
42+//! ```
43+//! use jacquard_identity::JacquardResolver;
44+//! use jacquard_identity::resolver::{ResolverOptions, PlcSource};
45+//!
46+//! let opts = ResolverOptions {
47+//! plc_source: PlcSource::slingshot_default(),
48+//! public_fallback_for_handle: true,
49+//! validate_doc_id: true,
50+//! ..Default::default()
51+//! };
52+//!
53+//! let resolver = JacquardResolver::new(reqwest::Client::new(), opts);
54+//! #[cfg(feature = "dns")]
55+//! let resolver = resolver.with_system_dns(); // Enable DNS TXT resolution
56+//! ```
57+//!
58+//! ## Response types
59+//!
60+//! Resolution methods return wrapper types that own the response buffer, allowing
61+//! zero-copy parsing:
62+//!
63+//! - [`resolver::DidDocResponse`] - Full DID document response
64+//! - [`MiniDocResponse`] - Slingshot mini-doc response (partial)
65+//!
66+//! Both support `.parse()` for borrowing and validation.
6768// use crate::CowStr; // not currently needed directly here
69pub mod resolver;
···441 }
442 PlcSource::Slingshot { base } => base.join(did.as_str())?,
443 };
0444 if let Ok((buf, status)) = self.get_json_bytes(url).await {
445 return Ok(DidDocResponse {
446 buffer: buf,
+100-19
crates/jacquard-lexicon/src/codegen/structs.rs
···6use proc_macro2::TokenStream;
7use quote::quote;
89-use super::utils::{make_ident, value_to_variant_name};
10use super::CodeGenerator;
01112/// Enum variant kind for IntoStatic generation
13#[derive(Debug, Clone)]
···52 match field_type {
53 LexObjectProperty::Union(union) => {
54 // Skip empty, single-variant unions unless they're self-referential
55- if !union.refs.is_empty() && (union.refs.len() > 1 || self.is_self_referential_union(nsid, &type_name, union)) {
56- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
000057 let refs: Vec<_> = union.refs.iter().cloned().collect();
58- let union_def =
59- self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
0000060 unions.push(union_def);
61 }
62 }
63 LexObjectProperty::Object(nested_obj) => {
64- let object_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
065 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?;
66 unions.push(obj_def);
67 }
···69 if let LexArrayItem::Union(union) = &array.items {
70 // Skip single-variant array unions
71 if union.refs.len() > 1 {
72- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "Item");
0073 let refs: Vec<_> = union.refs.iter().cloned().collect();
74- let union_def = self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
00000075 unions.push(union_def);
76 }
77 }
···80 }
81 }
820000000000000000000000000000000000000000000000083 // Generate Collection trait impl
84 let collection_impl = quote! {
85 impl jacquard_common::types::collection::Collection for #ident<'_> {
86 const NSID: &'static str = #nsid;
087 }
88 };
8990 Ok(quote! {
91 #struct_def
92 #(#unions)*
0093 #collection_impl
094 })
95 }
96 }
···127 match field_type {
128 LexObjectProperty::Union(union) => {
129 // Skip empty, single-variant unions unless they're self-referential
130- if !union.refs.is_empty() && (union.refs.len() > 1 || self.is_self_referential_union(nsid, &type_name, union)) {
131- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
0000132 let refs: Vec<_> = union.refs.iter().cloned().collect();
133 let union_def =
134 self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
···136 }
137 }
138 LexObjectProperty::Object(nested_obj) => {
139- let object_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
0140 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?;
141 unions.push(obj_def);
142 }
···144 if let LexArrayItem::Union(union) = &array.items {
145 // Skip single-variant array unions
146 if union.refs.len() > 1 {
147- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "Item");
0148 let refs: Vec<_> = union.refs.iter().cloned().collect();
149- let union_def = self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
0150 unions.push(union_def);
151 }
152 }
···296 };
297298 // Parse ref to get NSID and def name
299- let (ref_nsid_str, ref_def) = if let Some((nsid, fragment)) = normalized_ref.split_once('#') {
300- (nsid, fragment)
301- } else {
302- (normalized_ref.as_str(), "main")
303- };
0304305 // Skip unknown refs - they'll be handled by Unknown variant
306 if !self.corpus.ref_exists(&normalized_ref) {
···329 // For other fragments, include the last NSID segment to avoid collisions
330 // e.g. app.bsky.embed.images#view -> ImagesView
331 // app.bsky.embed.video#view -> VideoView
332- format!("{}{}", last_segment.to_pascal_case(), ref_def.to_pascal_case())
0000333 };
334335 variant_infos.push(VariantInfo {
···6use proc_macro2::TokenStream;
7use quote::quote;
809use super::CodeGenerator;
10+use super::utils::{make_ident, value_to_variant_name};
1112/// Enum variant kind for IntoStatic generation
13#[derive(Debug, Clone)]
···52 match field_type {
53 LexObjectProperty::Union(union) => {
54 // Skip empty, single-variant unions unless they're self-referential
55+ if !union.refs.is_empty()
56+ && (union.refs.len() > 1
57+ || self.is_self_referential_union(nsid, &type_name, union))
58+ {
59+ let union_name =
60+ self.generate_field_type_name(nsid, &type_name, field_name, "");
61 let refs: Vec<_> = union.refs.iter().cloned().collect();
62+ let union_def = self.generate_union(
63+ nsid,
64+ &union_name,
65+ &refs,
66+ None,
67+ union.closed,
68+ )?;
69 unions.push(union_def);
70 }
71 }
72 LexObjectProperty::Object(nested_obj) => {
73+ let object_name =
74+ self.generate_field_type_name(nsid, &type_name, field_name, "");
75 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?;
76 unions.push(obj_def);
77 }
···79 if let LexArrayItem::Union(union) = &array.items {
80 // Skip single-variant array unions
81 if union.refs.len() > 1 {
82+ let union_name = self.generate_field_type_name(
83+ nsid, &type_name, field_name, "Item",
84+ );
85 let refs: Vec<_> = union.refs.iter().cloned().collect();
86+ let union_def = self.generate_union(
87+ nsid,
88+ &union_name,
89+ &refs,
90+ None,
91+ union.closed,
92+ )?;
93 unions.push(union_def);
94 }
95 }
···98 }
99 }
100101+ // Generate typed GetRecordOutput wrapper
102+ let output_type_name = format!("{}GetRecordOutput", type_name);
103+ let output_type_ident =
104+ syn::Ident::new(&output_type_name, proc_macro2::Span::call_site());
105+106+ let output_wrapper = quote! {
107+ /// Typed wrapper for GetRecord response with this collection's record type.
108+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)]
109+ #[serde(rename_all = "camelCase")]
110+ pub struct #output_type_ident<'a> {
111+ #[serde(skip_serializing_if = "std::option::Option::is_none")]
112+ #[serde(borrow)]
113+ pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>,
114+ #[serde(borrow)]
115+ pub uri: jacquard_common::types::string::AtUri<'a>,
116+ #[serde(borrow)]
117+ pub value: #ident<'a>,
118+ }
119+ };
120+121+ // Generate marker struct for XrpcResp
122+ let record_marker_name = format!("{}Record", type_name);
123+ let record_marker_ident =
124+ syn::Ident::new(&record_marker_name, proc_macro2::Span::call_site());
125+126+ let record_marker = quote! {
127+ /// Marker type for deserializing records from this collection.
128+ pub struct #record_marker_ident;
129+130+ impl jacquard_common::xrpc::XrpcResp for #record_marker_ident {
131+ const NSID: &'static str = #nsid;
132+ const ENCODING: &'static str = "application/json";
133+ type Output<'de> = #output_type_ident<'de>;
134+ type Err<'de> = jacquard_common::types::collection::RecordError<'de>;
135+ }
136+137+138+ };
139+ let from_impl = quote! {
140+ impl From<#output_type_ident<'_>> for #ident<'static> {
141+ fn from(output: #output_type_ident<'_>) -> Self {
142+ use jacquard_common::IntoStatic;
143+ output.value.into_static()
144+ }
145+ }
146+ };
147+148 // Generate Collection trait impl
149 let collection_impl = quote! {
150 impl jacquard_common::types::collection::Collection for #ident<'_> {
151 const NSID: &'static str = #nsid;
152+ type Record = #record_marker_ident;
153 }
154 };
155156 Ok(quote! {
157 #struct_def
158 #(#unions)*
159+ #output_wrapper
160+ #record_marker
161 #collection_impl
162+ #from_impl
163 })
164 }
165 }
···196 match field_type {
197 LexObjectProperty::Union(union) => {
198 // Skip empty, single-variant unions unless they're self-referential
199+ if !union.refs.is_empty()
200+ && (union.refs.len() > 1
201+ || self.is_self_referential_union(nsid, &type_name, union))
202+ {
203+ let union_name =
204+ self.generate_field_type_name(nsid, &type_name, field_name, "");
205 let refs: Vec<_> = union.refs.iter().cloned().collect();
206 let union_def =
207 self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
···209 }
210 }
211 LexObjectProperty::Object(nested_obj) => {
212+ let object_name =
213+ self.generate_field_type_name(nsid, &type_name, field_name, "");
214 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?;
215 unions.push(obj_def);
216 }
···218 if let LexArrayItem::Union(union) = &array.items {
219 // Skip single-variant array unions
220 if union.refs.len() > 1 {
221+ let union_name =
222+ self.generate_field_type_name(nsid, &type_name, field_name, "Item");
223 let refs: Vec<_> = union.refs.iter().cloned().collect();
224+ let union_def =
225+ self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
226 unions.push(union_def);
227 }
228 }
···372 };
373374 // Parse ref to get NSID and def name
375+ let (ref_nsid_str, ref_def) =
376+ if let Some((nsid, fragment)) = normalized_ref.split_once('#') {
377+ (nsid, fragment)
378+ } else {
379+ (normalized_ref.as_str(), "main")
380+ };
381382 // Skip unknown refs - they'll be handled by Unknown variant
383 if !self.corpus.ref_exists(&normalized_ref) {
···406 // For other fragments, include the last NSID segment to avoid collisions
407 // e.g. app.bsky.embed.images#view -> ImagesView
408 // app.bsky.embed.video#view -> VideoView
409+ format!(
410+ "{}{}",
411+ last_segment.to_pascal_case(),
412+ ref_def.to_pascal_case()
413+ )
414 };
415416 variant_infos.push(VariantInfo {
+38
crates/jacquard-lexicon/src/lib.rs
···000000000000000000000000000000000000001pub mod codegen;
2pub mod corpus;
3pub mod error;
···1+//! # Lexicon schema parsing and Rust code generation for the Jacquard atproto ecosystem
2+//!
3+//! This crate also provides lexicon fetching capabilitiees ofr
4+//!
5+//! ## Usage
6+//!
7+//! ### Fetch lexicons
8+//!
9+//! The `lex-fetch` binary downloads lexicons from configured sources and
10+//! runs the code generation pipeline on them:
11+//!
12+//! ```bash
13+//! cargo run -p jacquard-lexicon --bin lex-fetch
14+//! ```
15+//!
16+//! Configuration lives in `lexicons.kdl` at the workspace root.
17+//!
18+//! ### Generate Rust code
19+//!
20+//! The `jacquard-codegen` binary can be pointed at a local directory to
21+//! runs the code generation pipeline:
22+//!
23+//! ```bash
24+//! cargo run -p jacquard-lexicon --bin jacquard-codegen -- \
25+//! -i ./lexicons \
26+//! -o ./crates/jacquard-api/src
27+//! ```
28+//!
29+//!
30+//! ## Modules
31+//!
32+//! - [`codegen`] - Rust code generation from parsed schemas
33+//! - [`corpus`] - Lexicon corpus management and namespace organization
34+//! - [`lexicon`] - Schema parsing and validation
35+//! - [`union_registry`] - Tracks union types for collision detection
36+//! - [`fetch`] - Ingests lexicons from git, atproto, http fetch, and other sources
37+//! - [`fs`] - Filesystem utilities for lexicon storage
38+39pub mod codegen;
40pub mod corpus;
41pub mod error;
···1-//! Core OAuth 2.1 (AT Protocol profile) types and helpers for Jacquard.
2-//! Transport, discovery, and orchestration live in `jacquard`.
00000000000000000000000000000000000000000000034pub mod atproto;
5pub mod authstore;
···1+//! # Jacquard OAuth 2.1 implementation for the AT Protocol
2+//!
3+//! Implements the AT Protocol OAuth profile, including DPoP (Demonstrating
4+//! Proof-of-Possession), PKCE, PAR (Pushed Authorization Requests), and token management.
5+//!
6+//!
7+//! ## Authentication flow
8+//!
9+//! ```no_run
10+//! # #[cfg(feature = "loopback")]
11+//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
12+//! use jacquard_oauth::client::OAuthClient;
13+//! use jacquard_oauth::session::ClientData;
14+//! use jacquard_oauth::atproto::AtprotoClientMetadata;
15+//! use jacquard_oauth::loopback::LoopbackConfig;
16+//! use jacquard_oauth::authstore::MemoryAuthStore;
17+//!
18+//! let store = MemoryAuthStore::new();
19+//!
20+//! // Create client with metadata
21+//! let client_data = ClientData {
22+//! keyset: None, // Will generate ES256 keypair if needed
23+//! config: AtprotoClientMetadata::default_localhost(),
24+//! };
25+//! let oauth = OAuthClient::new(store, client_data);
26+//!
27+//! // Start auth flow (with loopback feature)
28+//! let session = oauth.login_with_local_server(
29+//! "alice.bsky.social",
30+//! Default::default(),
31+//! LoopbackConfig::default(),
32+//! ).await?;
33+//!
34+//! // Session handles token refresh automatically
35+//! # Ok(())
36+//! # }
37+//! ```
38+//!
39+//! ## AT Protocol specifics
40+//!
41+//! The AT Protocol OAuth profile adds:
42+//! - Required DPoP for all token requests
43+//! - PAR (Pushed Authorization Requests) for better security
44+//! - Specific scope format (`atproto`, `transition:generic`, etc.)
45+//! - Server metadata discovery at `/.well-known/oauth-authorization-server`
46+//!
47+//! See [`atproto`] module for AT Protocol-specific metadata helpers.
4849pub mod atproto;
50pub mod authstore;
···389}
390391#[async_trait::async_trait]
392-impl jacquard_common::session::SessionStore<
0393 crate::client::credential_session::SessionKey,
394 crate::client::AtpSession,
395 > for FileAuthStore
···452#[cfg(test)]
453mod tests {
454 use super::*;
455- use crate::client::credential_session::SessionKey;
456 use crate::client::AtpSession;
0457 use jacquard_common::types::string::{Did, Handle};
458 use std::fs;
459 use std::path::PathBuf;
···389}
390391#[async_trait::async_trait]
392+impl
393+ jacquard_common::session::SessionStore<
394 crate::client::credential_session::SessionKey,
395 crate::client::AtpSession,
396 > for FileAuthStore
···453#[cfg(test)]
454mod tests {
455 use super::*;
0456 use crate::client::AtpSession;
457+ use crate::client::credential_session::SessionKey;
458 use jacquard_common::types::string::{Did, Handle};
459 use std::fs;
460 use std::path::PathBuf;
···1+/// Bluesky actor preferences implementation
2+pub mod preferences;
3+4+pub use preferences::PreferencesUpdate;
5+6+use jacquard_common::IntoStatic;
7+use jacquard_common::xrpc::{XrpcRequest, XrpcResp};
8+9+/// Trait for get-modify-put patterns on vec-based data structures.
10+///
11+/// This trait enables convenient update operations for endpoints that return arrays
12+/// that need to be fetched, modified, and put back. Common use cases include
13+/// preferences, saved feeds, and similar collection-style data.
14+///
15+/// # Example
16+///
17+/// ```ignore
18+/// use jacquard::client::vec_update::VecUpdate;
19+///
20+/// struct PreferencesUpdate;
21+///
22+/// impl VecUpdate for PreferencesUpdate {
23+/// type GetRequest = GetPreferences;
24+/// type PutRequest = PutPreferences;
25+/// type Item = PreferencesItem<'static>;
26+///
27+/// fn extract_vec(output: GetPreferencesOutput<'_>) -> Vec<Self::Item> {
28+/// output.preferences.into_iter().map(|p| p.into_static()).collect()
29+/// }
30+///
31+/// fn build_put(items: Vec<Self::Item>) -> PutPreferences {
32+/// PutPreferences { preferences: items }
33+/// }
34+///
35+/// fn matches(a: &Self::Item, b: &Self::Item) -> bool {
36+/// // Match by enum variant discriminant
37+/// std::mem::discriminant(a) == std::mem::discriminant(b)
38+/// }
39+/// }
40+/// ```
41+pub trait VecUpdate {
42+ /// The XRPC request type for fetching the data
43+ type GetRequest<'de>: XrpcRequest<'de>;
44+45+ /// The XRPC request type for putting the data back
46+ type PutRequest<'de>: XrpcRequest<'de>;
47+48+ /// The item type contained in the vec (must be owned/static)
49+ type Item: IntoStatic;
50+51+ /// Build the get request
52+ fn build_get<'s>() -> Self::GetRequest<'s>;
53+54+ /// Extract the vec from the get response output
55+ fn extract_vec<'s>(
56+ output: <<Self::GetRequest<'s> as XrpcRequest<'s>>::Response as XrpcResp>::Output<'s>,
57+ ) -> Vec<Self::Item>;
58+59+ /// Build the put request from the modified vec
60+ fn build_put<'s>(items: Vec<Self::Item>) -> Self::PutRequest<'s>;
61+62+ /// Check if two items match (for single-item update operations)
63+ ///
64+ /// This is used by `update_vec_item` to find and replace a single item in the vec.
65+ /// For example, preferences might match by enum variant discriminant.
66+ fn matches<'s>(a: &'s Self::Item, b: &'s Self::Item) -> bool;
67+}
···1+use jacquard_api::app_bsky::actor::PreferencesItem;
2+use jacquard_api::app_bsky::actor::get_preferences::{GetPreferences, GetPreferencesOutput};
3+use jacquard_api::app_bsky::actor::put_preferences::PutPreferences;
4+use jacquard_common::IntoStatic;
5+6+/// VecUpdate implementation for Bluesky actor preferences.
7+///
8+/// Provides get-modify-put operations on user preferences, which are stored
9+/// as a vec of preference items (each identified by enum discriminant).
10+///
11+/// # Example
12+///
13+/// ```ignore
14+/// use jacquard::client::vec_update::PreferencesUpdate;
15+/// use jacquard_api::app_bsky::actor::PreferencesItem;
16+///
17+/// // Update all preferences
18+/// agent.update_vec::<PreferencesUpdate>(|prefs| {
19+/// // Add a new preference
20+/// prefs.push(PreferencesItem::AdultContentPref(
21+/// Box::new(AdultContentPref { enabled: true })
22+/// ));
23+///
24+/// // Remove by variant
25+/// prefs.retain(|p| !matches!(p, PreferencesItem::InterestsPref(_)));
26+/// }).await?;
27+///
28+/// // Update a single preference (replaces by discriminant)
29+/// let pref = PreferencesItem::AdultContentPref(
30+/// Box::new(AdultContentPref { enabled: false })
31+/// );
32+/// agent.update_vec_item::<PreferencesUpdate>(pref).await?;
33+/// ```
34+pub struct PreferencesUpdate;
35+36+impl super::VecUpdate for PreferencesUpdate {
37+ type GetRequest<'de> = GetPreferences;
38+ type PutRequest<'de> = PutPreferences<'de>;
39+ type Item = PreferencesItem<'static>;
40+41+ fn build_get<'s>() -> Self::GetRequest<'s> {
42+ GetPreferences::new().build()
43+ }
44+45+ fn extract_vec<'s>(
46+ output: GetPreferencesOutput<'s>,
47+ ) -> Vec<<Self::Item as IntoStatic>::Output> {
48+ output
49+ .preferences
50+ .into_iter()
51+ .map(|p| p.into_static())
52+ .collect()
53+ }
54+55+ fn build_put<'s>(items: Vec<<Self::Item as IntoStatic>::Output>) -> Self::PutRequest<'s> {
56+ PutPreferences::new().preferences(items).build()
57+ }
58+59+ fn matches<'s>(a: &'s Self::Item, b: &'s Self::Item) -> bool {
60+ // Match preferences by enum variant discriminant
61+ std::mem::discriminant(a) == std::mem::discriminant(b)
62+ }
63+}
+17-13
crates/jacquard/src/lib.rs
···49//! async fn main() -> miette::Result<()> {
50//! let args = Args::parse();
51//!
52-//! // File-backed auth store shared by OAuthClient and session registry
53-//! let store = FileAuthStore::new(&args.store);
54-//! let client_data = jacquard_oauth::session::ClientData {
55-//! keyset: None,
56-//! // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
57-//! // The localhost helper will ensure you have at least "atproto" and will fix urls
58-//! config: AtprotoClientMetadata::default_localhost(),
59-//! };
60-//!
61-//! // Build an OAuth client (this is reusable, and can create multiple sessions)
62-//! let oauth = OAuthClient::new(store, client_data);
63//! // Authenticate with a PDS, using a loopback server to handle the callback flow
64//! # #[cfg(feature = "loopback")]
65//! let session = oauth
···155//! Ok(())
156//! }
157//! ```
0000000000000158159#![warn(missing_docs)]
160161-/// XRPC client traits and basic implementation
162pub mod client;
1630164#[cfg(feature = "api")]
165/// If enabled, re-export the generated api crate
166pub use jacquard_api as api;
167-pub use jacquard_common::*;
168169#[cfg(feature = "derive")]
170/// if enabled, reexport the attribute macros
···49//! async fn main() -> miette::Result<()> {
50//! let args = Args::parse();
51//!
52+//! // Build an OAuth client with file-backed auth store and default localhost config
53+//! let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
00000000054//! // Authenticate with a PDS, using a loopback server to handle the callback flow
55//! # #[cfg(feature = "loopback")]
56//! let session = oauth
···146//! Ok(())
147//! }
148//! ```
149+//!
150+//! ## Component Crates
151+//!
152+//! Jacquard is split into several crates for modularity. The main `jacquard` crate
153+//! re-exports most of the others, so you typically only need to depend on it directly.
154+//!
155+//! - [`jacquard-common`] - AT Protocol types (DIDs, handles, at-URIs, NSIDs, TIDs, CIDs, etc.)
156+//! - [`jacquard-api`] - Generated API bindings from 646+ lexicon schemas
157+//! - [`jacquard-axum`] - Server-side XRPC handler extractors for Axum framework (not re-exported, depends on jacquard)
158+//! - [`jacquard-oauth`] - OAuth/DPoP flow implementation with session management
159+//! - [`jacquard-identity`] - Identity resolution (handle→DID, DID→Doc, OAuth metadata)
160+//! - [`jacquard-lexicon`] - Lexicon resolution, fetching, parsing and Rust code generation from schemas
161+//! - [`jacquard-derive`] - Macros (`#[lexicon]`, `#[open_union]`, `#[derive(IntoStatic)]`)
162163#![warn(missing_docs)]
1640165pub mod client;
166167+pub use common::*;
168#[cfg(feature = "api")]
169/// If enabled, re-export the generated api crate
170pub use jacquard_api as api;
171+pub use jacquard_common as common;
172173#[cfg(feature = "derive")]
174/// if enabled, reexport the attribute macros
···1+use clap::Parser;
2+use jacquard::api::app_bsky::embed::images::{Image, Images};
3+use jacquard::api::app_bsky::feed::post::{Post, PostEmbed};
4+use jacquard::client::{Agent, FileAuthStore};
5+use jacquard::oauth::atproto::AtprotoClientMetadata;
6+use jacquard::oauth::client::OAuthClient;
7+use jacquard::oauth::loopback::LoopbackConfig;
8+use jacquard::types::blob::MimeType;
9+use jacquard::types::string::Datetime;
10+use jacquard::xrpc::XrpcClient;
11+use jacquard::CowStr;
12+use miette::IntoDiagnostic;
13+use std::path::PathBuf;
14+15+#[derive(Parser, Debug)]
16+#[command(author, version, about = "Create a post with an image")]
17+struct Args {
18+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
19+ input: CowStr<'static>,
20+21+ /// Post text
22+ #[arg(short, long)]
23+ text: String,
24+25+ /// Path to image file
26+ #[arg(short, long)]
27+ image: PathBuf,
28+29+ /// Alt text for image
30+ #[arg(long)]
31+ alt: Option<String>,
32+33+ /// Path to auth store file (will be created if missing)
34+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
35+ store: String,
36+}
37+38+#[tokio::main]
39+async fn main() -> miette::Result<()> {
40+ let args = Args::parse();
41+42+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
43+ let session = oauth
44+ .login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
45+ .await?;
46+47+ let agent: Agent<_> = Agent::from(session);
48+49+ // Read image file
50+ let image_data = std::fs::read(&args.image).into_diagnostic()?;
51+52+ // Infer mime type from extension
53+ let mime_str = match args.image.extension().and_then(|s| s.to_str()) {
54+ Some("jpg") | Some("jpeg") => "image/jpeg",
55+ Some("png") => "image/png",
56+ Some("gif") => "image/gif",
57+ Some("webp") => "image/webp",
58+ _ => "image/jpeg", // default
59+ };
60+ let mime_type = MimeType::new_static(mime_str);
61+62+ println!("📤 Uploading image...");
63+ let blob_ref = agent.upload_blob(image_data, mime_type).await?;
64+65+ // Extract the Blob from the BlobRef
66+ let blob = match blob_ref {
67+ jacquard::types::blob::BlobRef::Blob(b) => b,
68+ _ => miette::bail!("Expected Blob, got LegacyBlob"),
69+ };
70+71+ // Create post with image embed
72+ let post = Post {
73+ text: CowStr::from(args.text),
74+ created_at: Datetime::now(),
75+ embed: Some(PostEmbed::Images(Box::new(Images {
76+ images: vec![Image {
77+ alt: CowStr::from(args.alt.unwrap_or_default()),
78+ image: blob,
79+ aspect_ratio: None,
80+ extra_data: Default::default(),
81+ }],
82+ extra_data: Default::default(),
83+ }))),
84+ entities: None,
85+ facets: None,
86+ labels: None,
87+ langs: None,
88+ reply: None,
89+ tags: None,
90+ extra_data: Default::default(),
91+ };
92+93+ let output = agent.create_record(post, None).await?;
94+ println!("✓ Created post with image: {}", output.uri);
95+96+ Ok(())
97+}
+32
examples/public_atproto_feed.rs
···00000000000000000000000000000000
···1+use jacquard::api::app_bsky::feed::get_feed::GetFeed;
2+use jacquard::api::app_bsky::feed::post::Post;
3+use jacquard::types::string::AtUri;
4+use jacquard::types::value::from_data;
5+use jacquard::xrpc::XrpcExt;
6+use miette::IntoDiagnostic;
7+8+#[tokio::main]
9+async fn main() -> miette::Result<()> {
10+ // Stateless XRPC - no auth required for public feeds
11+ let http = reqwest::Client::new();
12+ let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
13+14+ // Feed of posts about the AT Protocol
15+ let feed_uri =
16+ AtUri::new_static("at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.generator/atproto")
17+ .unwrap();
18+19+ let request = GetFeed::new().feed(feed_uri).limit(10).build();
20+21+ let response = http.xrpc(base).send(&request).await?;
22+ let output = response.into_output()?;
23+24+ println!("📰 Latest posts from the AT Protocol feed:\n");
25+ for (i, item) in output.feed.iter().enumerate() {
26+ // Deserialize the post record from the Data type
27+ let post: Post = from_data(&item.post.record).into_diagnostic()?;
28+ println!("{}.(@{})\n{} ", i + 1, item.post.author.handle, post.text);
29+ }
30+31+ Ok(())
32+}
···1+use clap::Parser;
2+use jacquard::CowStr;
3+use jacquard::api::app_bsky::actor::profile::Profile;
4+use jacquard::client::{Agent, FileAuthStore};
5+use jacquard::oauth::atproto::AtprotoClientMetadata;
6+use jacquard::oauth::client::OAuthClient;
7+use jacquard::oauth::loopback::LoopbackConfig;
8+use jacquard::types::string::AtUri;
9+use jacquard::xrpc::XrpcClient;
10+use miette::IntoDiagnostic;
11+12+#[derive(Parser, Debug)]
13+#[command(author, version, about = "Update profile display name and description")]
14+struct Args {
15+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
16+ input: CowStr<'static>,
17+18+ /// New display name
19+ #[arg(long)]
20+ display_name: Option<String>,
21+22+ /// New bio/description
23+ #[arg(long)]
24+ description: Option<String>,
25+26+ /// Path to auth store file (will be created if missing)
27+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
28+ store: String,
29+}
30+31+#[tokio::main]
32+async fn main() -> miette::Result<()> {
33+ let args = Args::parse();
34+35+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
36+ let session = oauth
37+ .login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
38+ .await?;
39+40+ let agent: Agent<_> = Agent::from(session);
41+42+ // Get session info to build the at:// URI for the profile record
43+ let (did, _) = agent
44+ .info()
45+ .await
46+ .ok_or_else(|| miette::miette!("No session info available"))?;
47+48+ // Profile records use "self" as the rkey
49+ let uri_string = format!("at://{}/app.bsky.actor.profile/self", did);
50+ let uri = AtUri::new(&uri_string)?;
51+52+ // Update profile in-place using the fetch-modify-put pattern
53+ agent
54+ .update_record::<Profile>(uri, |profile| {
55+ if let Some(name) = &args.display_name {
56+ profile.display_name = Some(CowStr::from(name.clone()));
57+ }
58+ if let Some(desc) = &args.description {
59+ profile.description = Some(CowStr::from(desc.clone()));
60+ }
61+ })
62+ .await?;
63+64+ println!("✓ Profile updated successfully");
65+66+ Ok(())
67+}
+44
justfile
···12# Run 'bacon' to run the project (auto-recompiles)
13watch *ARGS:
14 bacon --job run -- -- {{ ARGS }}
00000000000000000000000000000000000000000000
···12# Run 'bacon' to run the project (auto-recompiles)
13watch *ARGS:
14 bacon --job run -- -- {{ ARGS }}
15+16+# Run the OAuth timeline example
17+example-oauth *ARGS:
18+ cargo run -p jacquard --example oauth_timeline --features fancy -- {{ARGS}}
19+20+# Create a simple post
21+example-create-post *ARGS:
22+ cargo run -p jacquard --example create_post --features fancy -- {{ARGS}}
23+24+# Create a post with an image
25+example-post-image *ARGS:
26+ cargo run -p jacquard --example post_with_image --features fancy -- {{ARGS}}
27+28+# Update profile display name and description
29+example-update-profile *ARGS:
30+ cargo run -p jacquard --example update_profile --features fancy -- {{ARGS}}
31+32+# Fetch public AT Protocol feed (no auth)
33+example-public-feed:
34+ cargo run -p jacquard --example public_atproto_feed
35+36+# Create a WhiteWind blog post
37+example-whitewind-create *ARGS:
38+ cargo run -p jacquard --example create_whitewind_post --features fancy -- {{ARGS}}
39+40+# Read a WhiteWind blog post
41+example-whitewind-read *ARGS:
42+ cargo run -p jacquard --example read_whitewind_posts --features fancy,api_full -- {{ARGS}}
43+44+# Read info about a Tangled git repository
45+example-tangled-repo *ARGS:
46+ cargo run -p jacquard --example read_tangled_repo --features fancy,api_full -- {{ARGS}}
47+48+# Resolve a handle to its DID document
49+example-resolve-did *ARGS:
50+ cargo run -p jacquard --example resolve_did -- {{ARGS}}
51+52+# Update Bluesky preferences
53+example-update-preferences *ARGS:
54+ cargo run -p jacquard --example update_preferences --features fancy -- {{ARGS}}
55+56+# Run the Axum server example
57+example-axum:
58+ cargo run -p jacquard-axum --example axum_server --features jacquard/fancy