+1
.gitignore
+1
.gitignore
+4
-3
README.md
+4
-3
README.md
···
25
25
use jacquard::CowStr;
26
26
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
27
27
use jacquard::api::com_atproto::server::create_session::CreateSession;
28
-
use jacquard::client::{AuthenticatedClient, Session, XrpcClient};
28
+
use jacquard::client::{BasicClient, Session};
29
29
use miette::IntoDiagnostic;
30
30
31
31
#[derive(Parser, Debug)]
···
49
49
let args = Args::parse();
50
50
51
51
// Create HTTP client
52
-
let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds);
52
+
let base = url::Url::parse(&args.pds).into_diagnostic()?;
53
+
let client = BasicClient::new(base);
53
54
54
55
// Create session
55
56
let session = Session::from(
···
65
66
);
66
67
67
68
println!("logged in as {} ({})", session.handle, session.did);
68
-
client.set_session(session);
69
+
client.set_session(session).await.into_diagnostic()?;
69
70
70
71
// Fetch timeline
71
72
println!("\nfetching timeline...");
+3
crates/jacquard-api/Cargo.toml
+3
crates/jacquard-api/Cargo.toml
+3
crates/jacquard-common/Cargo.toml
+3
crates/jacquard-common/Cargo.toml
+2
-2
crates/jacquard/Cargo.toml
+2
-2
crates/jacquard/Cargo.toml
···
12
12
license.workspace = true
13
13
14
14
[features]
15
-
default = ["api_all"]
15
+
default = ["api_all", "dns"]
16
16
derive = ["dep:jacquard-derive"]
17
17
api = ["jacquard-api/com_atproto"]
18
18
api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"]
···
42
42
serde_ipld_dagcbor.workspace = true
43
43
serde_json.workspace = true
44
44
thiserror.workspace = true
45
-
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
45
+
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }
46
46
hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true }
47
47
url.workspace = true
48
48
smol_str.workspace = true
+117
-162
crates/jacquard/src/client.rs
+117
-162
crates/jacquard/src/client.rs
···
3
3
//! This module provides HTTP and XRPC client traits along with an authenticated
4
4
//! client implementation that manages session tokens.
5
5
6
+
mod at_client;
6
7
mod error;
7
8
mod response;
9
+
mod token;
10
+
mod xrpc_call;
8
11
9
12
use std::fmt::Display;
10
13
use std::future::Future;
11
14
12
-
use bytes::Bytes;
15
+
pub use at_client::{AtClient, SendOverrides};
13
16
pub use error::{ClientError, Result};
14
17
use http::{
15
18
HeaderName, HeaderValue, Request,
16
-
header::{AUTHORIZATION, CONTENT_TYPE, InvalidHeaderValue},
19
+
header::{AUTHORIZATION, CONTENT_TYPE},
17
20
};
18
21
pub use response::Response;
22
+
pub use token::{FileTokenStore, MemoryTokenStore, TokenStore, TokenStoreError};
23
+
pub use xrpc_call::{CallOptions, XrpcCall, XrpcExt};
19
24
20
25
use jacquard_common::{
21
26
CowStr, IntoStatic,
···
24
29
xrpc::{XrpcMethod, XrpcRequest},
25
30
},
26
31
};
32
+
use url::Url;
27
33
28
34
/// Implement HttpClient for reqwest::Client
29
35
impl HttpClient for reqwest::Client {
···
61
67
}
62
68
}
63
69
64
-
/// HTTP client trait for sending raw HTTP requests
70
+
/// HTTP client trait for sending raw HTTP requests.
65
71
pub trait HttpClient {
66
72
/// Error type returned by the HTTP client
67
73
type Error: std::error::Error + Display + Send + Sync + 'static;
···
71
77
request: Request<Vec<u8>>,
72
78
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
73
79
}
74
-
/// XRPC client trait for AT Protocol RPC calls
75
-
pub trait XrpcClient: HttpClient + Sync {
76
-
/// Get the base URI for XRPC requests (e.g., "https://bsky.social")
77
-
fn base_uri(&self) -> CowStr<'_>;
78
-
/// Get the authorization token for XRPC requests
79
-
#[allow(unused_variables)]
80
-
fn authorization_token(
81
-
&self,
82
-
is_refresh: bool,
83
-
) -> impl Future<Output = Option<AuthorizationToken<'_>>> + Send {
84
-
async { None }
85
-
}
86
-
/// Get the `atproto-proxy` header.
87
-
fn atproto_proxy_header(&self) -> impl Future<Output = Option<String>> + Send {
88
-
async { None }
89
-
}
90
-
/// Get the `atproto-accept-labelers` header.
91
-
fn atproto_accept_labelers_header(&self) -> impl Future<Output = Option<Vec<String>>> + Send {
92
-
async { None }
93
-
}
94
-
/// Send an XRPC request and get back a response
95
-
fn send<R: XrpcRequest + Send>(&self, request: R) -> impl Future<Output = Result<Response<R>>> + Send
96
-
where
97
-
Self: Sized + Sync,
98
-
{
99
-
send_xrpc(self, request)
100
-
}
101
-
}
80
+
// Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs
102
81
103
82
pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession";
104
83
105
-
/// Authorization token types for XRPC requests
84
+
/// Authorization token types for XRPC requests.
85
+
#[derive(Debug, Clone)]
106
86
pub enum AuthorizationToken<'s> {
107
87
/// Bearer token (access JWT, refresh JWT to refresh the session)
108
88
Bearer(CowStr<'s>),
···
110
90
Dpop(CowStr<'s>),
111
91
}
112
92
113
-
impl TryFrom<AuthorizationToken<'_>> for HeaderValue {
114
-
type Error = InvalidHeaderValue;
93
+
/// Basic client wrapper: reqwest transport + in-memory token store.
94
+
pub struct BasicClient(AtClient<reqwest::Client, MemoryTokenStore>);
95
+
96
+
impl BasicClient {
97
+
/// Construct a basic client with minimal inputs.
98
+
pub fn new(base: Url) -> Self {
99
+
Self(AtClient::new(
100
+
reqwest::Client::new(),
101
+
base,
102
+
MemoryTokenStore::default(),
103
+
))
104
+
}
115
105
116
-
fn try_from(token: AuthorizationToken) -> core::result::Result<Self, Self::Error> {
117
-
HeaderValue::from_str(&match token {
118
-
AuthorizationToken::Bearer(t) => format!("Bearer {t}"),
119
-
AuthorizationToken::Dpop(t) => format!("DPoP {t}"),
120
-
})
106
+
/// Access the inner stateful client.
107
+
pub fn inner(&self) -> &AtClient<reqwest::Client, MemoryTokenStore> {
108
+
&self.0
109
+
}
110
+
111
+
/// Send an XRPC request.
112
+
pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> Result<Response<R>> {
113
+
self.0.send(req).await
114
+
}
115
+
116
+
/// Send with per-call overrides.
117
+
pub async fn send_with<R: XrpcRequest + Send>(
118
+
&self,
119
+
req: R,
120
+
overrides: SendOverrides<'_>,
121
+
) -> Result<Response<R>> {
122
+
self.0.send_with(req, overrides).await
123
+
}
124
+
125
+
/// Get current session.
126
+
pub async fn session(&self) -> Option<Session> {
127
+
self.0.session().await
128
+
}
129
+
130
+
/// Set the session.
131
+
pub async fn set_session(&self, session: Session) -> core::result::Result<(), TokenStoreError> {
132
+
self.0.set_session(session).await
133
+
}
134
+
135
+
/// Clear session.
136
+
pub async fn clear_session(&self) -> core::result::Result<(), TokenStoreError> {
137
+
self.0.clear_session().await
138
+
}
139
+
140
+
/// Base URL of this client.
141
+
pub fn base(&self) -> &Url {
142
+
self.0.base()
121
143
}
122
144
}
123
145
···
146
168
}
147
169
}
148
170
149
-
/// Generic XRPC send implementation that uses HttpClient
150
-
async fn send_xrpc<R, C>(client: &C, request: R) -> Result<Response<R>>
151
-
where
152
-
R: XrpcRequest + Send,
153
-
C: XrpcClient + ?Sized + Sync,
154
-
{
155
-
// Build URI: base_uri + /xrpc/ + NSID
156
-
let mut uri = format!("{}/xrpc/{}", client.base_uri(), R::NSID);
171
+
/// Build an HTTP request for an XRPC call given base URL and options
172
+
pub(crate) fn build_http_request<R: XrpcRequest>(
173
+
base: &Url,
174
+
req: &R,
175
+
opts: &xrpc_call::CallOptions<'_>,
176
+
) -> core::result::Result<Request<Vec<u8>>, error::TransportError> {
177
+
let mut url = base.clone();
178
+
let mut path = url.path().trim_end_matches('/').to_owned();
179
+
path.push_str("/xrpc/");
180
+
path.push_str(R::NSID);
181
+
url.set_path(&path);
157
182
158
-
// Add query parameters for Query methods
159
183
if let XrpcMethod::Query = R::METHOD {
160
-
let qs = serde_html_form::to_string(&request).map_err(error::EncodeError::from)?;
184
+
let qs = serde_html_form::to_string(&req)
185
+
.map_err(|e| error::TransportError::InvalidRequest(e.to_string()))?;
161
186
if !qs.is_empty() {
162
-
uri.push('?');
163
-
uri.push_str(&qs);
187
+
url.set_query(Some(&qs));
188
+
} else {
189
+
url.set_query(None);
164
190
}
165
191
}
166
192
167
-
// Build HTTP request
168
193
let method = match R::METHOD {
169
194
XrpcMethod::Query => http::Method::GET,
170
195
XrpcMethod::Procedure(_) => http::Method::POST,
171
196
};
172
197
173
-
let mut builder = Request::builder().method(method).uri(&uri);
198
+
let mut builder = Request::builder().method(method).uri(url.as_str());
174
199
175
-
// Add Content-Type for procedures
176
200
if let XrpcMethod::Procedure(encoding) = R::METHOD {
177
201
builder = builder.header(Header::ContentType, encoding);
178
202
}
203
+
builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING);
179
204
180
-
// Add authorization header
181
-
let is_refresh = R::NSID == NSID_REFRESH_SESSION;
182
-
if let Some(token) = client.authorization_token(is_refresh).await {
183
-
let header_value: HeaderValue = token.try_into().map_err(|e| {
205
+
if let Some(token) = &opts.auth {
206
+
let hv = match token {
207
+
AuthorizationToken::Bearer(t) => {
208
+
HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
209
+
}
210
+
AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())),
211
+
}
212
+
.map_err(|e| {
184
213
error::TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
185
214
})?;
186
-
builder = builder.header(Header::Authorization, header_value);
215
+
builder = builder.header(Header::Authorization, hv);
187
216
}
188
217
189
-
// Add atproto-proxy header
190
-
if let Some(proxy) = client.atproto_proxy_header().await {
191
-
builder = builder.header(Header::AtprotoProxy, proxy);
218
+
if let Some(proxy) = &opts.atproto_proxy {
219
+
builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
220
+
}
221
+
if let Some(labelers) = &opts.atproto_accept_labelers {
222
+
if !labelers.is_empty() {
223
+
let joined = labelers
224
+
.iter()
225
+
.map(|s| s.as_ref())
226
+
.collect::<Vec<_>>()
227
+
.join(", ");
228
+
builder = builder.header(Header::AtprotoAcceptLabelers, joined);
229
+
}
192
230
}
193
-
194
-
// Add atproto-accept-labelers header
195
-
if let Some(labelers) = client.atproto_accept_labelers_header().await {
196
-
builder = builder.header(Header::AtprotoAcceptLabelers, labelers.join(", "));
231
+
for (name, value) in &opts.extra_headers {
232
+
builder = builder.header(name, value);
197
233
}
198
234
199
-
// Serialize body for procedures
200
235
let body = if let XrpcMethod::Procedure(_) = R::METHOD {
201
-
request.encode_body()?
236
+
req.encode_body()
237
+
.map_err(|e| error::TransportError::InvalidRequest(e.to_string()))?
202
238
} else {
203
239
vec![]
204
240
};
205
241
206
-
// TODO: make this not panic
207
-
let http_request = builder.body(body).expect("Failed to build HTTP request");
208
-
209
-
// Send HTTP request
210
-
let http_response = client
211
-
.send_http(http_request)
212
-
.await
213
-
.map_err(|e| error::TransportError::Other(Box::new(e)))?;
214
-
215
-
let status = http_response.status();
216
-
let buffer = Bytes::from(http_response.into_body());
217
-
218
-
// XRPC errors come as 400/401 with structured error bodies
219
-
// Other error status codes (404, 500, etc.) are generic HTTP errors
220
-
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
221
-
return Err(ClientError::Http(error::HttpError {
222
-
status,
223
-
body: Some(buffer),
224
-
}));
225
-
}
226
-
227
-
// Response will parse XRPC errors for 400/401, or output for 2xx
228
-
Ok(Response::new(buffer, status))
242
+
builder
243
+
.body(body)
244
+
.map_err(|e| error::TransportError::InvalidRequest(e.to_string()))
229
245
}
230
246
231
247
/// Session information from `com.atproto.server.createSession`
···
256
272
}
257
273
}
258
274
259
-
/// Authenticated XRPC client wrapper that manages session tokens
260
-
///
261
-
/// Wraps an HTTP client and adds automatic Bearer token authentication for XRPC requests.
262
-
/// Handles both access tokens for regular requests and refresh tokens for session refresh.
263
-
pub struct AuthenticatedClient<C> {
264
-
client: C,
265
-
base_uri: CowStr<'static>,
266
-
session: Option<Session>,
267
-
}
268
-
269
-
impl<C> AuthenticatedClient<C> {
270
-
/// Create a new authenticated client with a base URI
271
-
///
272
-
/// # Example
273
-
/// ```ignore
274
-
/// let client = AuthenticatedClient::new(
275
-
/// reqwest::Client::new(),
276
-
/// CowStr::from("https://bsky.social")
277
-
/// );
278
-
/// ```
279
-
pub fn new(client: C, base_uri: CowStr<'static>) -> Self {
275
+
impl From<jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>>
276
+
for Session
277
+
{
278
+
fn from(
279
+
output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>,
280
+
) -> Self {
280
281
Self {
281
-
client,
282
-
base_uri: base_uri,
283
-
session: None,
284
-
}
285
-
}
286
-
287
-
/// Set the session obtained from `createSession` or `refreshSession`
288
-
pub fn set_session(&mut self, session: Session) {
289
-
self.session = Some(session);
290
-
}
291
-
292
-
/// Get the current session if one exists
293
-
pub fn session(&self) -> Option<&Session> {
294
-
self.session.as_ref()
295
-
}
296
-
297
-
/// Clear the current session locally
298
-
///
299
-
/// Note: This only clears the local session state. To properly revoke the session
300
-
/// server-side, use `com.atproto.server.deleteSession` before calling this.
301
-
pub fn clear_session(&mut self) {
302
-
self.session = None;
303
-
}
304
-
}
305
-
306
-
impl<C: HttpClient> HttpClient for AuthenticatedClient<C> {
307
-
type Error = C::Error;
308
-
309
-
fn send_http(
310
-
&self,
311
-
request: Request<Vec<u8>>,
312
-
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> {
313
-
self.client.send_http(request)
314
-
}
315
-
}
316
-
317
-
impl<C: HttpClient + Sync> XrpcClient for AuthenticatedClient<C> {
318
-
fn base_uri(&self) -> CowStr<'_> {
319
-
self.base_uri.clone()
320
-
}
321
-
322
-
async fn authorization_token(&self, is_refresh: bool) -> Option<AuthorizationToken<'_>> {
323
-
if is_refresh {
324
-
self.session
325
-
.as_ref()
326
-
.map(|s| AuthorizationToken::Bearer(s.refresh_jwt.clone()))
327
-
} else {
328
-
self.session
329
-
.as_ref()
330
-
.map(|s| AuthorizationToken::Bearer(s.access_jwt.clone()))
282
+
access_jwt: output.access_jwt.into_static(),
283
+
refresh_jwt: output.refresh_jwt.into_static(),
284
+
did: output.did.into_static(),
285
+
handle: output.handle.into_static(),
331
286
}
332
287
}
333
288
}
+232
crates/jacquard/src/client/at_client.rs
+232
crates/jacquard/src/client/at_client.rs
···
1
+
use bytes::Bytes;
2
+
use url::Url;
3
+
4
+
use crate::client::xrpc_call::{CallOptions, XrpcExt};
5
+
use crate::client::{self as super_mod, AuthorizationToken, HttpClient, Response, Session, error};
6
+
use jacquard_common::types::xrpc::XrpcRequest;
7
+
8
+
use super::token::TokenStore;
9
+
10
+
/// Per-call overrides when sending via `AtClient`.
11
+
#[derive(Debug, Default, Clone)]
12
+
pub struct SendOverrides<'a> {
13
+
/// Optional base URI override for this call.
14
+
pub base_uri: Option<Url>,
15
+
/// Per-request options such as auth, proxy, labelers, extra headers.
16
+
pub options: CallOptions<'a>,
17
+
/// Whether to auto-refresh on expired/invalid token and retry once.
18
+
pub auto_refresh: bool,
19
+
}
20
+
21
+
impl<'a> SendOverrides<'a> {
22
+
/// Construct default overrides (no base override, auto-refresh enabled).
23
+
pub fn new() -> Self {
24
+
Self {
25
+
base_uri: None,
26
+
options: CallOptions::default(),
27
+
auto_refresh: true,
28
+
}
29
+
}
30
+
/// Override the base URI for this call only.
31
+
pub fn base_uri(mut self, base: Url) -> Self {
32
+
self.base_uri = Some(base);
33
+
self
34
+
}
35
+
/// Provide a full set of call options (auth/headers/etc.).
36
+
pub fn options(mut self, opts: CallOptions<'a>) -> Self {
37
+
self.options = opts;
38
+
self
39
+
}
40
+
/// Enable or disable one-shot auto-refresh + retry behavior.
41
+
pub fn auto_refresh(mut self, enable: bool) -> Self {
42
+
self.auto_refresh = enable;
43
+
self
44
+
}
45
+
}
46
+
47
+
/// Stateful client for AT Protocol XRPC with token storage and auto-refresh.
48
+
///
49
+
/// Example (file-backed tokens)
50
+
/// ```ignore
51
+
/// use jacquard::client::{AtClient, FileTokenStore, TokenStore};
52
+
/// use jacquard::api::com_atproto::server::create_session::CreateSession;
53
+
/// use jacquard::client::AtClient as _; // method resolution
54
+
/// use jacquard::CowStr;
55
+
///
56
+
/// #[tokio::main]
57
+
/// async fn main() -> miette::Result<()> {
58
+
/// let base = url::Url::parse("https://bsky.social")?;
59
+
/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
60
+
/// let client = AtClient::new(reqwest::Client::new(), base, store);
61
+
/// let session = client
62
+
/// .send(
63
+
/// CreateSession::new()
64
+
/// .identifier(CowStr::from("alice.example"))
65
+
/// .password(CowStr::from("app-password"))
66
+
/// .build(),
67
+
/// )
68
+
/// .await?
69
+
/// .into_output()?;
70
+
/// client.set_session(session.into()).await?;
71
+
/// Ok(())
72
+
/// }
73
+
/// ```
74
+
pub struct AtClient<C: HttpClient, S: TokenStore> {
75
+
transport: C,
76
+
base: Url,
77
+
tokens: S,
78
+
refresh_lock: tokio::sync::Mutex<()>,
79
+
}
80
+
81
+
impl<C: HttpClient, S: TokenStore> AtClient<C, S> {
82
+
/// Create a new client with a transport, base URL, and token store.
83
+
pub fn new(transport: C, base: Url, tokens: S) -> Self {
84
+
Self {
85
+
transport,
86
+
base,
87
+
tokens,
88
+
refresh_lock: tokio::sync::Mutex::new(()),
89
+
}
90
+
}
91
+
92
+
/// Get the base URL of this client.
93
+
pub fn base(&self) -> &Url {
94
+
&self.base
95
+
}
96
+
97
+
/// Access the underlying transport.
98
+
pub fn transport(&self) -> &C {
99
+
&self.transport
100
+
}
101
+
102
+
/// Get the current session, if any.
103
+
pub async fn session(&self) -> Option<Session> {
104
+
self.tokens.get().await
105
+
}
106
+
107
+
/// Set the current session in the token store.
108
+
pub async fn set_session(&self, session: Session) -> Result<(), super_mod::TokenStoreError> {
109
+
self.tokens.set(session).await
110
+
}
111
+
112
+
/// Clear the current session from the token store.
113
+
pub async fn clear_session(&self) -> Result<(), super_mod::TokenStoreError> {
114
+
self.tokens.clear().await
115
+
}
116
+
117
+
/// Send an XRPC request using the client's base URL and default behavior.
118
+
pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> super_mod::Result<Response<R>> {
119
+
self.send_with(req, SendOverrides::new()).await
120
+
}
121
+
122
+
/// Send an XRPC request with per-call overrides.
123
+
pub async fn send_with<R: XrpcRequest + Send>(
124
+
&self,
125
+
req: R,
126
+
mut overrides: SendOverrides<'_>,
127
+
) -> super_mod::Result<Response<R>> {
128
+
let base = overrides
129
+
.base_uri
130
+
.clone()
131
+
.unwrap_or_else(|| self.base.clone());
132
+
let is_refresh = R::NSID == super_mod::NSID_REFRESH_SESSION;
133
+
134
+
if overrides.options.auth.is_none() {
135
+
if let Some(s) = self.tokens.get().await {
136
+
overrides.options.auth = Some(if is_refresh {
137
+
AuthorizationToken::Bearer(s.refresh_jwt)
138
+
} else {
139
+
AuthorizationToken::Bearer(s.access_jwt)
140
+
});
141
+
}
142
+
}
143
+
144
+
let http_request = super_mod::build_http_request(&base, &req, &overrides.options)
145
+
.map_err(error::TransportError::from)?;
146
+
let http_response = self
147
+
.transport
148
+
.send_http(http_request)
149
+
.await
150
+
.map_err(|e| error::TransportError::Other(Box::new(e)))?;
151
+
let status = http_response.status();
152
+
let buffer = Bytes::from(http_response.into_body());
153
+
154
+
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
155
+
return Err(error::HttpError {
156
+
status,
157
+
body: Some(buffer),
158
+
}
159
+
.into());
160
+
}
161
+
162
+
if overrides.auto_refresh
163
+
&& !is_refresh
164
+
&& overrides.options.auth.is_some()
165
+
&& Self::is_auth_expired(status, &buffer)
166
+
{
167
+
self.refresh_once().await?;
168
+
169
+
let mut retry_opts = overrides.options.clone();
170
+
if let Some(s) = self.tokens.get().await {
171
+
retry_opts.auth = Some(AuthorizationToken::Bearer(s.access_jwt));
172
+
}
173
+
let http_request = super_mod::build_http_request(&base, &req, &retry_opts)
174
+
.map_err(error::TransportError::from)?;
175
+
let http_response = self
176
+
.transport
177
+
.send_http(http_request)
178
+
.await
179
+
.map_err(|e| error::TransportError::Other(Box::new(e)))?;
180
+
let status = http_response.status();
181
+
let buffer = Bytes::from(http_response.into_body());
182
+
183
+
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
184
+
return Err(error::HttpError {
185
+
status,
186
+
body: Some(buffer),
187
+
}
188
+
.into());
189
+
}
190
+
return Ok(Response::new(buffer, status));
191
+
}
192
+
193
+
Ok(Response::new(buffer, status))
194
+
}
195
+
196
+
async fn refresh_once(&self) -> super_mod::Result<()> {
197
+
let _guard = self.refresh_lock.lock().await;
198
+
let Some(s) = self.tokens.get().await else {
199
+
return Err(error::ClientError::Auth(error::AuthError::NotAuthenticated));
200
+
};
201
+
let refresh_token = s.refresh_jwt.clone();
202
+
let refresh_resp = self
203
+
.transport
204
+
.xrpc(self.base.clone())
205
+
.auth(AuthorizationToken::Bearer(refresh_token))
206
+
.send(jacquard_api::com_atproto::server::refresh_session::RefreshSession)
207
+
.await?;
208
+
let refreshed = match refresh_resp.into_output() {
209
+
Ok(o) => Session::from(o),
210
+
Err(_) => return Err(error::ClientError::Auth(error::AuthError::RefreshFailed)),
211
+
};
212
+
self.tokens
213
+
.set(refreshed)
214
+
.await
215
+
.map_err(|_| error::ClientError::Auth(error::AuthError::RefreshFailed))?;
216
+
Ok(())
217
+
}
218
+
219
+
fn is_auth_expired(status: http::StatusCode, buffer: &Bytes) -> bool {
220
+
if status.as_u16() == 401 {
221
+
return true;
222
+
}
223
+
if status.as_u16() == 400 {
224
+
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(buffer) {
225
+
if let Some(code) = val.get("error").and_then(|v| v.as_str()) {
226
+
return matches!(code, "ExpiredToken" | "InvalidToken");
227
+
}
228
+
}
229
+
}
230
+
false
231
+
}
232
+
}
+125
crates/jacquard/src/client/token.rs
+125
crates/jacquard/src/client/token.rs
···
1
+
use async_trait::async_trait;
2
+
use std::path::{Path, PathBuf};
3
+
use std::sync::Arc;
4
+
use thiserror::Error;
5
+
6
+
use super::Session;
7
+
use jacquard_common::IntoStatic;
8
+
use jacquard_common::types::string::{Did, Handle};
9
+
10
+
/// Errors emitted by token stores.
11
+
#[derive(Debug, Error)]
12
+
pub enum TokenStoreError {
13
+
/// An underlying I/O or serialization error with context.
14
+
#[error("token store error: {0}")]
15
+
Other(String),
16
+
}
17
+
18
+
/// Pluggable session token storage (memory, disk, browser, etc.).
19
+
#[async_trait]
20
+
pub trait TokenStore: Send + Sync {
21
+
/// Get the current session if present.
22
+
async fn get(&self) -> Option<Session>;
23
+
/// Persist the given session.
24
+
async fn set(&self, session: Session) -> Result<(), TokenStoreError>;
25
+
/// Remove any stored session.
26
+
async fn clear(&self) -> Result<(), TokenStoreError>;
27
+
}
28
+
29
+
/// In-memory token store suitable for short-lived sessions and tests.
30
+
#[derive(Default, Clone)]
31
+
pub struct MemoryTokenStore(Arc<tokio::sync::RwLock<Option<Session>>>);
32
+
33
+
#[async_trait]
34
+
impl TokenStore for MemoryTokenStore {
35
+
async fn get(&self) -> Option<Session> {
36
+
self.0.read().await.clone()
37
+
}
38
+
async fn set(&self, session: Session) -> Result<(), TokenStoreError> {
39
+
*self.0.write().await = Some(session);
40
+
Ok(())
41
+
}
42
+
async fn clear(&self) -> Result<(), TokenStoreError> {
43
+
*self.0.write().await = None;
44
+
Ok(())
45
+
}
46
+
}
47
+
48
+
/// File-backed token store using a JSON file.
49
+
///
50
+
/// Example
51
+
/// ```ignore
52
+
/// use jacquard::client::{AtClient, FileTokenStore};
53
+
/// let base = url::Url::parse("https://bsky.social").unwrap();
54
+
/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
55
+
/// let client = AtClient::new(reqwest::Client::new(), base, store);
56
+
/// ```
57
+
#[derive(Clone, Debug)]
58
+
pub struct FileTokenStore {
59
+
path: PathBuf,
60
+
}
61
+
62
+
impl FileTokenStore {
63
+
/// Create a new file token store at the given path.
64
+
pub fn new(path: impl AsRef<Path>) -> Self {
65
+
Self {
66
+
path: path.as_ref().to_path_buf(),
67
+
}
68
+
}
69
+
}
70
+
71
+
#[derive(serde::Serialize, serde::Deserialize)]
72
+
struct FileSession {
73
+
access_jwt: String,
74
+
refresh_jwt: String,
75
+
did: String,
76
+
handle: String,
77
+
}
78
+
79
+
#[async_trait]
80
+
impl TokenStore for FileTokenStore {
81
+
async fn get(&self) -> Option<Session> {
82
+
let data = tokio::fs::read(&self.path).await.ok()?;
83
+
let disk: FileSession = serde_json::from_slice(&data).ok()?;
84
+
let did = Did::new_owned(disk.did).ok()?;
85
+
let handle = Handle::new_owned(disk.handle).ok()?;
86
+
Some(Session {
87
+
access_jwt: disk.access_jwt.into(),
88
+
refresh_jwt: disk.refresh_jwt.into(),
89
+
did: did.into_static(),
90
+
handle: handle.into_static(),
91
+
})
92
+
}
93
+
94
+
async fn set(&self, session: Session) -> Result<(), TokenStoreError> {
95
+
let disk = FileSession {
96
+
access_jwt: session.access_jwt.to_string(),
97
+
refresh_jwt: session.refresh_jwt.to_string(),
98
+
did: session.did.to_string(),
99
+
handle: session.handle.to_string(),
100
+
};
101
+
let buf =
102
+
serde_json::to_vec_pretty(&disk).map_err(|e| TokenStoreError::Other(e.to_string()))?;
103
+
if let Some(parent) = self.path.parent() {
104
+
tokio::fs::create_dir_all(parent)
105
+
.await
106
+
.map_err(|e| TokenStoreError::Other(e.to_string()))?;
107
+
}
108
+
let tmp = self.path.with_extension("tmp");
109
+
tokio::fs::write(&tmp, &buf)
110
+
.await
111
+
.map_err(|e| TokenStoreError::Other(e.to_string()))?;
112
+
tokio::fs::rename(&tmp, &self.path)
113
+
.await
114
+
.map_err(|e| TokenStoreError::Other(e.to_string()))?;
115
+
Ok(())
116
+
}
117
+
118
+
async fn clear(&self) -> Result<(), TokenStoreError> {
119
+
match tokio::fs::remove_file(&self.path).await {
120
+
Ok(_) => Ok(()),
121
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
122
+
Err(e) => Err(TokenStoreError::Other(e.to_string())),
123
+
}
124
+
}
125
+
}
+154
crates/jacquard/src/client/xrpc_call.rs
+154
crates/jacquard/src/client/xrpc_call.rs
···
1
+
use bytes::Bytes;
2
+
use http::{HeaderName, HeaderValue};
3
+
use url::Url;
4
+
5
+
use crate::CowStr;
6
+
use crate::client::{self as super_mod, Response, error};
7
+
use crate::client::{AuthorizationToken, HttpClient};
8
+
use jacquard_common::types::xrpc::XrpcRequest;
9
+
10
+
/// Per-request options for XRPC calls.
11
+
#[derive(Debug, Default, Clone)]
12
+
pub struct CallOptions<'a> {
13
+
/// Optional Authorization to apply (`Bearer` or `DPoP`).
14
+
pub auth: Option<AuthorizationToken<'a>>,
15
+
/// `atproto-proxy` header value.
16
+
pub atproto_proxy: Option<CowStr<'a>>,
17
+
/// `atproto-accept-labelers` header values.
18
+
pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
19
+
/// Extra headers to attach to this request.
20
+
pub extra_headers: Vec<(HeaderName, HeaderValue)>,
21
+
}
22
+
23
+
/// Extension for stateless XRPC calls on any `HttpClient`.
24
+
///
25
+
/// Example
26
+
/// ```ignore
27
+
/// use jacquard::client::XrpcExt;
28
+
/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
29
+
/// use jacquard::types::ident::AtIdentifier;
30
+
/// use miette::IntoDiagnostic;
31
+
///
32
+
/// #[tokio::main]
33
+
/// async fn main() -> miette::Result<()> {
34
+
/// let http = reqwest::Client::new();
35
+
/// let base = url::Url::parse("https://public.api.bsky.app")?;
36
+
/// let resp = http
37
+
/// .xrpc(base)
38
+
/// .send(
39
+
/// GetAuthorFeed::new()
40
+
/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
41
+
/// .limit(5)
42
+
/// .build(),
43
+
/// )
44
+
/// .await?;
45
+
/// let out = resp.into_output()?;
46
+
/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
47
+
/// Ok(())
48
+
/// }
49
+
/// ```
50
+
pub trait XrpcExt: HttpClient {
51
+
/// Start building an XRPC call for the given base URL.
52
+
fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self>
53
+
where
54
+
Self: Sized,
55
+
{
56
+
XrpcCall {
57
+
client: self,
58
+
base,
59
+
opts: CallOptions::default(),
60
+
}
61
+
}
62
+
}
63
+
64
+
impl<T: HttpClient> XrpcExt for T {}
65
+
66
+
/// Stateless XRPC call builder.
67
+
///
68
+
/// Example (per-request overrides)
69
+
/// ```ignore
70
+
/// use jacquard::client::{XrpcExt, AuthorizationToken};
71
+
/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
72
+
/// use jacquard::types::ident::AtIdentifier;
73
+
/// use jacquard::CowStr;
74
+
/// use miette::IntoDiagnostic;
75
+
///
76
+
/// #[tokio::main]
77
+
/// async fn main() -> miette::Result<()> {
78
+
/// let http = reqwest::Client::new();
79
+
/// let base = url::Url::parse("https://public.api.bsky.app")?;
80
+
/// let resp = http
81
+
/// .xrpc(base)
82
+
/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
83
+
/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
84
+
/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
85
+
/// .send(
86
+
/// GetAuthorFeed::new()
87
+
/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
88
+
/// .limit(5)
89
+
/// .build(),
90
+
/// )
91
+
/// .await?;
92
+
/// let out = resp.into_output()?;
93
+
/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
94
+
/// Ok(())
95
+
/// }
96
+
/// ```
97
+
pub struct XrpcCall<'a, C: HttpClient> {
98
+
pub(crate) client: &'a C,
99
+
pub(crate) base: Url,
100
+
pub(crate) opts: CallOptions<'a>,
101
+
}
102
+
103
+
impl<'a, C: HttpClient> XrpcCall<'a, C> {
104
+
/// Apply Authorization to this call.
105
+
pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
106
+
self.opts.auth = Some(token);
107
+
self
108
+
}
109
+
/// Set `atproto-proxy` header for this call.
110
+
pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
111
+
self.opts.atproto_proxy = Some(proxy);
112
+
self
113
+
}
114
+
/// Set `atproto-accept-labelers` header(s) for this call.
115
+
pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
116
+
self.opts.atproto_accept_labelers = Some(labelers);
117
+
self
118
+
}
119
+
/// Add an extra header.
120
+
pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
121
+
self.opts.extra_headers.push((name, value));
122
+
self
123
+
}
124
+
/// Replace the builder's options entirely.
125
+
pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
126
+
self.opts = opts;
127
+
self
128
+
}
129
+
130
+
/// Send the given typed XRPC request and return a response wrapper.
131
+
pub async fn send<R: XrpcRequest + Send>(self, request: R) -> super_mod::Result<Response<R>> {
132
+
let http_request = super_mod::build_http_request(&self.base, &request, &self.opts)
133
+
.map_err(error::TransportError::from)?;
134
+
135
+
let http_response = self
136
+
.client
137
+
.send_http(http_request)
138
+
.await
139
+
.map_err(|e| error::TransportError::Other(Box::new(e)))?;
140
+
141
+
let status = http_response.status();
142
+
let buffer = Bytes::from(http_response.into_body());
143
+
144
+
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
145
+
return Err(error::HttpError {
146
+
status,
147
+
body: Some(buffer),
148
+
}
149
+
.into());
150
+
}
151
+
152
+
Ok(Response::new(buffer, status))
153
+
}
154
+
}
+37
-93
crates/jacquard/src/identity/resolver.rs
+37
-93
crates/jacquard/src/identity/resolver.rs
···
1
1
//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
2
2
//!
3
3
//! Fallback order (default):
4
-
//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → embedded XRPC
5
-
//! `resolveHandle` → public API fallback → Slingshot `resolveHandle` (if configured).
6
-
//! - DID → Doc: did:web well-known → PLC/slingshot HTTP → embedded XRPC `resolveDid`,
4
+
//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC
5
+
//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured).
6
+
//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured),
7
7
//! then Slingshot mini‑doc (partial) if configured.
8
8
//!
9
9
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10
10
//! and optionally validate the document `id` against the requested DID.
11
11
12
-
use crate::CowStr;
13
-
use crate::client::AuthenticatedClient;
12
+
// use crate::CowStr; // not currently needed directly here
13
+
use crate::client::XrpcExt;
14
14
use bon::Builder;
15
15
use bytes::Bytes;
16
16
use jacquard_common::IntoStatic;
···
183
183
/// Configurable resolver options.
184
184
///
185
185
/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
186
-
/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (auth-aware
187
-
/// paths available via helpers that take an `XrpcClient`).
186
+
/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless
187
+
/// XRPC over reqwest; authentication can be layered as needed).
188
188
/// - `handle_order`/`did_order`: ordered strategies for resolution.
189
189
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
190
190
/// returning `DocIdMismatch` with the fetched document on mismatch.
191
191
/// - `public_fallback_for_handle`: if true (default), attempt
192
192
/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
193
-
/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the embedded XRPC
193
+
/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC
194
194
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
195
195
#[derive(Debug, Clone, Builder)]
196
196
#[builder(start_fn = new)]
···
238
238
/// - HTTPS well-known for handles and `did:web`
239
239
/// - PLC directory or Slingshot for `did:plc`
240
240
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
241
-
/// - Auth-aware PDS fallbacks via helpers that accept an `XrpcClient`
241
+
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
242
242
#[async_trait::async_trait]
243
243
pub trait IdentityResolver {
244
244
/// Access options for validation decisions in default methods
···
284
284
}
285
285
286
286
/// Default resolver implementation with configurable fallback order.
287
-
///
288
-
/// Behavior highlights:
289
-
/// - Handle resolution tries DNS TXT (if enabled via `dns` feature), then HTTPS
290
-
/// well-known, then Slingshot's unauthenticated `resolveHandle` when
291
-
/// `PlcSource::Slingshot` is configured.
292
-
/// - DID resolution tries did:web well-known for `did:web`, and the configured
293
-
/// PLC base (PLC directory or Slingshot) for `did:plc`.
294
-
/// - PDS-authenticated fallbacks (e.g., `resolveHandle`, `resolveDid` on a PDS)
295
-
/// are available via helper methods that accept a user-provided `XrpcClient`.
296
-
///
297
-
/// Example
298
-
/// ```ignore
299
-
/// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
300
-
/// # use jacquard::client::{AuthenticatedClient, XrpcClient};
301
-
/// # use jacquard::types::string::Handle;
302
-
/// # use jacquard::CowStr;
303
-
///
304
-
/// // Build an auth-capable XRPC client (without a session it behaves like public/unauth)
305
-
/// let http = reqwest::Client::new();
306
-
/// let xrpc = AuthenticatedClient::new(http.clone(), CowStr::new_static("https://bsky.social"));
307
-
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default());
308
-
///
309
-
/// // Resolve a handle to a DID
310
-
/// let did = tokio_test::block_on(async { resolver.resolve_handle(&Handle::new("bad-example.com").unwrap()).await }).unwrap();
311
-
/// ```
312
-
pub struct DefaultResolver<C: crate::client::XrpcClient + Send + Sync> {
287
+
pub struct DefaultResolver {
313
288
http: reqwest::Client,
314
-
xrpc: C,
315
289
opts: ResolverOptions,
316
290
#[cfg(feature = "dns")]
317
291
dns: Option<TokioAsyncResolver>,
318
292
}
319
293
320
-
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
294
+
impl DefaultResolver {
321
295
/// Create a new instance of the default resolver with all options (except DNS) up front
322
-
pub fn new(http: reqwest::Client, xrpc: C, opts: ResolverOptions) -> Self {
296
+
pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
323
297
Self {
324
298
http,
325
-
xrpc,
326
299
opts,
327
300
#[cfg(feature = "dns")]
328
301
dns: None,
···
439
412
}
440
413
}
441
414
442
-
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
443
-
/// Resolve handle to DID via a PDS XRPC client (auth-aware path)
415
+
impl DefaultResolver {
416
+
/// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
444
417
pub async fn resolve_handle_via_pds(
445
418
&self,
446
419
handle: &Handle<'_>,
447
420
) -> Result<Did<'static>, IdentityError> {
421
+
let pds = match &self.opts.pds_fallback {
422
+
Some(u) => u.clone(),
423
+
None => return Err(IdentityError::InvalidWellKnown),
424
+
};
448
425
let req = ResolveHandle::new().handle((*handle).clone()).build();
449
426
let resp = self
450
-
.xrpc
427
+
.http
428
+
.xrpc(pds)
451
429
.send(req)
452
430
.await
453
431
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
···
464
442
&self,
465
443
did: &Did<'_>,
466
444
) -> Result<DidDocument<'static>, IdentityError> {
445
+
let pds = match &self.opts.pds_fallback {
446
+
Some(u) => u.clone(),
447
+
None => return Err(IdentityError::InvalidWellKnown),
448
+
};
467
449
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
468
450
let resp = self
469
-
.xrpc
451
+
.http
452
+
.xrpc(pds)
470
453
.send(req)
471
454
.await
472
455
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
···
510
493
}
511
494
512
495
#[async_trait::async_trait]
513
-
impl<C: crate::client::XrpcClient + Send + Sync> IdentityResolver for DefaultResolver<C> {
496
+
impl IdentityResolver for DefaultResolver {
514
497
fn options(&self) -> &ResolverOptions {
515
498
&self.opts
516
499
}
···
541
524
}
542
525
}
543
526
HandleStep::PdsResolveHandle => {
544
-
// Prefer embedded XRPC client
527
+
// Prefer PDS XRPC via stateless client
545
528
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
546
529
return Ok(did);
547
530
}
···
630
613
}
631
614
}
632
615
DidStep::PdsResolveDid => {
633
-
// Try embedded XRPC client for full DID doc
616
+
// Try PDS XRPC for full DID doc
634
617
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
635
618
let buf = serde_json::to_vec(&doc).unwrap_or_default();
636
619
return Ok(DidDocResponse {
···
667
650
},
668
651
}
669
652
670
-
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
653
+
impl DefaultResolver {
671
654
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
672
655
/// This applies the default equality check on the document id (error with doc if mismatch).
673
656
pub async fn resolve_handle_and_doc(
···
772
755
773
756
#[test]
774
757
fn did_web_urls() {
775
-
let r = DefaultResolver::new(
776
-
reqwest::Client::new(),
777
-
TestXrpc::new(),
778
-
ResolverOptions::default(),
779
-
);
758
+
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
780
759
assert_eq!(
781
760
r.test_did_web_url_raw("did:web:example.com"),
782
761
"https://example.com/.well-known/did.json"
···
819
798
820
799
#[test]
821
800
fn slingshot_mini_doc_url_build() {
822
-
let r = DefaultResolver::new(
823
-
reqwest::Client::new(),
824
-
TestXrpc::new(),
825
-
ResolverOptions::default(),
826
-
);
801
+
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
827
802
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
828
803
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
829
804
assert_eq!(
···
873
848
other => panic!("unexpected: {:?}", other),
874
849
}
875
850
}
876
-
use crate::client::{HttpClient, XrpcClient};
877
-
use http::Request;
878
-
use jacquard_common::CowStr;
879
-
880
-
struct TestXrpc {
881
-
client: reqwest::Client,
882
-
}
883
-
impl TestXrpc {
884
-
fn new() -> Self {
885
-
Self {
886
-
client: reqwest::Client::new(),
887
-
}
888
-
}
889
-
}
890
-
impl HttpClient for TestXrpc {
891
-
type Error = reqwest::Error;
892
-
async fn send_http(
893
-
&self,
894
-
request: Request<Vec<u8>>,
895
-
) -> Result<http::Response<Vec<u8>>, Self::Error> {
896
-
self.client.send_http(request).await
897
-
}
898
-
}
899
-
impl XrpcClient for TestXrpc {
900
-
fn base_uri(&self) -> CowStr<'_> {
901
-
CowStr::from("https://public.api.bsky.app")
902
-
}
903
-
}
904
851
}
905
852
906
-
/// Resolver specialized for unauthenticated/public flows using reqwest + AuthenticatedClient
907
-
pub type PublicResolver = DefaultResolver<AuthenticatedClient<reqwest::Client>>;
853
+
/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
854
+
pub type PublicResolver = DefaultResolver;
908
855
909
856
impl Default for PublicResolver {
910
857
/// Build a resolver with:
911
858
/// - reqwest HTTP client
912
-
/// - XRPC base https://public.api.bsky.app (unauthenticated)
859
+
/// - Public fallbacks enabled for handle resolution
913
860
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
914
861
///
915
862
/// Example
···
919
866
/// ```
920
867
fn default() -> Self {
921
868
let http = reqwest::Client::new();
922
-
let xrpc =
923
-
AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
924
869
let opts = ResolverOptions::default();
925
-
let resolver = DefaultResolver::new(http, xrpc, opts);
870
+
let resolver = DefaultResolver::new(http, opts);
926
871
#[cfg(feature = "dns")]
927
872
let resolver = resolver.with_system_dns();
928
873
resolver
···
933
878
/// mini-doc fallbacks, unauthenticated by default.
934
879
pub fn slingshot_resolver_default() -> PublicResolver {
935
880
let http = reqwest::Client::new();
936
-
let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
937
881
let mut opts = ResolverOptions::default();
938
882
opts.plc_source = PlcSource::slingshot_default();
939
-
let resolver = DefaultResolver::new(http, xrpc, opts);
883
+
let resolver = DefaultResolver::new(http, opts);
940
884
#[cfg(feature = "dns")]
941
885
let resolver = resolver.with_system_dns();
942
886
resolver
+82
-3
crates/jacquard/src/lib.rs
+82
-3
crates/jacquard/src/lib.rs
···
24
24
//! # use jacquard::CowStr;
25
25
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
26
26
//! use jacquard::api::com_atproto::server::create_session::CreateSession;
27
-
//! use jacquard::client::{AuthenticatedClient, Session, XrpcClient};
27
+
//! use jacquard::client::{BasicClient, Session};
28
28
//! # use miette::IntoDiagnostic;
29
29
//!
30
30
//! # #[derive(Parser, Debug)]
···
48
48
//! let args = Args::parse();
49
49
//!
50
50
//! // Create HTTP client
51
-
//! let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds);
51
+
//! let url = url::Url::parse(&args.pds).unwrap();
52
+
//! let client = BasicClient::new(url);
52
53
//!
53
54
//! // Create session
54
55
//! let session = Session::from(
···
64
65
//! );
65
66
//!
66
67
//! println!("logged in as {} ({})", session.handle, session.did);
67
-
//! client.set_session(session);
68
+
//! client.set_session(session).await.unwrap();
68
69
//!
69
70
//! // Fetch timeline
70
71
//! println!("\nfetching timeline...");
···
85
86
//! Ok(())
86
87
//! }
87
88
//! ```
89
+
//!
90
+
//! ## Clients
91
+
//!
92
+
//! - Stateless XRPC: any `HttpClient` (e.g., `reqwest::Client`) implements `XrpcExt`,
93
+
//! which provides `xrpc(base: Url) -> XrpcCall` for per-request calls with
94
+
//! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you
95
+
//! want to pass auth on each call or build advanced flows.
96
+
//! Example
97
+
//! ```ignore
98
+
//! use jacquard::client::XrpcExt;
99
+
//! use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
100
+
//! use jacquard::types::ident::AtIdentifier;
101
+
//!
102
+
//! #[tokio::main]
103
+
//! async fn main() -> anyhow::Result<()> {
104
+
//! let http = reqwest::Client::new();
105
+
//! let base = url::Url::parse("https://public.api.bsky.app")?;
106
+
//! let resp = http
107
+
//! .xrpc(base)
108
+
//! .send(
109
+
//! GetAuthorFeed::new()
110
+
//! .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
111
+
//! .limit(5)
112
+
//! .build(),
113
+
//! )
114
+
//! .await?;
115
+
//! let out = resp.into_output()?;
116
+
//! println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
117
+
//! Ok(())
118
+
//! }
119
+
//! ```
120
+
//! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a
121
+
//! `TokenStore` implementation. It automatically sets Authorization and can
122
+
//! auto-refresh a session when expired, retrying once.
123
+
//! - Convenience wrapper: `BasicClient` is an ergonomic newtype over
124
+
//! `AtClient<reqwest::Client, MemoryTokenStore>` with a `new(Url)` constructor.
125
+
//!
126
+
//! Per-request overrides (stateless)
127
+
//! ```ignore
128
+
//! use jacquard::client::{XrpcExt, AuthorizationToken};
129
+
//! use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
130
+
//! use jacquard::types::ident::AtIdentifier;
131
+
//! use jacquard::CowStr;
132
+
//! use miette::IntoDiagnostic;
133
+
//!
134
+
//! #[tokio::main]
135
+
//! async fn main() -> miette::Result<()> {
136
+
//! let http = reqwest::Client::new();
137
+
//! let base = url::Url::parse("https://public.api.bsky.app")?;
138
+
//! let resp = http
139
+
//! .xrpc(base)
140
+
//! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
141
+
//! .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
142
+
//! .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
143
+
//! .send(
144
+
//! GetAuthorFeed::new()
145
+
//! .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
146
+
//! .limit(5)
147
+
//! .build(),
148
+
//! )
149
+
//! .await?;
150
+
//! let out = resp.into_output()?;
151
+
//! println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
152
+
//! Ok(())
153
+
//! }
154
+
//! ```
155
+
//!
156
+
//! Token storage:
157
+
//! - Use `MemoryTokenStore` for ephemeral sessions, tests, and CLIs.
158
+
//! - For persistence, `FileTokenStore` stores session tokens as JSON on disk.
159
+
//! See `client::token::FileTokenStore` docs for details.
160
+
//! Example
161
+
//! ```ignore
162
+
//! use jacquard::client::{AtClient, FileTokenStore};
163
+
//! let base = url::Url::parse("https://bsky.social").unwrap();
164
+
//! let store = FileTokenStore::new("/tmp/jacquard-session.json");
165
+
//! let client = AtClient::new(reqwest::Client::new(), base, store);
166
+
//! ```
88
167
//!
89
168
90
169
#![warn(missing_docs)]
+9
-4
crates/jacquard/src/main.rs
+9
-4
crates/jacquard/src/main.rs
···
2
2
use jacquard::CowStr;
3
3
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
4
4
use jacquard::api::com_atproto::server::create_session::CreateSession;
5
-
use jacquard::client::{AuthenticatedClient, Session, XrpcClient};
5
+
use jacquard::client::{BasicClient, Session};
6
+
use jacquard::identity::resolver::{slingshot_resolver_default, IdentityResolver};
7
+
use jacquard::types::string::Handle;
6
8
use miette::IntoDiagnostic;
7
9
8
10
#[derive(Parser, Debug)]
···
24
26
async fn main() -> miette::Result<()> {
25
27
let args = Args::parse();
26
28
27
-
// Create HTTP client
28
-
let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds);
29
+
// Resolve PDS for the handle using the Slingshot-enabled resolver
30
+
let resolver = slingshot_resolver_default();
31
+
let handle = Handle::new(args.username.as_ref()).into_diagnostic()?;
32
+
let (_did, pds_url) = resolver.pds_for_handle(&handle).await.into_diagnostic()?;
33
+
let client = BasicClient::new(pds_url);
29
34
30
35
// Create session
31
36
let session = Session::from(
···
41
46
);
42
47
43
48
println!("logged in as {} ({})", session.handle, session.did);
44
-
client.set_session(session);
49
+
client.set_session(session).await.into_diagnostic()?;
45
50
46
51
// Fetch timeline
47
52
println!("\nfetching timeline...");