+1
.gitignore
+1
.gitignore
+4
-3
README.md
+4
-3
README.md
···
25
use jacquard::CowStr;
26
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
27
use jacquard::api::com_atproto::server::create_session::CreateSession;
28
-
use jacquard::client::{AuthenticatedClient, Session, XrpcClient};
29
use miette::IntoDiagnostic;
30
31
#[derive(Parser, Debug)]
···
49
let args = Args::parse();
50
51
// Create HTTP client
52
-
let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds);
53
54
// Create session
55
let session = Session::from(
···
65
);
66
67
println!("logged in as {} ({})", session.handle, session.did);
68
-
client.set_session(session);
69
70
// Fetch timeline
71
println!("\nfetching timeline...");
···
25
use jacquard::CowStr;
26
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
27
use jacquard::api::com_atproto::server::create_session::CreateSession;
28
+
use jacquard::client::{BasicClient, Session};
29
use miette::IntoDiagnostic;
30
31
#[derive(Parser, Debug)]
···
49
let args = Args::parse();
50
51
// Create HTTP client
52
+
let base = url::Url::parse(&args.pds).into_diagnostic()?;
53
+
let client = BasicClient::new(base);
54
55
// Create session
56
let session = Session::from(
···
66
);
67
68
println!("logged in as {} ({})", session.handle, session.did);
69
+
client.set_session(session).await.into_diagnostic()?;
70
71
// Fetch timeline
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
license.workspace = true
13
14
[features]
15
-
default = ["api_all"]
16
derive = ["dep:jacquard-derive"]
17
api = ["jacquard-api/com_atproto"]
18
api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"]
···
42
serde_ipld_dagcbor.workspace = true
43
serde_json.workspace = true
44
thiserror.workspace = true
45
-
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
46
hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true }
47
url.workspace = true
48
smol_str.workspace = true
···
12
license.workspace = true
13
14
[features]
15
+
default = ["api_all", "dns"]
16
derive = ["dep:jacquard-derive"]
17
api = ["jacquard-api/com_atproto"]
18
api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"]
···
42
serde_ipld_dagcbor.workspace = true
43
serde_json.workspace = true
44
thiserror.workspace = true
45
+
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }
46
hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true }
47
url.workspace = true
48
smol_str.workspace = true
+117
-162
crates/jacquard/src/client.rs
+117
-162
crates/jacquard/src/client.rs
···
3
//! This module provides HTTP and XRPC client traits along with an authenticated
4
//! client implementation that manages session tokens.
5
6
mod error;
7
mod response;
8
9
use std::fmt::Display;
10
use std::future::Future;
11
12
-
use bytes::Bytes;
13
pub use error::{ClientError, Result};
14
use http::{
15
HeaderName, HeaderValue, Request,
16
-
header::{AUTHORIZATION, CONTENT_TYPE, InvalidHeaderValue},
17
};
18
pub use response::Response;
19
20
use jacquard_common::{
21
CowStr, IntoStatic,
···
24
xrpc::{XrpcMethod, XrpcRequest},
25
},
26
};
27
28
/// Implement HttpClient for reqwest::Client
29
impl HttpClient for reqwest::Client {
···
61
}
62
}
63
64
-
/// HTTP client trait for sending raw HTTP requests
65
pub trait HttpClient {
66
/// Error type returned by the HTTP client
67
type Error: std::error::Error + Display + Send + Sync + 'static;
···
71
request: Request<Vec<u8>>,
72
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
73
}
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
-
}
102
103
pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession";
104
105
-
/// Authorization token types for XRPC requests
106
pub enum AuthorizationToken<'s> {
107
/// Bearer token (access JWT, refresh JWT to refresh the session)
108
Bearer(CowStr<'s>),
···
110
Dpop(CowStr<'s>),
111
}
112
113
-
impl TryFrom<AuthorizationToken<'_>> for HeaderValue {
114
-
type Error = InvalidHeaderValue;
115
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
-
})
121
}
122
}
123
···
146
}
147
}
148
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);
157
158
-
// Add query parameters for Query methods
159
if let XrpcMethod::Query = R::METHOD {
160
-
let qs = serde_html_form::to_string(&request).map_err(error::EncodeError::from)?;
161
if !qs.is_empty() {
162
-
uri.push('?');
163
-
uri.push_str(&qs);
164
}
165
}
166
167
-
// Build HTTP request
168
let method = match R::METHOD {
169
XrpcMethod::Query => http::Method::GET,
170
XrpcMethod::Procedure(_) => http::Method::POST,
171
};
172
173
-
let mut builder = Request::builder().method(method).uri(&uri);
174
175
-
// Add Content-Type for procedures
176
if let XrpcMethod::Procedure(encoding) = R::METHOD {
177
builder = builder.header(Header::ContentType, encoding);
178
}
179
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| {
184
error::TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
185
})?;
186
-
builder = builder.header(Header::Authorization, header_value);
187
}
188
189
-
// Add atproto-proxy header
190
-
if let Some(proxy) = client.atproto_proxy_header().await {
191
-
builder = builder.header(Header::AtprotoProxy, proxy);
192
}
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(", "));
197
}
198
199
-
// Serialize body for procedures
200
let body = if let XrpcMethod::Procedure(_) = R::METHOD {
201
-
request.encode_body()?
202
} else {
203
vec![]
204
};
205
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))
229
}
230
231
/// Session information from `com.atproto.server.createSession`
···
256
}
257
}
258
259
-
/// Authenticated XRPC client wrapper that manages session tokens
260
-
///
261
-
/// Wraps an HTTP client and adds automatic Bearer token authentication for XRPC requests.
262
-
/// Handles both access tokens for regular requests and refresh tokens for session refresh.
263
-
pub struct AuthenticatedClient<C> {
264
-
client: C,
265
-
base_uri: CowStr<'static>,
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 {
280
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()))
331
}
332
}
333
}
···
3
//! This module provides HTTP and XRPC client traits along with an authenticated
4
//! client implementation that manages session tokens.
5
6
+
mod at_client;
7
mod error;
8
mod response;
9
+
mod token;
10
+
mod xrpc_call;
11
12
use std::fmt::Display;
13
use std::future::Future;
14
15
+
pub use at_client::{AtClient, SendOverrides};
16
pub use error::{ClientError, Result};
17
use http::{
18
HeaderName, HeaderValue, Request,
19
+
header::{AUTHORIZATION, CONTENT_TYPE},
20
};
21
pub use response::Response;
22
+
pub use token::{FileTokenStore, MemoryTokenStore, TokenStore, TokenStoreError};
23
+
pub use xrpc_call::{CallOptions, XrpcCall, XrpcExt};
24
25
use jacquard_common::{
26
CowStr, IntoStatic,
···
29
xrpc::{XrpcMethod, XrpcRequest},
30
},
31
};
32
+
use url::Url;
33
34
/// Implement HttpClient for reqwest::Client
35
impl HttpClient for reqwest::Client {
···
67
}
68
}
69
70
+
/// HTTP client trait for sending raw HTTP requests.
71
pub trait HttpClient {
72
/// Error type returned by the HTTP client
73
type Error: std::error::Error + Display + Send + Sync + 'static;
···
77
request: Request<Vec<u8>>,
78
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
79
}
80
+
// Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs
81
82
pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession";
83
84
+
/// Authorization token types for XRPC requests.
85
+
#[derive(Debug, Clone)]
86
pub enum AuthorizationToken<'s> {
87
/// Bearer token (access JWT, refresh JWT to refresh the session)
88
Bearer(CowStr<'s>),
···
90
Dpop(CowStr<'s>),
91
}
92
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
+
}
105
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()
143
}
144
}
145
···
168
}
169
}
170
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);
182
183
if let XrpcMethod::Query = R::METHOD {
184
+
let qs = serde_html_form::to_string(&req)
185
+
.map_err(|e| error::TransportError::InvalidRequest(e.to_string()))?;
186
if !qs.is_empty() {
187
+
url.set_query(Some(&qs));
188
+
} else {
189
+
url.set_query(None);
190
}
191
}
192
193
let method = match R::METHOD {
194
XrpcMethod::Query => http::Method::GET,
195
XrpcMethod::Procedure(_) => http::Method::POST,
196
};
197
198
+
let mut builder = Request::builder().method(method).uri(url.as_str());
199
200
if let XrpcMethod::Procedure(encoding) = R::METHOD {
201
builder = builder.header(Header::ContentType, encoding);
202
}
203
+
builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING);
204
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| {
213
error::TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
214
})?;
215
+
builder = builder.header(Header::Authorization, hv);
216
}
217
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
+
}
230
}
231
+
for (name, value) in &opts.extra_headers {
232
+
builder = builder.header(name, value);
233
}
234
235
let body = if let XrpcMethod::Procedure(_) = R::METHOD {
236
+
req.encode_body()
237
+
.map_err(|e| error::TransportError::InvalidRequest(e.to_string()))?
238
} else {
239
vec![]
240
};
241
242
+
builder
243
+
.body(body)
244
+
.map_err(|e| error::TransportError::InvalidRequest(e.to_string()))
245
}
246
247
/// Session information from `com.atproto.server.createSession`
···
272
}
273
}
274
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 {
281
Self {
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(),
286
}
287
}
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
//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
2
//!
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`,
7
//! then Slingshot mini‑doc (partial) if configured.
8
//!
9
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10
//! and optionally validate the document `id` against the requested DID.
11
12
-
use crate::CowStr;
13
-
use crate::client::AuthenticatedClient;
14
use bon::Builder;
15
use bytes::Bytes;
16
use jacquard_common::IntoStatic;
···
183
/// Configurable resolver options.
184
///
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`).
188
/// - `handle_order`/`did_order`: ordered strategies for resolution.
189
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
190
/// returning `DocIdMismatch` with the fetched document on mismatch.
191
/// - `public_fallback_for_handle`: if true (default), attempt
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
194
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
195
#[derive(Debug, Clone, Builder)]
196
#[builder(start_fn = new)]
···
238
/// - HTTPS well-known for handles and `did:web`
239
/// - PLC directory or Slingshot for `did:plc`
240
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
241
-
/// - Auth-aware PDS fallbacks via helpers that accept an `XrpcClient`
242
#[async_trait::async_trait]
243
pub trait IdentityResolver {
244
/// Access options for validation decisions in default methods
···
284
}
285
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> {
313
http: reqwest::Client,
314
-
xrpc: C,
315
opts: ResolverOptions,
316
#[cfg(feature = "dns")]
317
dns: Option<TokioAsyncResolver>,
318
}
319
320
-
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
321
/// 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 {
323
Self {
324
http,
325
-
xrpc,
326
opts,
327
#[cfg(feature = "dns")]
328
dns: None,
···
439
}
440
}
441
442
-
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
443
-
/// Resolve handle to DID via a PDS XRPC client (auth-aware path)
444
pub async fn resolve_handle_via_pds(
445
&self,
446
handle: &Handle<'_>,
447
) -> Result<Did<'static>, IdentityError> {
448
let req = ResolveHandle::new().handle((*handle).clone()).build();
449
let resp = self
450
-
.xrpc
451
.send(req)
452
.await
453
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
···
464
&self,
465
did: &Did<'_>,
466
) -> Result<DidDocument<'static>, IdentityError> {
467
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
468
let resp = self
469
-
.xrpc
470
.send(req)
471
.await
472
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
···
510
}
511
512
#[async_trait::async_trait]
513
-
impl<C: crate::client::XrpcClient + Send + Sync> IdentityResolver for DefaultResolver<C> {
514
fn options(&self) -> &ResolverOptions {
515
&self.opts
516
}
···
541
}
542
}
543
HandleStep::PdsResolveHandle => {
544
-
// Prefer embedded XRPC client
545
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
546
return Ok(did);
547
}
···
630
}
631
}
632
DidStep::PdsResolveDid => {
633
-
// Try embedded XRPC client for full DID doc
634
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
635
let buf = serde_json::to_vec(&doc).unwrap_or_default();
636
return Ok(DidDocResponse {
···
667
},
668
}
669
670
-
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
671
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
672
/// This applies the default equality check on the document id (error with doc if mismatch).
673
pub async fn resolve_handle_and_doc(
···
772
773
#[test]
774
fn did_web_urls() {
775
-
let r = DefaultResolver::new(
776
-
reqwest::Client::new(),
777
-
TestXrpc::new(),
778
-
ResolverOptions::default(),
779
-
);
780
assert_eq!(
781
r.test_did_web_url_raw("did:web:example.com"),
782
"https://example.com/.well-known/did.json"
···
819
820
#[test]
821
fn slingshot_mini_doc_url_build() {
822
-
let r = DefaultResolver::new(
823
-
reqwest::Client::new(),
824
-
TestXrpc::new(),
825
-
ResolverOptions::default(),
826
-
);
827
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
828
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
829
assert_eq!(
···
873
other => panic!("unexpected: {:?}", other),
874
}
875
}
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
}
905
906
-
/// Resolver specialized for unauthenticated/public flows using reqwest + AuthenticatedClient
907
-
pub type PublicResolver = DefaultResolver<AuthenticatedClient<reqwest::Client>>;
908
909
impl Default for PublicResolver {
910
/// Build a resolver with:
911
/// - reqwest HTTP client
912
-
/// - XRPC base https://public.api.bsky.app (unauthenticated)
913
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
914
///
915
/// Example
···
919
/// ```
920
fn default() -> Self {
921
let http = reqwest::Client::new();
922
-
let xrpc =
923
-
AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
924
let opts = ResolverOptions::default();
925
-
let resolver = DefaultResolver::new(http, xrpc, opts);
926
#[cfg(feature = "dns")]
927
let resolver = resolver.with_system_dns();
928
resolver
···
933
/// mini-doc fallbacks, unauthenticated by default.
934
pub fn slingshot_resolver_default() -> PublicResolver {
935
let http = reqwest::Client::new();
936
-
let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
937
let mut opts = ResolverOptions::default();
938
opts.plc_source = PlcSource::slingshot_default();
939
-
let resolver = DefaultResolver::new(http, xrpc, opts);
940
#[cfg(feature = "dns")]
941
let resolver = resolver.with_system_dns();
942
resolver
···
1
//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
2
//!
3
//! Fallback order (default):
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
//! then Slingshot mini‑doc (partial) if configured.
8
//!
9
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10
//! and optionally validate the document `id` against the requested DID.
11
12
+
// use crate::CowStr; // not currently needed directly here
13
+
use crate::client::XrpcExt;
14
use bon::Builder;
15
use bytes::Bytes;
16
use jacquard_common::IntoStatic;
···
183
/// Configurable resolver options.
184
///
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 (stateless
187
+
/// XRPC over reqwest; authentication can be layered as needed).
188
/// - `handle_order`/`did_order`: ordered strategies for resolution.
189
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
190
/// returning `DocIdMismatch` with the fetched document on mismatch.
191
/// - `public_fallback_for_handle`: if true (default), attempt
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 PDS XRPC
194
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
195
#[derive(Debug, Clone, Builder)]
196
#[builder(start_fn = new)]
···
238
/// - HTTPS well-known for handles and `did:web`
239
/// - PLC directory or Slingshot for `did:plc`
240
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
241
+
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
242
#[async_trait::async_trait]
243
pub trait IdentityResolver {
244
/// Access options for validation decisions in default methods
···
284
}
285
286
/// Default resolver implementation with configurable fallback order.
287
+
pub struct DefaultResolver {
288
http: reqwest::Client,
289
opts: ResolverOptions,
290
#[cfg(feature = "dns")]
291
dns: Option<TokioAsyncResolver>,
292
}
293
294
+
impl DefaultResolver {
295
/// Create a new instance of the default resolver with all options (except DNS) up front
296
+
pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
297
Self {
298
http,
299
opts,
300
#[cfg(feature = "dns")]
301
dns: None,
···
412
}
413
}
414
415
+
impl DefaultResolver {
416
+
/// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
417
pub async fn resolve_handle_via_pds(
418
&self,
419
handle: &Handle<'_>,
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
+
};
425
let req = ResolveHandle::new().handle((*handle).clone()).build();
426
let resp = self
427
+
.http
428
+
.xrpc(pds)
429
.send(req)
430
.await
431
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
···
442
&self,
443
did: &Did<'_>,
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
+
};
449
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
450
let resp = self
451
+
.http
452
+
.xrpc(pds)
453
.send(req)
454
.await
455
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
···
493
}
494
495
#[async_trait::async_trait]
496
+
impl IdentityResolver for DefaultResolver {
497
fn options(&self) -> &ResolverOptions {
498
&self.opts
499
}
···
524
}
525
}
526
HandleStep::PdsResolveHandle => {
527
+
// Prefer PDS XRPC via stateless client
528
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
529
return Ok(did);
530
}
···
613
}
614
}
615
DidStep::PdsResolveDid => {
616
+
// Try PDS XRPC for full DID doc
617
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
618
let buf = serde_json::to_vec(&doc).unwrap_or_default();
619
return Ok(DidDocResponse {
···
650
},
651
}
652
653
+
impl DefaultResolver {
654
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
655
/// This applies the default equality check on the document id (error with doc if mismatch).
656
pub async fn resolve_handle_and_doc(
···
755
756
#[test]
757
fn did_web_urls() {
758
+
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
759
assert_eq!(
760
r.test_did_web_url_raw("did:web:example.com"),
761
"https://example.com/.well-known/did.json"
···
798
799
#[test]
800
fn slingshot_mini_doc_url_build() {
801
+
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
802
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
803
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
804
assert_eq!(
···
848
other => panic!("unexpected: {:?}", other),
849
}
850
}
851
}
852
853
+
/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
854
+
pub type PublicResolver = DefaultResolver;
855
856
impl Default for PublicResolver {
857
/// Build a resolver with:
858
/// - reqwest HTTP client
859
+
/// - Public fallbacks enabled for handle resolution
860
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
861
///
862
/// Example
···
866
/// ```
867
fn default() -> Self {
868
let http = reqwest::Client::new();
869
let opts = ResolverOptions::default();
870
+
let resolver = DefaultResolver::new(http, opts);
871
#[cfg(feature = "dns")]
872
let resolver = resolver.with_system_dns();
873
resolver
···
878
/// mini-doc fallbacks, unauthenticated by default.
879
pub fn slingshot_resolver_default() -> PublicResolver {
880
let http = reqwest::Client::new();
881
let mut opts = ResolverOptions::default();
882
opts.plc_source = PlcSource::slingshot_default();
883
+
let resolver = DefaultResolver::new(http, opts);
884
#[cfg(feature = "dns")]
885
let resolver = resolver.with_system_dns();
886
resolver
+82
-3
crates/jacquard/src/lib.rs
+82
-3
crates/jacquard/src/lib.rs
···
24
//! # use jacquard::CowStr;
25
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
26
//! use jacquard::api::com_atproto::server::create_session::CreateSession;
27
-
//! use jacquard::client::{AuthenticatedClient, Session, XrpcClient};
28
//! # use miette::IntoDiagnostic;
29
//!
30
//! # #[derive(Parser, Debug)]
···
48
//! let args = Args::parse();
49
//!
50
//! // Create HTTP client
51
-
//! let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds);
52
//!
53
//! // Create session
54
//! let session = Session::from(
···
64
//! );
65
//!
66
//! println!("logged in as {} ({})", session.handle, session.did);
67
-
//! client.set_session(session);
68
//!
69
//! // Fetch timeline
70
//! println!("\nfetching timeline...");
···
85
//! Ok(())
86
//! }
87
//! ```
88
//!
89
90
#![warn(missing_docs)]
···
24
//! # use jacquard::CowStr;
25
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
26
//! use jacquard::api::com_atproto::server::create_session::CreateSession;
27
+
//! use jacquard::client::{BasicClient, Session};
28
//! # use miette::IntoDiagnostic;
29
//!
30
//! # #[derive(Parser, Debug)]
···
48
//! let args = Args::parse();
49
//!
50
//! // Create HTTP client
51
+
//! let url = url::Url::parse(&args.pds).unwrap();
52
+
//! let client = BasicClient::new(url);
53
//!
54
//! // Create session
55
//! let session = Session::from(
···
65
//! );
66
//!
67
//! println!("logged in as {} ({})", session.handle, session.did);
68
+
//! client.set_session(session).await.unwrap();
69
//!
70
//! // Fetch timeline
71
//! println!("\nfetching timeline...");
···
86
//! Ok(())
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
+
//! ```
167
//!
168
169
#![warn(missing_docs)]
+9
-4
crates/jacquard/src/main.rs
+9
-4
crates/jacquard/src/main.rs
···
2
use jacquard::CowStr;
3
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
4
use jacquard::api::com_atproto::server::create_session::CreateSession;
5
-
use jacquard::client::{AuthenticatedClient, Session, XrpcClient};
6
use miette::IntoDiagnostic;
7
8
#[derive(Parser, Debug)]
···
24
async fn main() -> miette::Result<()> {
25
let args = Args::parse();
26
27
-
// Create HTTP client
28
-
let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds);
29
30
// Create session
31
let session = Session::from(
···
41
);
42
43
println!("logged in as {} ({})", session.handle, session.did);
44
-
client.set_session(session);
45
46
// Fetch timeline
47
println!("\nfetching timeline...");
···
2
use jacquard::CowStr;
3
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
4
use jacquard::api::com_atproto::server::create_session::CreateSession;
5
+
use jacquard::client::{BasicClient, Session};
6
+
use jacquard::identity::resolver::{slingshot_resolver_default, IdentityResolver};
7
+
use jacquard::types::string::Handle;
8
use miette::IntoDiagnostic;
9
10
#[derive(Parser, Debug)]
···
26
async fn main() -> miette::Result<()> {
27
let args = Args::parse();
28
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);
34
35
// Create session
36
let session = Session::from(
···
46
);
47
48
println!("logged in as {} ({})", session.handle, session.did);
49
+
client.set_session(session).await.into_diagnostic()?;
50
51
// Fetch timeline
52
println!("\nfetching timeline...");