+2
-13
README.md
+2
-13
README.md
···
24
24
use jacquard::CowStr;
25
25
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
26
26
use jacquard::client::{Agent, FileAuthStore};
27
-
use jacquard::oauth::atproto::AtprotoClientMetadata;
28
27
use jacquard::oauth::client::OAuthClient;
29
28
use jacquard::oauth::loopback::LoopbackConfig;
30
-
use jacquard::oauth::scopes::Scope;
31
29
use jacquard::types::xrpc::XrpcClient;
32
30
use miette::IntoDiagnostic;
33
31
···
46
44
async fn main() -> miette::Result<()> {
47
45
let args = Args::parse();
48
46
49
-
// File-backed auth store for testing
50
-
let store = FileAuthStore::new(&args.store);
51
-
let client_data = jacquard_oauth::session::ClientData {
52
-
keyset: None,
53
-
// Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
54
-
// The localhost helper will ensure you have at least "atproto" and will fix urls
55
-
config: AtprotoClientMetadata::default_localhost()
56
-
};
57
-
58
-
// Build an OAuth client
59
-
let oauth = OAuthClient::new(store, client_data);
47
+
// Build an OAuth client with file-backed auth store and default localhost config
48
+
let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
60
49
// Authenticate with a PDS, using a loopback server to handle the callback flow
61
50
let session = oauth
62
51
.login_with_local_server(
+3
-9
crates/jacquard-api/src/app_bsky/feed/get_timeline.rs
+3
-9
crates/jacquard-api/src/app_bsky/feed/get_timeline.rs
···
13
13
PartialEq,
14
14
Eq,
15
15
bon::Builder,
16
-
jacquard_derive::IntoStatic
16
+
jacquard_derive::IntoStatic,
17
17
)]
18
18
#[builder(start_fn = new)]
19
19
#[serde(rename_all = "camelCase")]
···
33
33
34
34
#[jacquard_derive::lexicon]
35
35
#[derive(
36
-
serde::Serialize,
37
-
serde::Deserialize,
38
-
Debug,
39
-
Clone,
40
-
PartialEq,
41
-
Eq,
42
-
jacquard_derive::IntoStatic
36
+
serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic,
43
37
)]
44
38
#[serde(rename_all = "camelCase")]
45
39
pub struct GetTimelineOutput<'a> {
···
74
68
const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query;
75
69
type Request<'de> = GetTimeline<'de>;
76
70
type Response = GetTimelineResponse;
77
-
}
71
+
}
+146
-132
crates/jacquard-common/src/lib.rs
+146
-132
crates/jacquard-common/src/lib.rs
···
1
-
//! # Common types for the jacquard implementation of atproto
1
+
//! Common types for the jacquard implementation of atproto
2
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.
3
+
//! ## Just `.send()` it
9
4
//!
10
-
//! ### The Problem: Lifetimes + Async + Traits
5
+
//! Jacquard has a couple of `.send()` methods. One is stateless. it's the output of a method that creates a request builder, implemented as an extension trait, `XrpcExt`, on any http client which implements a very simple HttpClient trait. You can use a bare `reqwest::Client` to make XRPC requests. You call `.xrpc(base_url)` and get an `XrpcCall` struct. `XrpcCall` is a builder, which allows you to pass authentication, atproto proxy settings, labeler headings, and set other options for the final request. There's also a similar trait `DpopExt` in the `jacquard-oauth` crate, which handles that form of authenticated request in a similar way. For basic stuff, this works great, and it's a useful building block for more complex logic, or when one size does **not** in fact fit all.
11
6
//!
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
-
//! }
7
+
//! ```rust
8
+
//! use jacquard_common::xrpc::XrpcExt;
9
+
//! use jacquard_common::http_client::HttpClient;
10
+
//! // ...
11
+
//! let http = reqwest::Client::new();
12
+
//! let base = url::Url::parse("https://public.api.bsky.app")?;
13
+
//! let resp = http.xrpc(base).send(&request).await?;
19
14
//! ```
15
+
//! The other, `XrpcClient`, is stateful, and can be implemented on anything with a bit of internal state to store the base URI (the URL of the PDS being contacted) and the default options. It's the one you're most likely to interact with doing normal atproto API client stuff. The Agent struct in the initial example implements that trait, as does the session struct it wraps, and the `.send()` method used is that trait method.
20
16
//!
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):
17
+
//! >`XrpcClient` implementers don't *have* to implement token auto-refresh and so on, but realistically they *should* implement at least a basic version. There is an `AgentSession` trait which does require full session/state management.
23
18
//!
24
-
//! ```ignore
25
-
//! fn foo<R>(response: &[u8])
26
-
//! where
27
-
//! R: for<'any> XrpcRequest<'any>
28
-
//! {
29
-
//! // deserialize from response...
30
-
//! }
19
+
//! Here is the entire text of `XrpcCall::send()`. [`build_http_request()`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-common/src/xrpc.rs#L400) and [`process_response()`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-common/src/xrpc.rs#L344) are public functions and can be used in other crates. The first does more or less what it says on the tin. The second does less than you might think. It mostly surfaces authentication errors at an earlier level so you don't have to fully parse the response to know if there was an error or not.
20
+
//!
21
+
//! ```rust
22
+
//! pub async fn send<'s, R>(
23
+
//! self,
24
+
//! request: &R,
25
+
//! ) -> XrpcResult<Response<<R as XrpcRequest<'s>>::Response>>
26
+
//! where
27
+
//! R: XrpcRequest<'s>,
28
+
//! {
29
+
//! let http_request = build_http_request(&self.base, request, &self.opts)
30
+
//! .map_err(TransportError::from)?;
31
+
//! let http_response = self
32
+
//! .client
33
+
//! .send_http(http_request)
34
+
//! .await
35
+
//! .map_err(|e| TransportError::Other(Box::new(e)))?;
36
+
//! process_response(http_response)
37
+
//! }
31
38
//! ```
39
+
//! >A core goal of Jacquard is to not only provide an easy interface to atproto, but to also make it very easy to build something that fits your needs, and making "helper" functions like those part of the API surface is a big part of that, as are "stateless" implementations like `XrpcExt` and `XrpcCall`.
32
40
//!
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.
41
+
//! `.send()` works for any endpoint and any type that implements the required traits, regardless of what crate it's defined in. There's no `KnownRecords` enum which defines a complete set of known records, and no restriction of Service endpoints in the agent/client, or anything like that, nothing that privileges any set of lexicons or way of working with the library, as much as possible. There's one primary method and you can put pretty much anything relevant into it. Whatever atproto API you need to call, just `.send()` it. Okay there are a couple of additional helpers, but we're focusing on the core one, because pretty much everything else is just wrapping the above `send()` in one way or another, and they use the same pattern.
39
42
//!
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.
43
+
//! ## Punchcard Instructions
47
44
//!
48
-
//! ### Explaining where the buffer goes to `rustc`: GATs + Method-Level Lifetimes
45
+
//! So how does this work? How does `send()` and its helper functions know what to do? The answer shouldn't be surprising to anyone familiar with Rust. It's traits! Specifically, the following traits, which have generated implementations for every lexicon type ingested by Jacquard's API code generation, but which honestly aren't hard to just implement yourself (more tedious than anything). XrpcResp is always implemented on a unit/marker struct with no fields. They provide all the request-specific instructions to the functions.
49
46
//!
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 {
47
+
//! ```rust
48
+
//! pub trait XrpcRequest<'de>: Serialize + Deserialize<'de> {
49
+
//! const NSID: &'static str;
50
+
//! /// XRPC method (query/GET or procedure/POST)
51
+
//! const METHOD: XrpcMethod;
52
+
//! type Response: XrpcResp;
53
+
//! /// Encode the request body for procedures.
54
+
//! fn encode_body(&self) -> Result<Vec<u8>, EncodeError> {
55
+
//! Ok(serde_json::to_vec(self)?)
56
+
//! }
57
+
//! /// Decode the request body for procedures. (Used server-side)
58
+
//! fn decode_body(body: &'de [u8]) -> Result<Box<Self>, DecodeError> {
59
+
//! let body: Self = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?;
60
+
//! Ok(Box::new(body))
61
+
//! }
62
+
//! }
63
+
//! pub trait XrpcResp {
55
64
//! const NSID: &'static str;
56
-
//!
57
-
//! // GATs: lifetime is on the associated type, not the trait
65
+
//! /// Output encoding (MIME type)
66
+
//! const ENCODING: &'static str;
58
67
//! type Output<'de>: Deserialize<'de> + IntoStatic;
59
-
//! type Err<'de>: Deserialize<'de> + IntoStatic;
68
+
//! type Err<'de>: Error + Deserialize<'de> + IntoStatic;
60
69
//! }
61
70
//! ```
71
+
//! Here are the implementations for [`GetTimeline`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/get_timeline.rs). You'll also note that `send()` doesn't return the fully decoded response on success. It returns a Response struct which has a generic parameter that must implement the XrpcResp trait above. Here's its definition. It's essentially just a cheaply cloneable byte buffer and a type marker.
62
72
//!
63
-
//! Now you can write trait bounds without HRTBs:
73
+
//! ```rust
74
+
//! pub struct Response<R: XrpcResp> {
75
+
//! buffer: Bytes,
76
+
//! status: StatusCode,
77
+
//! _marker: PhantomData<R>,
78
+
//! }
64
79
//!
65
-
//! ```ignore
66
-
//! fn foo<R: XrpcResp>(response: &[u8]) {
67
-
//! // Compiler can pick a concrete lifetime for R::Output<'_>
80
+
//! impl<R: XrpcResp> Response<R> {
81
+
//! pub fn parse<'s>(
82
+
//! &'s self
83
+
//! ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
84
+
//! // Borrowed parsing into Output or Err
85
+
//! }
86
+
//! pub fn into_output(
87
+
//! self
88
+
//! ) -> Result<<Resp as XrpcResp>::Output<'static>, XrpcError<<Resp as XrpcResp>::Err<'static>>>
89
+
//! where ...
90
+
//! { /* Owned parsing into Output or Err */ }
68
91
//! }
69
92
//! ```
93
+
//! You decode the response (or the endpoint-specific error) out of this, borrowing from the buffer or taking ownership so you can drop the buffer. There are two reasons for this. One is separation of concerns. By two-staging the parsing, it's easier to distinguish network and authentication problems from application-level errors. The second is lifetimes and borrowed deserialization.
70
94
//!
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.
95
+
//! ## Working with Lifetimes and Zero-Copy Deserialization
77
96
//!
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
-
//! ```
97
+
//! Jacquard is designed around zero-copy/borrowed deserialization: types like [`Post<'a>`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/post.rs) can borrow strings and other data directly from the response buffer instead of allocating owned copies. This is great for performance, but it creates some interesting challenges, especially in async contexts. So how do you specify the lifetime of the borrow?
88
98
//!
89
-
//! The compiler can monomorphize for concrete lifetimes instead of trying to prove bounds hold
90
-
//! for *all* lifetimes at once.
99
+
//! The naive approach would be to put a lifetime parameter on the trait itself:
91
100
//!
92
-
//! ### Handling Async with `Response<R: XrpcResp>`
101
+
//!```ignore
102
+
//!// Note: I actually DO do this for XrpcRequest as you can see above,
103
+
//!// because it is implemented on the request parameter struct, which has this
104
+
//!// sort of lifetime bound inherently, and we need it to implement Deserialize
105
+
//!// for server-side handling.
106
+
//!trait NaiveXrpcRequest<'de> {
107
+
//! type Output: Deserialize<'de>;
108
+
//! // ...
109
+
//!}
110
+
//!```
93
111
//!
94
-
//! For the async problem, we use a wrapper type that owns the response buffer:
112
+
//! This looks reasonable until you try to use it in a generic context. If you have a function that works with *any* lifetime, you need a Higher-ranked trait bound:
95
113
//!
96
-
//! ```ignore
97
-
//! pub struct Response<R: XrpcResp> {
98
-
//! buffer: Bytes, // Refcounted, cheap to clone
99
-
//! status: StatusCode,
100
-
//! _marker: PhantomData<R>,
101
-
//! }
102
-
//! ```
114
+
//!```ignore
115
+
//!fn parse<R>(response: &[u8]) ... // return type
116
+
//!where
117
+
//! R: for<'any> XrpcRequest<'any>
118
+
//!{ /* deserialize from response... */ }
119
+
//!```
103
120
//!
104
-
//! This lets async methods return a `Response` that owns its buffer, then the *caller* decides
105
-
//! the lifetime strategy:
121
+
//! The `for<'any>` bound says "this type must implement `XrpcRequest` for *every possible lifetime*", which, for `Deserialize`, is effectively the same as requiring `DeserializeOwned`. You've probably just thrown away your zero-copy optimization, and furthermore that trait bound just straight-up won't work on most of the types in Jacquard. The vast majority of them have either a custom Deserialize implementation which will borrow if it can, a `#[serde(borrow)]` attribute on one or more fields, or an equivalent lifetime bound attribute, associated with the Deserialize derive macro. You will get "Deserialize implementation not general enough" if you try. And no, you cannot have an additional deserialize implementation for the `'static` lifetime due to how serde works.
106
122
//!
107
-
//! ```ignore
108
-
//! // Zero-copy: borrow from the owned buffer
109
-
//! let output: R::Output<'_> = response.parse()?;
123
+
//! If you instead try something like the below function signature and specify a specific lifetime, it will compile in isolation, but when you go to use it, the Rust compiler will not generally be able to figure out the lifetimes at the call site, and will complain about things being dropped while still borrowed, even if you convert the response to an owned/ `'static` lifetime version of the type.
110
124
//!
111
-
//! // Owned: convert to 'static via IntoStatic
112
-
//! let output: R::Output<'static> = response.into_output()?;
113
-
//! ```
125
+
//!```ignore
126
+
//!fn parse<'s, R: XrpcRequest<'s>>(response: &'s [u8]) ... // return type with the same lifetime
127
+
//!{ /* deserialize from response... */ }
128
+
//!```
114
129
//!
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.
130
+
//! It gets worse with async. If you want to return borrowed data from an async method, where does the lifetime come from? The response buffer needs to outlive the borrow, but the buffer is consumed or potentially has to have an unbounded lifetime. You end up with confusing and frustrating errors because the compiler can't prove the buffer will stay alive or that you have taken ownership of the parts of it you care about. You *could* do some lifetime laundering with `unsafe`, but that road leads to potential soundness issues, and besides, you don't actually *need* to tell `rustc` to "trust me, bro", you can, with some cleverness, explain this to the compiler in a way that it can reason about perfectly well.
120
131
//!
121
-
//! ### Example: XRPC Traits in Practice
132
+
//! ### Explaining where the buffer goes to `rustc`
122
133
//!
123
-
//! Here's how the pattern works with the XRPC layer:
134
+
//! The fix is to use Generic Associated Types (GATs) on the trait's associated types, while keeping the trait itself lifetime-free:
124
135
//!
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
-
//! }
136
+
//!```ignore
137
+
//!pub trait XrpcResp {
138
+
//! const NSID: &'static str;
139
+
//! /// Output encoding (MIME type)
140
+
//! const ENCODING: &'static str;
141
+
//! type Output<'de>: Deserialize<'de> + IntoStatic;
142
+
//! type Err<'de>: Error + Deserialize<'de> + IntoStatic;
143
+
//!}
144
+
//!```
132
145
//!
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
-
//! }
146
+
//!Now you can write trait bounds without HRTBs, and with lifetime bounds that are actually possible for Jacquard's borrowed deserializing types to meet:
139
147
//!
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
-
//! }
148
+
//!```ignore
149
+
//!fn parse<'s, R: XrpcResp>(response: &'s [u8]) /* return type with same lifetime */ {
150
+
//! // Compiler can pick a concrete lifetime for R::Output<'_> or have it specified easily
151
+
//!}
152
+
//!```
145
153
//!
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
-
//! }
154
+
//!Methods that need lifetimes use method-level generic parameters:
152
155
//!
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
-
//! }
156
+
//!```ignore
157
+
//!// This is part of a trait from jacquard itself, used to genericize updates to things like the Bluesky
158
+
//!// preferences union, so that if you implement a similar lexicon type in your app, you don't have
159
+
//!// to special-case it. Instead you can do a relatively simple trait implementation and then call
160
+
//!// .update_vec() with a modifier function or .update_vec_item() with a single item you want to set.
161
+
//
162
+
//!pub trait VecUpdate {
163
+
//! type GetRequest<'de>: XrpcRequest<'de>; //GAT
164
+
//! type PutRequest<'de>: XrpcRequest<'de>; //GAT
165
+
//! //... more stuff
166
+
//
167
+
//! //Method-level lifetime, not trait-level
168
+
//! fn extract_vec<'s>(
169
+
//! output: <Self::GetRequest<'s> as XrpcRequest<'s>>::Output<'s>
170
+
//! ) -> Vec<Self::Item>;
171
+
//! //... more stuff
172
+
//!}
173
+
//!```
162
174
//!
163
-
//! // Usage:
164
-
//! let response = send_xrpc(request).await?;
175
+
//!The compiler can monomorphize for concrete lifetimes instead of trying to prove bounds hold for *all* lifetimes at once, or struggle to figure out when you're done with a buffer. `XrpcResp` being separate and lifetime-free lets async methods like `.send()` return a `Response` that owns the response buffer, and then the *caller* decides the lifetime strategy:
165
176
//!
166
-
//! // Zero-copy: borrow from response buffer
167
-
//! let output = response.parse()?; // Output<'_> borrows from response
177
+
//!```ignore
178
+
//!// Zero-copy: borrow from the owned buffer
179
+
//!let output: R::Output<'_> = response.parse()?;
180
+
//
181
+
//!// Owned: convert to 'static via IntoStatic
182
+
//!let output: R::Output<'static> = response.into_output()?;
183
+
//!```
168
184
//!
169
-
//! // Or owned: convert to 'static
170
-
//! let output = response.into_output()?; // Output<'static> is fully owned
171
-
//! ```
185
+
//! The async method doesn't need to know or care about lifetimes for the most part - it just returns the `Response`. The caller gets full control over whether to use borrowed or owned data. It can even decide after the fact that it doesn't want to parse out the API response type that it asked for. Instead it can call `.parse_data()` or `.parse_raw()` on the response to get loosely typed, validated data or minimally typed maximally accepting data values out.
172
186
//!
173
187
//! When you see types like `Response<R: XrpcResp>` or methods with lifetime parameters,
174
188
//! this is the pattern at work. It looks a bit funky, but it's solving a specific problem
+1
-21
crates/jacquard-common/src/xrpc.rs
+1
-21
crates/jacquard-common/src/xrpc.rs
···
317
317
.await
318
318
.map_err(|e| crate::error::TransportError::Other(Box::new(e)))?;
319
319
320
-
let status = http_response.status();
321
-
// If the server returned 401 with a WWW-Authenticate header, expose it so higher layers
322
-
// (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh.
323
-
if status.as_u16() == 401 {
324
-
if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
325
-
return Err(crate::error::ClientError::Auth(
326
-
crate::error::AuthError::Other(hv.clone()),
327
-
));
328
-
}
329
-
}
330
-
let buffer = Bytes::from(http_response.into_body());
331
-
332
-
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
333
-
return Err(crate::error::HttpError {
334
-
status,
335
-
body: Some(buffer),
336
-
}
337
-
.into());
338
-
}
339
-
340
-
Ok(Response::new(buffer, status))
320
+
process_response(http_response)
341
321
}
342
322
}
343
323
+17
-14
crates/jacquard/Cargo.toml
+17
-14
crates/jacquard/Cargo.toml
···
12
12
license.workspace = true
13
13
14
14
[features]
15
-
default = ["api_full", "dns", "loopback"]
15
+
default = ["api_full", "dns", "loopback", "derive"]
16
16
derive = ["dep:jacquard-derive"]
17
+
# Minimal API bindings
17
18
api = ["jacquard-api/com_atproto", "jacquard-api/com_bad_example" ]
19
+
# Bluesky API bindings
18
20
api_bluesky = ["api", "jacquard-api/bluesky" ]
21
+
# Bluesky API bindings, plus a curated selection of community lexicons
19
22
api_full = ["api", "jacquard-api/bluesky", "jacquard-api/other", "jacquard-api/lexicon_community"]
23
+
# All captured generated lexicon API bindings
20
24
api_all = ["api_full", "jacquard-api/ufos"]
21
25
dns = ["jacquard-identity/dns"]
26
+
# Pretty debug prints for examples
22
27
fancy = ["miette/fancy"]
23
28
# Propagate loopback to oauth (server + browser helper)
24
29
loopback = ["jacquard-oauth/loopback", "jacquard-oauth/browser-open"]
25
30
26
-
[lib]
27
-
name = "jacquard"
28
-
path = "src/lib.rs"
29
31
30
32
[[example]]
31
33
name = "oauth_timeline"
32
34
path = "../../examples/oauth_timeline.rs"
33
-
required-features = ["fancy", "loopback", "api_bluesky"]
35
+
required-features = ["fancy"]
34
36
35
37
[[example]]
36
38
name = "create_post"
37
39
path = "../../examples/create_post.rs"
38
-
required-features = ["fancy", "loopback", "api_bluesky"]
40
+
required-features = ["fancy"]
39
41
40
42
[[example]]
41
43
name = "post_with_image"
42
44
path = "../../examples/post_with_image.rs"
43
-
required-features = ["fancy", "loopback", "api_bluesky"]
45
+
required-features = ["fancy"]
44
46
45
47
[[example]]
46
48
name = "update_profile"
47
49
path = "../../examples/update_profile.rs"
48
-
required-features = ["fancy", "loopback", "api_bluesky"]
50
+
required-features = ["fancy"]
49
51
50
52
[[example]]
51
53
name = "public_atproto_feed"
···
54
56
[[example]]
55
57
name = "create_whitewind_post"
56
58
path = "../../examples/create_whitewind_post.rs"
57
-
required-features = ["fancy", "loopback", "api_full"]
59
+
required-features = ["fancy", ]
58
60
59
61
[[example]]
60
-
name = "read_whitewind_posts"
61
-
path = "../../examples/read_whitewind_posts.rs"
62
-
required-features = ["fancy", "api_full"]
62
+
name = "read_whitewind_post"
63
+
path = "../../examples/read_whitewind_post.rs"
64
+
required-features = ["fancy"]
63
65
64
66
[[example]]
65
67
name = "read_tangled_repo"
66
68
path = "../../examples/read_tangled_repo.rs"
67
-
required-features = ["api_full"]
69
+
required-features = ["fancy"]
68
70
69
71
[[example]]
70
72
name = "resolve_did"
71
73
path = "../../examples/resolve_did.rs"
74
+
required-features = ["fancy"]
72
75
73
76
[[example]]
74
77
name = "update_preferences"
75
78
path = "../../examples/update_preferences.rs"
76
-
required-features = ["fancy", "loopback", "api_full"]
79
+
required-features = ["fancy"]
77
80
78
81
[dependencies]
79
82
jacquard-api = { version = "0.4", path = "../jacquard-api" }
+6
-7
crates/jacquard/src/client.rs
+6
-7
crates/jacquard/src/client.rs
···
34
34
pub use jacquard_common::error::{ClientError, XrpcResult};
35
35
use jacquard_common::http_client::HttpClient;
36
36
pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError};
37
-
use jacquard_common::types::blob::{BlobRef, MimeType};
37
+
use jacquard_common::types::blob::{Blob, MimeType};
38
38
use jacquard_common::types::collection::Collection;
39
39
use jacquard_common::types::recordkey::{RecordKey, Rkey};
40
40
use jacquard_common::types::string::AtUri;
···
395
395
///
396
396
/// The collection is inferred from the type parameter.
397
397
/// The repo is automatically filled from the session info.
398
-
pub async fn delete_record<R, K>(
398
+
pub async fn delete_record<R>(
399
399
&self,
400
-
rkey: K,
400
+
rkey: RecordKey<Rkey<'_>>,
401
401
) -> Result<DeleteRecordOutput<'static>, AgentError>
402
402
where
403
403
R: Collection,
404
-
K: Into<RecordKey<Rkey<'static>>>,
405
404
{
406
405
use jacquard_api::com_atproto::repo::delete_record::DeleteRecord;
407
406
use jacquard_common::types::ident::AtIdentifier;
···
411
410
let request = DeleteRecord::new()
412
411
.repo(AtIdentifier::Did(did))
413
412
.collection(R::nsid())
414
-
.rkey(rkey.into())
413
+
.rkey(rkey)
415
414
.build();
416
415
417
416
let response = self.send(request).await?;
···
491
490
&self,
492
491
data: impl Into<bytes::Bytes>,
493
492
mime_type: MimeType<'_>,
494
-
) -> Result<BlobRef<'static>, AgentError> {
493
+
) -> Result<Blob<'static>, AgentError> {
495
494
use http::header::CONTENT_TYPE;
496
495
use jacquard_api::com_atproto::repo::upload_blob::UploadBlob;
497
496
···
522
521
error: Box::new(typed),
523
522
},
524
523
})?;
525
-
Ok(BlobRef::Blob(output.blob.into_static()))
524
+
Ok(output.blob.into_static())
526
525
}
527
526
528
527
/// Update a vec-based data structure with a fetch-modify-put pattern.
-2
crates/jacquard/src/lib.rs
-2
crates/jacquard/src/lib.rs
···
25
25
//! # use clap::Parser;
26
26
//! # use jacquard::CowStr;
27
27
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
28
-
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
29
28
//! use jacquard::client::{Agent, FileAuthStore};
30
-
//! use jacquard::oauth::atproto::AtprotoClientMetadata;
31
29
//! use jacquard::oauth::client::OAuthClient;
32
30
//! use jacquard::xrpc::XrpcClient;
33
31
//! # #[cfg(feature = "loopback")]
+11
-4
examples/create_whitewind_post.rs
+11
-4
examples/create_whitewind_post.rs
···
1
1
use clap::Parser;
2
+
use jacquard::CowStr;
2
3
use jacquard::api::com_whtwnd::blog::entry::Entry;
3
4
use jacquard::client::{Agent, FileAuthStore};
4
5
use jacquard::oauth::atproto::AtprotoClientMetadata;
···
6
7
use jacquard::oauth::loopback::LoopbackConfig;
7
8
use jacquard::types::string::Datetime;
8
9
use jacquard::xrpc::XrpcClient;
9
-
use jacquard::CowStr;
10
10
use miette::IntoDiagnostic;
11
+
use url::Url;
11
12
12
13
#[derive(Parser, Debug)]
13
14
#[command(author, version, about = "Create a WhiteWind blog post")]
···
58
59
extra_data: Default::default(),
59
60
};
60
61
61
-
let output = agent.create_record(entry, None).await?;
62
-
println!("✓ Created WhiteWind blog post: {}", output.uri);
63
-
println!(" View at: https://whtwnd.com/post/{}", output.uri);
62
+
let mut output = agent.create_record(entry, None).await?;
63
+
println!("Created WhiteWind blog post: {}", output.uri);
64
+
let url = Url::parse(format!(
65
+
"https://whtwnd.nat.vg/{}/{}",
66
+
output.uri.authority(),
67
+
output.uri.rkey().map(|r| r.as_ref()).unwrap_or("")
68
+
))
69
+
.into_diagnostic()?;
70
+
println!("View at: {}", url);
64
71
65
72
Ok(())
66
73
}
+4
-10
examples/post_with_image.rs
+4
-10
examples/post_with_image.rs
···
1
1
use clap::Parser;
2
+
use jacquard::CowStr;
2
3
use jacquard::api::app_bsky::embed::images::{Image, Images};
3
4
use jacquard::api::app_bsky::feed::post::{Post, PostEmbed};
4
5
use jacquard::client::{Agent, FileAuthStore};
···
8
9
use jacquard::types::blob::MimeType;
9
10
use jacquard::types::string::Datetime;
10
11
use jacquard::xrpc::XrpcClient;
11
-
use jacquard::CowStr;
12
12
use miette::IntoDiagnostic;
13
13
use std::path::PathBuf;
14
14
···
59
59
};
60
60
let mime_type = MimeType::new_static(mime_str);
61
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
-
};
62
+
println!("Uploading image...");
63
+
let blob = agent.upload_blob(image_data, mime_type).await?;
70
64
71
65
// Create post with image embed
72
66
let post = Post {
···
91
85
};
92
86
93
87
let output = agent.create_record(post, None).await?;
94
-
println!("✓ Created post with image: {}", output.uri);
88
+
println!("Created post with image: {}", output.uri);
95
89
96
90
Ok(())
97
91
}
+1
-1
examples/public_atproto_feed.rs
+1
-1
examples/public_atproto_feed.rs
···
21
21
let response = http.xrpc(base).send(&request).await?;
22
22
let output = response.into_output()?;
23
23
24
-
println!("📰 Latest posts from the AT Protocol feed:\n");
24
+
println!("Latest posts from the AT Protocol feed:\n");
25
25
for (i, item) in output.feed.iter().enumerate() {
26
26
// Deserialize the post record from the Data type
27
27
let post: Post = from_data(&item.post.record).into_diagnostic()?;
examples/read_whitewind_posts.rs
examples/read_whitewind_post.rs
examples/read_whitewind_posts.rs
examples/read_whitewind_post.rs
+2
-2
justfile
+2
-2
justfile
···
39
39
40
40
# Read a WhiteWind blog post
41
41
example-whitewind-read *ARGS:
42
-
cargo run -p jacquard --example read_whitewind_posts --features fancy,api_full -- {{ARGS}}
42
+
cargo run -p jacquard --example read_whitewind_posts --features fancy -- {{ARGS}}
43
43
44
44
# Read info about a Tangled git repository
45
45
example-tangled-repo *ARGS:
46
-
cargo run -p jacquard --example read_tangled_repo --features fancy,api_full -- {{ARGS}}
46
+
cargo run -p jacquard --example read_tangled_repo --features fancy -- {{ARGS}}
47
47
48
48
# Resolve a handle to its DID document
49
49
example-resolve-did *ARGS: