A better Rust ATProto crate

reworked xrpc client traits and resolver

Orual 839ef8ba d5eb3508

Changed files
+769 -267
crates
jacquard
jacquard-api
jacquard-common
+1
.gitignore
··· 9 9 crates/jacquard-lexicon/tests/fixtures/lexicons/atproto 10 10 crates/jacquard-lexicon/target 11 11 codegen_plan.md 12 + /lex_js
+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
··· 11 11 exclude.workspace = true 12 12 license.workspace = true 13 13 14 + [package.metadata.docs.rs] 15 + features = [ "com_atproto", "app_bsky", "chat_bsky", "tools_ozone" ] 16 + 14 17 [features] 15 18 default = [ "com_atproto"] 16 19 app_bsky = []
+3
crates/jacquard-common/Cargo.toml
··· 60 60 optional = true 61 61 default-features = false 62 62 features = ["arithmetic"] 63 + 64 + [package.metadata.docs.rs] 65 + features = [ "crypto-k256", "crypto-k256", "crypto-p256"]
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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...");