A better Rust ATProto crate

big error type rework

Orual 3a87cc05 b790511c

Changed files
+2433 -1073
crates
jacquard
jacquard-api
src
app_bsky
com_atproto
garden_lexicon
ngerakines
semeion
jacquard-axum
jacquard-common
jacquard-identity
jacquard-oauth
+1 -3
.gitignore
··· 6 6 /.pre-commit-config.yaml 7 7 CLAUDE.md 8 8 AGENTS.md 9 - crates/jacquard-lexicon/tests/fixtures/lexicons/atproto 10 9 crates/jacquard-lexicon/target 11 - codegen_plan.md 12 - /lex_js 13 10 /plans 14 11 /docs 15 12 /binaries/releases/ 13 + rustdoc-host.nix
+1
Cargo.lock
··· 2353 2353 "futures", 2354 2354 "futures-lite", 2355 2355 "genawaiter", 2356 + "getrandom 0.2.16", 2356 2357 "getrandom 0.3.4", 2357 2358 "http", 2358 2359 "ipld-core",
+1 -3
crates/jacquard-api/src/app_bsky/video/upload_video.rs
··· 56 56 fn encode_body(&self) -> Result<Vec<u8>, jacquard_common::xrpc::EncodeError> { 57 57 Ok(self.body.to_vec()) 58 58 } 59 - fn decode_body<'de>( 60 - body: &'de [u8], 61 - ) -> Result<Box<Self>, jacquard_common::error::DecodeError> 59 + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult<Box<Self>> 62 60 where 63 61 Self: serde::Deserialize<'de>, 64 62 {
+1 -3
crates/jacquard-api/src/com_atproto/repo/import_repo.rs
··· 40 40 fn encode_body(&self) -> Result<Vec<u8>, jacquard_common::xrpc::EncodeError> { 41 41 Ok(self.body.to_vec()) 42 42 } 43 - fn decode_body<'de>( 44 - body: &'de [u8], 45 - ) -> Result<Box<Self>, jacquard_common::error::DecodeError> 43 + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult<Box<Self>> 46 44 where 47 45 Self: serde::Deserialize<'de>, 48 46 {
+1 -3
crates/jacquard-api/src/com_atproto/repo/upload_blob.rs
··· 56 56 fn encode_body(&self) -> Result<Vec<u8>, jacquard_common::xrpc::EncodeError> { 57 57 Ok(self.body.to_vec()) 58 58 } 59 - fn decode_body<'de>( 60 - body: &'de [u8], 61 - ) -> Result<Box<Self>, jacquard_common::error::DecodeError> 59 + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult<Box<Self>> 62 60 where 63 61 Self: serde::Deserialize<'de>, 64 62 {
+1 -3
crates/jacquard-api/src/garden_lexicon/ngerakines/semeion/sign.rs
··· 69 69 fn encode_body(&self) -> Result<Vec<u8>, jacquard_common::xrpc::EncodeError> { 70 70 Ok(self.body.to_vec()) 71 71 } 72 - fn decode_body<'de>( 73 - body: &'de [u8], 74 - ) -> Result<Box<Self>, jacquard_common::error::DecodeError> 72 + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult<Box<Self>> 75 73 where 76 74 Self: serde::Deserialize<'de>, 77 75 {
+1 -1
crates/jacquard-axum/tests/service_auth_tests.rs
··· 120 120 &self, 121 121 _handle: &jacquard_common::types::string::Handle<'_>, 122 122 ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send { 123 - async { Err(IdentityError::InvalidWellKnown) } 123 + async { Err(IdentityError::invalid_well_known()) } 124 124 } 125 125 126 126 fn resolve_did_doc(
+3
crates/jacquard-common/Cargo.toml
··· 64 64 [target.'cfg(target_family = "wasm")'.dependencies] 65 65 getrandom = { version = "0.3.4", features = ["wasm_js"] } 66 66 67 + [target.'cfg(target_arch = "wasm32")'.dependencies] 68 + getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] } 69 + 67 70 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 68 71 reqwest = { workspace = true, optional = true, features = [ "http2", "system-proxy", "rustls-tls"] } 69 72 tokio-util = { version = "0.7.16", features = ["io"] }
+326 -79
crates/jacquard-common/src/error.rs
··· 2 2 3 3 use crate::xrpc::EncodeError; 4 4 use bytes::Bytes; 5 + use smol_str::SmolStr; 6 + 7 + /// Boxed error type for wrapping arbitrary errors 8 + pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>; 5 9 6 - /// Client error type wrapping all possible error conditions 10 + /// Client error type for all XRPC client operations 7 11 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 8 - pub enum ClientError { 9 - /// HTTP transport error 10 - #[error("HTTP transport error: {0}")] 11 - Transport( 12 - #[from] 13 - #[diagnostic_source] 14 - TransportError, 15 - ), 12 + #[error("{kind}")] 13 + pub struct ClientError { 14 + #[diagnostic_source] 15 + kind: ClientErrorKind, 16 + #[source] 17 + source: Option<BoxError>, 18 + #[help] 19 + help: Option<SmolStr>, 20 + context: Option<SmolStr>, 21 + url: Option<SmolStr>, 22 + details: Option<SmolStr>, 23 + location: Option<SmolStr>, 24 + } 25 + 26 + /// Error categories for client operations 27 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 28 + pub enum ClientErrorKind { 29 + /// HTTP transport error (connection, timeout, etc.) 30 + #[error("transport error")] 31 + #[diagnostic(code(jacquard::client::transport))] 32 + Transport, 33 + 34 + /// Request validation/construction failed 35 + #[error("invalid request: {0}")] 36 + #[diagnostic( 37 + code(jacquard::client::invalid_request), 38 + help("check request parameters and format") 39 + )] 40 + InvalidRequest(SmolStr), 16 41 17 42 /// Request serialization failed 18 - #[error("{0}")] 19 - Encode( 20 - #[from] 21 - #[diagnostic_source] 22 - EncodeError, 23 - ), 43 + #[error("encode error: {0}")] 44 + #[diagnostic( 45 + code(jacquard::client::encode), 46 + help("check request body format and encoding") 47 + )] 48 + Encode(SmolStr), 24 49 25 50 /// Response deserialization failed 26 - #[error("{0}")] 27 - Decode( 28 - #[from] 29 - #[diagnostic_source] 30 - DecodeError, 31 - ), 51 + #[error("decode error: {0}")] 52 + #[diagnostic( 53 + code(jacquard::client::decode), 54 + help("check response format and encoding") 55 + )] 56 + Decode(SmolStr), 57 + 58 + /// HTTP error response (non-200 status) 59 + #[error("HTTP {status}")] 60 + #[diagnostic(code(jacquard::client::http))] 61 + Http { 62 + /// HTTP status code 63 + status: http::StatusCode, 64 + }, 65 + 66 + /// Authentication/authorization error 67 + #[error("auth error: {0}")] 68 + #[diagnostic(code(jacquard::client::auth))] 69 + Auth(AuthError), 70 + 71 + /// Identity resolution error (handle→DID, DID→Doc) 72 + #[error("identity resolution failed")] 73 + #[diagnostic( 74 + code(jacquard::client::identity_resolution), 75 + help("check handle/DID is valid and network is accessible") 76 + )] 77 + IdentityResolution, 78 + 79 + /// Storage/persistence error 80 + #[error("storage error")] 81 + #[diagnostic( 82 + code(jacquard::client::storage), 83 + help("check storage backend is accessible and has sufficient permissions") 84 + )] 85 + Storage, 86 + } 87 + 88 + impl ClientError { 89 + /// Create a new error with the given kind and optional source 90 + pub fn new(kind: ClientErrorKind, source: Option<BoxError>) -> Self { 91 + Self { 92 + kind, 93 + source, 94 + help: None, 95 + context: None, 96 + url: None, 97 + details: None, 98 + location: None, 99 + } 100 + } 101 + 102 + /// Get the error kind 103 + pub fn kind(&self) -> &ClientErrorKind { 104 + &self.kind 105 + } 106 + 107 + /// Get the source error if present 108 + pub fn source_err(&self) -> Option<&BoxError> { 109 + self.source.as_ref() 110 + } 111 + 112 + /// Get the context string if present 113 + pub fn context(&self) -> Option<&str> { 114 + self.context.as_ref().map(|s| s.as_str()) 115 + } 116 + 117 + /// Get the URL if present 118 + pub fn url(&self) -> Option<&str> { 119 + self.url.as_ref().map(|s| s.as_str()) 120 + } 121 + 122 + /// Get the details if present 123 + pub fn details(&self) -> Option<&str> { 124 + self.details.as_ref().map(|s| s.as_str()) 125 + } 126 + 127 + /// Get the location if present 128 + pub fn location(&self) -> Option<&str> { 129 + self.location.as_ref().map(|s| s.as_str()) 130 + } 131 + 132 + /// Add help text to this error 133 + pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 134 + self.help = Some(help.into()); 135 + self 136 + } 137 + 138 + /// Add context to this error 139 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 140 + self.context = Some(context.into()); 141 + self 142 + } 143 + 144 + /// Add URL to this error 145 + pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 146 + self.url = Some(url.into()); 147 + self 148 + } 149 + 150 + /// Add details to this error 151 + pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self { 152 + self.details = Some(details.into()); 153 + self 154 + } 155 + 156 + /// Add location to this error 157 + pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self { 158 + self.location = Some(location.into()); 159 + self 160 + } 161 + 162 + // Constructors for each kind 163 + 164 + /// Create a transport error 165 + pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 166 + Self::new(ClientErrorKind::Transport, Some(Box::new(source))) 167 + } 168 + 169 + /// Create an invalid request error 170 + pub fn invalid_request(msg: impl Into<SmolStr>) -> Self { 171 + Self::new(ClientErrorKind::InvalidRequest(msg.into()), None) 172 + } 173 + 174 + /// Create an encode error 175 + pub fn encode(msg: impl Into<SmolStr>) -> Self { 176 + Self::new(ClientErrorKind::Encode(msg.into()), None) 177 + } 178 + 179 + /// Create a decode error 180 + pub fn decode(msg: impl Into<SmolStr>) -> Self { 181 + Self::new(ClientErrorKind::Decode(msg.into()), None) 182 + } 183 + 184 + /// Create an HTTP error with status code and optional body 185 + pub fn http(status: http::StatusCode, body: Option<Bytes>) -> Self { 186 + let http_err = HttpError { status, body }; 187 + Self::new(ClientErrorKind::Http { status }, Some(Box::new(http_err))) 188 + } 189 + 190 + /// Create an authentication error 191 + pub fn auth(auth_error: AuthError) -> Self { 192 + Self::new(ClientErrorKind::Auth(auth_error), None) 193 + } 32 194 33 - /// HTTP error response 34 - #[error("HTTP {0}")] 35 - Http( 36 - #[from] 37 - #[diagnostic_source] 38 - HttpError, 39 - ), 195 + /// Create an identity resolution error 196 + pub fn identity_resolution(source: impl std::error::Error + Send + Sync + 'static) -> Self { 197 + Self::new(ClientErrorKind::IdentityResolution, Some(Box::new(source))) 198 + } 40 199 41 - /// Authentication error 42 - #[error("Authentication error: {0}")] 43 - Auth( 44 - #[from] 45 - #[diagnostic_source] 46 - AuthError, 47 - ), 200 + /// Create a storage error 201 + pub fn storage(source: impl std::error::Error + Send + Sync + 'static) -> Self { 202 + Self::new(ClientErrorKind::Storage, Some(Box::new(source))) 203 + } 48 204 } 49 205 206 + /// Result type for client operations 207 + pub type XrpcResult<T> = std::result::Result<T, ClientError>; 208 + 209 + // ============================================================================ 210 + // Old error types (deprecated) 211 + // ============================================================================ 212 + 50 213 /// Transport-level errors that occur during HTTP communication 51 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 52 - pub enum TransportError { 53 - /// Failed to establish connection to server 54 - #[error("Connection error: {0}")] 55 - Connect(String), 214 + // #[deprecated(since = "0.8.0", note = "Use ClientError::transport() instead")] 215 + // #[derive(Debug, thiserror::Error, miette::Diagnostic)] 216 + // pub enum TransportError { 217 + // /// Failed to establish connection to server 218 + // #[error("Connection error: {0}")] 219 + // Connect(String), 56 220 57 - /// Request timed out 58 - #[error("Request timeout")] 59 - Timeout, 221 + // /// Request timed out 222 + // #[error("Request timeout")] 223 + // Timeout, 60 224 61 - /// Request construction failed (malformed URI, headers, etc.) 62 - #[error("Invalid request: {0}")] 63 - InvalidRequest(String), 225 + // /// Request construction failed (malformed URI, headers, etc.) 226 + // #[error("Invalid request: {0}")] 227 + // InvalidRequest(String), 64 228 65 - /// Other transport error 66 - #[error("Transport error: {0}")] 67 - Other(Box<dyn std::error::Error + Send + Sync>), 68 - } 229 + // /// Other transport error 230 + // #[error("Transport error: {0}")] 231 + // Other(Box<dyn std::error::Error + Send + Sync>), 232 + // } 69 233 70 234 /// Response deserialization errors 235 + /// 236 + /// Preserves detailed error information from various deserialization backends. 237 + /// Can be converted to string for serialization while maintaining the full error context. 71 238 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 72 239 pub enum DecodeError { 73 240 /// JSON deserialization failed ··· 134 301 } 135 302 } 136 303 137 - /// Result type for client operations 138 - pub type XrpcResult<T> = std::result::Result<T, ClientError>; 139 - 140 - #[cfg(feature = "reqwest-client")] 141 - impl From<reqwest::Error> for TransportError { 142 - #[cfg(not(target_arch = "wasm32"))] 143 - fn from(e: reqwest::Error) -> Self { 144 - if e.is_timeout() { 145 - Self::Timeout 146 - } else if e.is_connect() { 147 - Self::Connect(e.to_string()) 148 - } else if e.is_builder() || e.is_request() { 149 - Self::InvalidRequest(e.to_string()) 150 - } else { 151 - Self::Other(Box::new(e)) 152 - } 153 - } 154 - #[cfg(target_arch = "wasm32")] 155 - fn from(e: reqwest::Error) -> Self { 156 - if e.is_timeout() { 157 - Self::Timeout 158 - } else if e.is_builder() || e.is_request() { 159 - Self::InvalidRequest(e.to_string()) 160 - } else { 161 - Self::Other(Box::new(e)) 162 - } 163 - } 164 - } 165 - 166 304 /// Authentication and authorization errors 167 305 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 168 306 pub enum AuthError { ··· 200 338 } 201 339 } 202 340 } 341 + 342 + // ============================================================================ 343 + // Conversions from old to new 344 + // ============================================================================ 345 + 346 + #[allow(deprecated)] 347 + // impl From<TransportError> for ClientError { 348 + // fn from(e: TransportError) -> Self { 349 + // Self::transport(e) 350 + // } 351 + // } 352 + 353 + impl From<DecodeError> for ClientError { 354 + fn from(e: DecodeError) -> Self { 355 + let msg = smol_str::format_smolstr!("{:?}", e); 356 + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) 357 + .with_context("response deserialization failed") 358 + } 359 + } 360 + 361 + impl From<HttpError> for ClientError { 362 + fn from(e: HttpError) -> Self { 363 + Self::http(e.status, e.body) 364 + } 365 + } 366 + 367 + impl From<AuthError> for ClientError { 368 + fn from(e: AuthError) -> Self { 369 + Self::auth(e) 370 + } 371 + } 372 + 373 + impl From<EncodeError> for ClientError { 374 + fn from(e: EncodeError) -> Self { 375 + let msg = smol_str::format_smolstr!("{:?}", e); 376 + Self::new(ClientErrorKind::Encode(msg), Some(Box::new(e))) 377 + .with_context("request encoding failed") 378 + } 379 + } 380 + 381 + // Platform-specific conversions 382 + #[cfg(feature = "reqwest-client")] 383 + impl From<reqwest::Error> for ClientError { 384 + #[cfg(not(target_arch = "wasm32"))] 385 + fn from(e: reqwest::Error) -> Self { 386 + Self::transport(e) 387 + } 388 + 389 + #[cfg(target_arch = "wasm32")] 390 + fn from(e: reqwest::Error) -> Self { 391 + Self::transport(e) 392 + } 393 + } 394 + 395 + // Serde error conversions 396 + impl From<serde_json::Error> for ClientError { 397 + fn from(e: serde_json::Error) -> Self { 398 + let msg = smol_str::format_smolstr!("{:?}", e); 399 + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) 400 + .with_context("JSON deserialization failed") 401 + } 402 + } 403 + 404 + impl From<serde_ipld_dagcbor::DecodeError<std::io::Error>> for ClientError { 405 + fn from(e: serde_ipld_dagcbor::DecodeError<std::io::Error>) -> Self { 406 + let msg = smol_str::format_smolstr!("{:?}", e); 407 + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) 408 + .with_context("DAG-CBOR deserialization failed (local I/O)") 409 + } 410 + } 411 + 412 + impl From<serde_ipld_dagcbor::DecodeError<HttpError>> for ClientError { 413 + fn from(e: serde_ipld_dagcbor::DecodeError<HttpError>) -> Self { 414 + let msg = smol_str::format_smolstr!("{:?}", e); 415 + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) 416 + .with_context("DAG-CBOR deserialization failed (remote)") 417 + } 418 + } 419 + 420 + impl From<serde_ipld_dagcbor::DecodeError<std::convert::Infallible>> for ClientError { 421 + fn from(e: serde_ipld_dagcbor::DecodeError<std::convert::Infallible>) -> Self { 422 + let msg = smol_str::format_smolstr!("{:?}", e); 423 + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) 424 + .with_context("DAG-CBOR deserialization failed (in-memory)") 425 + } 426 + } 427 + 428 + #[cfg(feature = "websocket")] 429 + impl From<ciborium::de::Error<std::io::Error>> for ClientError { 430 + fn from(e: ciborium::de::Error<std::io::Error>) -> Self { 431 + let msg = smol_str::format_smolstr!("{:?}", e); 432 + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) 433 + .with_context("CBOR header deserialization failed") 434 + } 435 + } 436 + 437 + // Session store errors 438 + impl From<crate::session::SessionStoreError> for ClientError { 439 + fn from(e: crate::session::SessionStoreError) -> Self { 440 + Self::storage(e) 441 + } 442 + } 443 + 444 + // URL parse errors 445 + impl From<url::ParseError> for ClientError { 446 + fn from(e: url::ParseError) -> Self { 447 + Self::invalid_request(e.to_string()) 448 + } 449 + }
+17 -30
crates/jacquard-common/src/http_client.rs
··· 31 31 ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>>; 32 32 33 33 /// Send HTTP request with streaming body and receive streaming response 34 + #[cfg(not(target_arch = "wasm32"))] 34 35 fn send_http_bidirectional<S>( 35 36 &self, 36 37 parts: http::request::Parts, ··· 38 39 ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> 39 40 where 40 41 S: n0_future::Stream<Item = Result<bytes::Bytes, StreamError>> + Send + 'static; 42 + 43 + /// Send HTTP request with streaming body and receive streaming response (WASM) 44 + #[cfg(target_arch = "wasm32")] 45 + fn send_http_bidirectional<S>( 46 + &self, 47 + parts: http::request::Parts, 48 + body: S, 49 + ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> 50 + where 51 + S: n0_future::Stream<Item = Result<bytes::Bytes, StreamError>> + 'static; 41 52 } 42 53 43 54 #[cfg(feature = "reqwest-client")] ··· 180 191 #[cfg(target_arch = "wasm32")] 181 192 async fn send_http_bidirectional<S>( 182 193 &self, 183 - parts: http::request::Parts, 184 - body: S, 194 + _parts: http::request::Parts, 195 + _body: S, 185 196 ) -> Result<http::Response<ByteStream>, Self::Error> 186 197 where 187 - S: n0_future::Stream<Item = bytes::Bytes> + Send + 'static, 198 + S: n0_future::Stream<Item = Result<bytes::Bytes, StreamError>> + 'static, 188 199 { 189 - // Convert stream to reqwest::Body 190 - use futures::StreamExt; 191 - 192 - let mut req = self 193 - .request(parts.method, parts.uri.to_string()) 194 - .body(reqwest_body); 195 - 196 - // Copy headers 197 - for (name, value) in parts.headers.iter() { 198 - req = req.header(name.as_str(), value.as_bytes()); 199 - } 200 - 201 - // Send and convert response 202 - let resp = req.send().await?; 203 - 204 - let mut builder = http::Response::builder().status(resp.status()); 205 - 206 - for (name, value) in resp.headers().iter() { 207 - builder = builder.header(name.as_str(), value.as_bytes()); 208 - } 209 - 210 - let stream = resp 211 - .bytes_stream() 212 - .map(|result| result.map_err(|e| StreamError::transport(e))); 213 - let byte_stream = ByteStream::new(stream); 214 - 215 - Ok(builder.body(byte_stream).expect("Failed to build response")) 200 + // WASM reqwest doesn't support streaming request bodies 201 + // This would require ReadableStream/WritableStream integration 202 + unimplemented!("Bidirectional streaming not yet supported on WASM") 216 203 } 217 204 }
+20 -1
crates/jacquard-common/src/stream.rs
··· 159 159 } 160 160 161 161 use bytes::Bytes; 162 - use n0_future::stream::Boxed; 162 + 163 + /// Boxed stream type with proper Send bounds for native, no Send for WASM 164 + #[cfg(not(target_arch = "wasm32"))] 165 + type Boxed<T> = Pin<Box<dyn n0_future::Stream<Item = T> + Send>>; 166 + 167 + /// Boxed stream type without Send bound for WASM 168 + #[cfg(target_arch = "wasm32")] 169 + type Boxed<T> = Pin<Box<dyn n0_future::Stream<Item = T>>>; 163 170 164 171 /// Platform-agnostic byte stream abstraction 165 172 pub struct ByteStream { ··· 168 175 169 176 impl ByteStream { 170 177 /// Create a new byte stream from any compatible stream 178 + #[cfg(not(target_arch = "wasm32"))] 171 179 pub fn new<S>(stream: S) -> Self 172 180 where 173 181 S: n0_future::Stream<Item = Result<Bytes, StreamError>> + Unpin + Send + 'static, 182 + { 183 + Self { 184 + inner: Box::pin(stream), 185 + } 186 + } 187 + 188 + /// Create a new byte stream from any compatible stream (WASM) 189 + #[cfg(target_arch = "wasm32")] 190 + pub fn new<S>(stream: S) -> Self 191 + where 192 + S: n0_future::Stream<Item = Result<Bytes, StreamError>> + Unpin + 'static, 174 193 { 175 194 Self { 176 195 inner: Box::pin(stream),
+77 -22
crates/jacquard-common/src/xrpc.rs
··· 24 24 25 25 #[cfg(feature = "streaming")] 26 26 use crate::StreamError; 27 + use crate::error::DecodeError; 27 28 use crate::http_client::HttpClient; 28 29 #[cfg(feature = "streaming")] 29 30 use crate::http_client::HttpClientExt; 30 31 use crate::types::value::Data; 31 32 use crate::{AuthorizationToken, error::AuthError}; 32 33 use crate::{CowStr, error::XrpcResult}; 33 - use crate::{IntoStatic, error::DecodeError}; 34 - use crate::{error::TransportError, types::value::RawData}; 34 + use crate::{IntoStatic, types::value::RawData}; 35 35 use bytes::Bytes; 36 36 use http::{ 37 37 HeaderName, HeaderValue, Request, StatusCode, ··· 124 124 /// Decode the request body for procedures. 125 125 /// 126 126 /// Default implementation deserializes from JSON. Override for non-JSON encodings. 127 - fn decode_body<'de>(body: &'de [u8]) -> Result<Box<Self>, DecodeError> 127 + fn decode_body<'de>(body: &'de [u8]) -> XrpcResult<Box<Self>> 128 128 where 129 129 Self: Deserialize<'de>, 130 130 { 131 - let body: Self = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?; 131 + let body: Self = serde_json::from_slice(body) 132 + .map_err(|e| crate::error::ClientError::decode(format!("{:?}", e)))?; 132 133 133 134 Ok(Box::new(body)) 134 135 } ··· 148 149 type Output<'de>: Serialize + Deserialize<'de> + IntoStatic; 149 150 150 151 /// Error type for this request 151 - type Err<'de>: Error + Deserialize<'de> + IntoStatic; 152 + type Err<'de>: Error + Deserialize<'de> + Serialize + IntoStatic; 152 153 153 154 /// Output body encoding function, similar to the request-side type 154 155 fn encode_output(output: &Self::Output<'_>) -> Result<Vec<u8>, EncodeError> { ··· 158 159 /// Decode the response output body. 159 160 /// 160 161 /// Default implementation deserializes from JSON. Override for non-JSON encodings. 161 - fn decode_output<'de>(body: &'de [u8]) -> Result<Self::Output<'de>, DecodeError> 162 + fn decode_output<'de>(body: &'de [u8]) -> core::result::Result<Self::Output<'de>, DecodeError> 162 163 where 163 164 Self::Output<'de>: Deserialize<'de>, 164 165 { 166 + #[allow(deprecated)] 165 167 let body = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?; 166 168 167 169 Ok(body) ··· 444 446 R: XrpcRequest, 445 447 <R as XrpcRequest>::Response: Send + Sync, 446 448 { 447 - let http_request = build_http_request(&self.base, request, &self.opts) 448 - .map_err(crate::error::TransportError::from)?; 449 + let http_request = build_http_request(&self.base, request, &self.opts)?; 449 450 450 451 let http_response = self 451 452 .client 452 453 .send_http(http_request) 453 454 .await 454 - .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; 455 + .map_err(|e| crate::error::ClientError::transport(e))?; 455 456 456 457 process_response(http_response) 457 458 } ··· 468 469 let status = http_response.status(); 469 470 // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers 470 471 // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. 472 + #[allow(deprecated)] 471 473 if status.as_u16() == 401 { 472 474 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 473 - return Err(crate::error::ClientError::Auth( 475 + return Err(crate::error::ClientError::auth( 474 476 crate::error::AuthError::Other(hv.clone()), 475 477 )); 476 478 } ··· 518 520 base: &Url, 519 521 req: &R, 520 522 opts: &CallOptions<'_>, 521 - ) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> 523 + ) -> XrpcResult<Request<Vec<u8>>> 522 524 where 523 525 R: XrpcRequest, 524 526 { 527 + use crate::error::ClientError; 528 + 525 529 let mut url = base.clone(); 526 530 let mut path = url.path().trim_end_matches('/').to_owned(); 527 531 path.push_str("/xrpc/"); ··· 529 533 url.set_path(&path); 530 534 531 535 if let XrpcMethod::Query = <R as XrpcRequest>::METHOD { 532 - let qs = serde_html_form::to_string(&req) 533 - .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?; 536 + let qs = serde_html_form::to_string(&req).map_err(|e| { 537 + ClientError::invalid_request(format!("Failed to serialize query: {}", e)) 538 + })?; 534 539 if !qs.is_empty() { 535 540 url.set_query(Some(&qs)); 536 541 } else { ··· 558 563 } 559 564 AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), 560 565 } 561 - .map_err(|e| { 562 - TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) 563 - })?; 566 + .map_err(|e| ClientError::invalid_request(format!("Invalid authorization token: {}", e)))?; 564 567 builder = builder.header(Header::Authorization, hv); 565 568 } 566 569 ··· 583 586 584 587 let body = if let XrpcMethod::Procedure(_) = R::METHOD { 585 588 req.encode_body() 586 - .map_err(|e| TransportError::InvalidRequest(e.to_string()))? 589 + .map_err(|e| ClientError::invalid_request(format!("Failed to encode body: {}", e)))? 587 590 } else { 588 591 vec![] 589 592 }; 590 593 591 594 builder 592 595 .body(body) 593 - .map_err(|e| TransportError::InvalidRequest(e.to_string())) 596 + .map_err(|e| ClientError::invalid_request(format!("Failed to build request: {}", e))) 594 597 } 595 598 596 599 /// XRPC response wrapper that owns the response buffer ··· 980 983 } 981 984 } 982 985 986 + impl<E> Serialize for XrpcError<E> 987 + where 988 + E: std::error::Error + IntoStatic + Serialize, 989 + { 990 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 991 + where 992 + S: serde::Serializer, 993 + { 994 + use serde::ser::SerializeStruct; 995 + 996 + match self { 997 + // Typed errors already serialize to correct atproto format 998 + XrpcError::Xrpc(e) => e.serialize(serializer), 999 + // Generic errors already have correct format 1000 + XrpcError::Generic(g) => g.serialize(serializer), 1001 + // Auth and Decode need manual mapping to {"error": "...", "message": ...} 1002 + XrpcError::Auth(auth) => { 1003 + let mut state = serializer.serialize_struct("XrpcError", 2)?; 1004 + let (error, message) = match auth { 1005 + AuthError::TokenExpired => ("ExpiredToken", Some("Access token has expired")), 1006 + AuthError::InvalidToken => { 1007 + ("InvalidToken", Some("Access token is invalid or malformed")) 1008 + } 1009 + AuthError::RefreshFailed => { 1010 + ("RefreshFailed", Some("Token refresh request failed")) 1011 + } 1012 + AuthError::NotAuthenticated => ( 1013 + "AuthenticationRequired", 1014 + Some("Request requires authentication but none was provided"), 1015 + ), 1016 + AuthError::Other(hv) => { 1017 + let msg = hv.to_str().unwrap_or("[non-utf8 header]"); 1018 + ("AuthenticationError", Some(msg)) 1019 + } 1020 + }; 1021 + state.serialize_field("error", error)?; 1022 + if let Some(msg) = message { 1023 + state.serialize_field("message", msg)?; 1024 + } 1025 + state.end() 1026 + } 1027 + XrpcError::Decode(decode_err) => { 1028 + let mut state = serializer.serialize_struct("XrpcError", 2)?; 1029 + state.serialize_field("error", "ResponseDecodeError")?; 1030 + // Convert DecodeError to string for message field 1031 + let msg = format!("{:?}", decode_err); 1032 + state.serialize_field("message", &msg)?; 1033 + state.end() 1034 + } 1035 + } 1036 + } 1037 + } 1038 + 983 1039 #[cfg(feature = "streaming")] 984 1040 impl<'a, C: HttpClient + HttpClientExt> XrpcCall<'a, C> { 985 1041 /// Send an XRPC call and stream the binary response. ··· 1016 1072 <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>: XrpcStreamResp, 1017 1073 { 1018 1074 use futures::TryStreamExt; 1019 - use n0_future::StreamExt; 1020 1075 1021 1076 let mut url = self.base; 1022 1077 let mut path = url.path().trim_end_matches('/').to_owned(); ··· 1061 1116 .map_err(|e| StreamError::protocol(e.to_string()))? 1062 1117 .into_parts(); 1063 1118 1064 - let body_stream = stream.0.map_ok(|f| f.buffer).boxed(); 1119 + let body_stream = Box::pin(stream.0.map_ok(|f| f.buffer)); 1065 1120 1066 1121 let resp = self 1067 1122 .client ··· 1086 1141 #[allow(dead_code)] 1087 1142 struct DummyReq; 1088 1143 1089 - #[derive(Deserialize, Debug, thiserror::Error)] 1144 + #[derive(Deserialize, Serialize, Debug, thiserror::Error)] 1090 1145 #[error("{0}")] 1091 1146 struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>); 1092 1147 ··· 1153 1208 fn no_double_slash_in_path() { 1154 1209 #[derive(Serialize, Deserialize)] 1155 1210 struct Req; 1156 - #[derive(Deserialize, Debug, thiserror::Error)] 1211 + #[derive(Deserialize, Serialize, Debug, thiserror::Error)] 1157 1212 #[error("{0}")] 1158 1213 struct Err<'a>(#[serde(borrow)] CowStr<'a>); 1159 1214 impl IntoStatic for Err<'_> {
+22 -19
crates/jacquard-common/src/xrpc/streaming.rs
··· 3 3 use crate::{IntoStatic, StreamError, stream::ByteStream, xrpc::XrpcRequest}; 4 4 use bytes::Bytes; 5 5 use http::StatusCode; 6 - use n0_future::{StreamExt, TryStreamExt, stream::Boxed}; 6 + use n0_future::{StreamExt, TryStreamExt}; 7 7 use serde::{Deserialize, Serialize}; 8 8 #[cfg(not(target_arch = "wasm32"))] 9 9 use std::path::Path; 10 10 use std::{marker::PhantomData, pin::Pin}; 11 + 12 + /// Boxed stream type with proper Send bounds for native, no Send for WASM 13 + #[cfg(not(target_arch = "wasm32"))] 14 + type Boxed<T> = Pin<Box<dyn n0_future::Stream<Item = T> + Send>>; 15 + 16 + /// Boxed stream type without Send bound for WASM 17 + #[cfg(target_arch = "wasm32")] 18 + type Boxed<T> = Pin<Box<dyn n0_future::Stream<Item = T>>>; 11 19 12 20 /// Trait for streaming XRPC procedures (bidirectional streaming). 13 21 /// ··· 145 153 <P as XrpcProcedureStream>::Frame<'static>: Serialize, 146 154 { 147 155 let stream = s 148 - .map(|f| P::encode_frame(f).map(|b| XrpcStreamFrame::new_typed::<P::Frame<'_>>(b))) 149 - .boxed(); 156 + .map(|f| P::encode_frame(f).map(|b| XrpcStreamFrame::new_typed::<P::Frame<'_>>(b))); 150 157 151 - XrpcProcedureSend(stream) 158 + XrpcProcedureSend(Box::pin(stream)) 152 159 } 153 160 154 161 /// Sending stream for streaming XRPC procedure uplink. ··· 172 179 pub fn from_bytestream(StreamingResponse { parts, body }: StreamingResponse) -> Self { 173 180 Self { 174 181 parts, 175 - body: body 182 + body: Box::pin(body 176 183 .into_inner() 177 - .map_ok(|b| XrpcStreamFrame::new(b)) 178 - .boxed(), 184 + .map_ok(|b| XrpcStreamFrame::new(b))), 179 185 } 180 186 } 181 187 ··· 183 189 pub fn from_parts(parts: http::response::Parts, body: ByteStream) -> Self { 184 190 Self { 185 191 parts, 186 - body: body 192 + body: Box::pin(body 187 193 .into_inner() 188 - .map_ok(|b| XrpcStreamFrame::new(b)) 189 - .boxed(), 194 + .map_ok(|b| XrpcStreamFrame::new(b))), 190 195 } 191 196 } 192 197 ··· 194 199 pub fn into_parts(self) -> (http::response::Parts, ByteStream) { 195 200 ( 196 201 self.parts, 197 - ByteStream::new(self.body.map_ok(|f| f.buffer).boxed()), 202 + ByteStream::new(Box::pin(self.body.map_ok(|f| f.buffer))), 198 203 ) 199 204 } 200 205 201 206 /// Consume and return just the body stream 202 207 pub fn into_bytestream(self) -> ByteStream { 203 - ByteStream::new(self.body.map_ok(|f| f.buffer).boxed()) 208 + ByteStream::new(Box::pin(self.body.map_ok(|f| f.buffer))) 204 209 } 205 210 } 206 211 ··· 209 214 pub fn from_stream(StreamingResponse { parts, body }: StreamingResponse) -> Self { 210 215 Self { 211 216 parts, 212 - body: body 217 + body: Box::pin(body 213 218 .into_inner() 214 - .map_ok(|b| XrpcStreamFrame::new_typed::<F::Frame<'_>>(b)) 215 - .boxed(), 219 + .map_ok(|b| XrpcStreamFrame::new_typed::<F::Frame<'_>>(b))), 216 220 } 217 221 } 218 222 ··· 220 224 pub fn from_typed_parts(parts: http::response::Parts, body: ByteStream) -> Self { 221 225 Self { 222 226 parts, 223 - body: body 227 + body: Box::pin(body 224 228 .into_inner() 225 - .map_ok(|b| XrpcStreamFrame::new_typed::<F::Frame<'_>>(b)) 226 - .boxed(), 229 + .map_ok(|b| XrpcStreamFrame::new_typed::<F::Frame<'_>>(b))), 227 230 } 228 231 } 229 232 } ··· 231 234 impl<F: XrpcStreamResp + 'static> XrpcResponseStream<F> { 232 235 /// Consume the typed stream and return just the raw byte stream 233 236 pub fn into_bytestream(self) -> ByteStream { 234 - ByteStream::new(self.body.map_ok(|f| f.buffer).boxed()) 237 + ByteStream::new(Box::pin(self.body.map_ok(|f| f.buffer))) 235 238 } 236 239 } 237 240
+64 -56
crates/jacquard-identity/src/lib.rs
··· 79 79 use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle; 80 80 #[cfg(feature = "streaming")] 81 81 use jacquard_common::ByteStream; 82 - use jacquard_common::error::TransportError; 83 82 use jacquard_common::http_client::HttpClient; 84 83 use jacquard_common::types::did::Did; 85 84 use jacquard_common::types::did_doc::DidDocument; ··· 169 168 /// 170 169 /// - `did:web:example.com` → `https://example.com/.well-known/did.json` 171 170 /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json` 172 - fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 171 + fn did_web_url(&self, did: &Did<'_>) -> resolver::Result<Url> { 173 172 // did:web:example.com[:path:segments] 174 173 let s = did.as_str(); 175 174 let rest = s 176 175 .strip_prefix("did:web:") 177 - .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 176 + .ok_or_else(|| IdentityError::unsupported_did_method(s))?; 178 177 let mut parts = rest.split(':'); 179 178 let host = parts 180 179 .next() 181 - .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 182 - let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?; 180 + .ok_or_else(|| IdentityError::unsupported_did_method(s))?; 181 + let mut url = Url::parse(&format!("https://{host}/"))?; 183 182 let path: Vec<&str> = parts.collect(); 184 183 if path.is_empty() { 185 184 url.set_path(".well-known/did.json"); ··· 187 186 // Append path segments and did.json 188 187 let mut segments = url 189 188 .path_segments_mut() 190 - .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?; 189 + .map_err(|_| IdentityError::url(ParseError::SetHostOnCannotBeABaseUrl))?; 191 190 for seg in path { 192 191 // Minimally percent-decode each segment per spec guidance 193 192 let decoded = percent_decode_str(seg).decode_utf8_lossy(); ··· 205 204 self.did_web_url(&did).unwrap().to_string() 206 205 } 207 206 208 - async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { 209 - let resp = self 210 - .http 211 - .get(url) 212 - .send() 213 - .await 214 - .map_err(TransportError::from)?; 207 + async fn get_json_bytes(&self, url: Url) -> resolver::Result<(Bytes, StatusCode)> { 208 + let resp = self.http.get(url).send().await?; 215 209 let status = resp.status(); 216 - let buf = resp.bytes().await.map_err(TransportError::from)?; 210 + let buf = resp.bytes().await?; 217 211 Ok((buf, status)) 218 212 } 219 213 220 - async fn get_text(&self, url: Url) -> Result<String, IdentityError> { 221 - let resp = self 222 - .http 223 - .get(url) 224 - .send() 225 - .await 226 - .map_err(TransportError::from)?; 214 + async fn get_text(&self, url: Url) -> resolver::Result<String> { 215 + let resp = self.http.get(url).send().await?; 227 216 if resp.status() == StatusCode::OK { 228 - Ok(resp.text().await.map_err(TransportError::from)?) 217 + Ok(resp.text().await?) 229 218 } else { 230 - Err(IdentityError::Http( 231 - resp.error_for_status().unwrap_err().into(), 219 + Err(IdentityError::transport( 220 + resp.error_for_status().unwrap_err(), 232 221 )) 233 222 } 234 223 } 235 224 236 225 #[cfg(feature = "dns")] 237 - async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> { 226 + async fn dns_txt(&self, name: &str) -> resolver::Result<Vec<String>> { 238 227 let Some(dns) = &self.dns else { 239 228 return Ok(vec![]); 240 229 }; ··· 249 238 Ok(out) 250 239 } 251 240 252 - fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> { 241 + fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> { 253 242 let line = body 254 243 .lines() 255 244 .find(|l| !l.trim().is_empty()) 256 - .ok_or(IdentityError::InvalidWellKnown)?; 257 - let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?; 245 + .ok_or_else(|| IdentityError::invalid_well_known())?; 246 + let did = Did::new(line.trim()).map_err(|_| IdentityError::invalid_well_known())?; 258 247 Ok(did.into_static()) 259 248 } 260 249 } ··· 264 253 pub async fn resolve_handle_via_pds( 265 254 &self, 266 255 handle: &Handle<'_>, 267 - ) -> Result<Did<'static>, IdentityError> { 256 + ) -> resolver::Result<Did<'static>> { 268 257 let pds = match &self.opts.pds_fallback { 269 258 Some(u) => u.clone(), 270 - None => return Err(IdentityError::InvalidWellKnown), 259 + None => return Err(IdentityError::invalid_well_known()), 271 260 }; 272 261 let req = ResolveHandle::new() 273 262 .handle(handle.clone().into_static()) ··· 277 266 .xrpc(pds) 278 267 .send(&req) 279 268 .await 280 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 269 + .map_err(|e| IdentityError::xrpc(e.to_string()))?; 281 270 let out = resp 282 271 .parse() 283 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 272 + .map_err(|e| IdentityError::xrpc(e.to_string()))?; 284 273 Did::new_owned(out.did.as_str()) 285 274 .map(|d| d.into_static()) 286 - .map_err(|_| IdentityError::InvalidWellKnown) 275 + .map_err(|_| IdentityError::invalid_well_known()) 287 276 } 288 277 289 278 /// Fetch DID document via PDS resolveDid (returns owned DidDocument) 290 279 pub async fn fetch_did_doc_via_pds_owned( 291 280 &self, 292 281 did: &Did<'_>, 293 - ) -> Result<DidDocument<'static>, IdentityError> { 282 + ) -> resolver::Result<DidDocument<'static>> { 294 283 let pds = match &self.opts.pds_fallback { 295 284 Some(u) => u.clone(), 296 - None => return Err(IdentityError::InvalidWellKnown), 285 + None => return Err(IdentityError::invalid_well_known()), 297 286 }; 298 287 let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 299 288 let resp = self ··· 301 290 .xrpc(pds) 302 291 .send(&req) 303 292 .await 304 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 293 + .map_err(|e| IdentityError::xrpc(e.to_string()))?; 305 294 let out = resp 306 295 .parse() 307 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 296 + .map_err(|e| IdentityError::xrpc(e.to_string()))?; 308 297 let doc_json = serde_json::to_value(&out.did_doc)?; 309 298 let s = serde_json::to_string(&doc_json)?; 310 299 let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; ··· 316 305 pub async fn fetch_mini_doc_via_slingshot( 317 306 &self, 318 307 did: &Did<'_>, 319 - ) -> Result<DidDocResponse, IdentityError> { 308 + ) -> resolver::Result<DidDocResponse> { 320 309 let base = match &self.opts.plc_source { 321 310 PlcSource::Slingshot { base } => base.clone(), 322 311 _ => { 323 - return Err(IdentityError::UnsupportedDidMethod( 324 - "mini-doc requires Slingshot source".into(), 312 + return Err(IdentityError::unsupported_did_method( 313 + "mini-doc requires Slingshot source", 325 314 )); 326 315 } 327 316 }; ··· 348 337 &self.opts 349 338 } 350 339 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(handle = %handle)))] 351 - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 340 + async fn resolve_handle(&self, handle: &Handle<'_>) -> resolver::Result<Did<'static>> { 352 341 let host = handle.as_str(); 353 342 for step in &self.opts.handle_order { 354 343 match step { ··· 433 422 } 434 423 } 435 424 } 436 - Err(IdentityError::InvalidWellKnown) 425 + Err(IdentityError::invalid_well_known()) 437 426 } 438 427 439 428 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(did = %did)))] 440 - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 429 + async fn resolve_did_doc(&self, did: &Did<'_>) -> resolver::Result<DidDocResponse> { 441 430 let s = did.as_str(); 442 431 for step in &self.opts.did_order { 443 432 match step { ··· 491 480 _ => {} 492 481 } 493 482 } 494 - Err(IdentityError::UnsupportedDidMethod(s.to_string())) 483 + Err(IdentityError::unsupported_did_method(s)) 495 484 } 496 485 } 497 486 ··· 517 506 } 518 507 519 508 /// Send HTTP request with streaming body and receive streaming response 509 + #[cfg(not(target_arch = "wasm32"))] 520 510 fn send_http_bidirectional<S>( 521 511 &self, 522 512 parts: http::request::Parts, ··· 529 519 { 530 520 self.http.send_http_bidirectional(parts, body) 531 521 } 522 + 523 + /// Send HTTP request with streaming body and receive streaming response (WASM) 524 + #[cfg(target_arch = "wasm32")] 525 + fn send_http_bidirectional<S>( 526 + &self, 527 + parts: http::request::Parts, 528 + body: S, 529 + ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> 530 + where 531 + S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> + 'static, 532 + { 533 + self.http.send_http_bidirectional(parts, body) 534 + } 532 535 } 533 536 534 537 /// Warnings produced during identity checks that are not fatal ··· 547 550 pub async fn resolve_handle_and_doc( 548 551 &self, 549 552 handle: &Handle<'_>, 550 - ) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> { 553 + ) -> resolver::Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>)> { 551 554 let did = self.resolve_handle(handle).await?; 552 555 let resp = self.resolve_did_doc(&did).await?; 553 556 let resp_for_parse = resp.clone(); 554 557 let doc_borrowed = resp_for_parse.parse()?; 555 558 if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() { 556 - return Err(IdentityError::DocIdMismatch { 557 - expected: did.clone().into_static(), 558 - doc: doc_borrowed.clone().into_static(), 559 - }); 559 + return Err(IdentityError::doc_id_mismatch( 560 + did.clone().into_static(), 561 + doc_borrowed.clone().into_static(), 562 + )); 560 563 } 561 564 let mut warnings = Vec::new(); 562 565 // Check handle alias presence (soft warning) ··· 575 578 } 576 579 577 580 /// Build Slingshot mini-doc URL for an identifier (handle or DID) 578 - fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> { 581 + fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> resolver::Result<Url> { 579 582 let mut url = base.clone(); 580 583 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 581 584 url.set_query(Some(&format!( ··· 589 592 pub async fn fetch_mini_doc_via_slingshot_identifier( 590 593 &self, 591 594 identifier: &AtIdentifier<'_>, 592 - ) -> Result<MiniDocResponse, IdentityError> { 595 + ) -> resolver::Result<MiniDocResponse> { 593 596 let base = match &self.opts.plc_source { 594 597 PlcSource::Slingshot { base } => base.clone(), 595 598 _ => { 596 - return Err(IdentityError::UnsupportedDidMethod( 597 - "mini-doc requires Slingshot source".into(), 599 + return Err(IdentityError::unsupported_did_method( 600 + "mini-doc requires Slingshot source", 598 601 )); 599 602 } 600 603 }; ··· 616 619 617 620 impl MiniDocResponse { 618 621 /// Parse borrowed MiniDoc 619 - pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> { 622 + pub fn parse<'b>(&'b self) -> resolver::Result<MiniDoc<'b>> { 620 623 if self.status.is_success() { 621 624 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 622 625 } else { 623 - Err(IdentityError::HttpStatus(self.status)) 626 + Err(IdentityError::http_status(self.status)) 624 627 } 625 628 } 626 629 } ··· 726 729 status: StatusCode::BAD_REQUEST, 727 730 }; 728 731 match resp.parse() { 729 - Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST), 732 + Err(e) => match e.kind() { 733 + resolver::IdentityErrorKind::HttpStatus(s) => { 734 + assert_eq!(*s, StatusCode::BAD_REQUEST) 735 + } 736 + _ => panic!("unexpected error kind: {:?}", e), 737 + }, 730 738 other => panic!("unexpected: {:?}", other), 731 739 } 732 740 }
+313 -131
crates/jacquard-identity/src/resolver.rs
··· 12 12 use bon::Builder; 13 13 use bytes::Bytes; 14 14 use http::StatusCode; 15 - use jacquard_common::error::TransportError; 15 + use jacquard_common::error::BoxError; 16 16 use jacquard_common::types::did::Did; 17 17 use jacquard_common::types::did_doc::{DidDocument, Service}; 18 18 use jacquard_common::types::ident::AtIdentifier; 19 19 use jacquard_common::types::string::{AtprotoStr, Handle}; 20 20 use jacquard_common::types::uri::Uri; 21 21 use jacquard_common::types::value::{AtDataError, Data}; 22 - use jacquard_common::{CowStr, IntoStatic}; 23 - use miette::Diagnostic; 22 + use jacquard_common::{CowStr, IntoStatic, smol_str}; 23 + use smol_str::SmolStr; 24 24 use std::collections::BTreeMap; 25 25 use std::marker::Sync; 26 26 use std::str::FromStr; 27 - use thiserror::Error; 28 27 use url::Url; 29 28 30 - /// Errors that can occur during identity resolution. 31 - /// 32 - /// Note: when validating a fetched DID document against a requested DID, a 33 - /// `DocIdMismatch` error is returned that includes the owned document so callers 34 - /// can inspect it and decide how to proceed. 35 - #[derive(Debug, Error, Diagnostic)] 36 - #[allow(missing_docs)] 37 - pub enum IdentityError { 38 - #[error("unsupported DID method: {0}")] 39 - #[diagnostic( 40 - code(jacquard_identity::unsupported_did_method), 41 - help("supported DID methods: did:web, did:plc") 42 - )] 43 - UnsupportedDidMethod(String), 44 - #[error("invalid well-known atproto-did content")] 45 - #[diagnostic( 46 - code(jacquard_identity::invalid_well_known), 47 - help("expected first non-empty line to be a DID") 48 - )] 49 - InvalidWellKnown, 50 - #[error("missing PDS endpoint in DID document")] 51 - #[diagnostic(code(jacquard_identity::missing_pds_endpoint))] 52 - MissingPdsEndpoint, 53 - #[error("HTTP error: {0}")] 54 - #[diagnostic( 55 - code(jacquard_identity::http), 56 - help("check network connectivity and TLS configuration") 57 - )] 58 - Http(#[from] TransportError), 59 - #[error("HTTP status {0}")] 60 - #[diagnostic( 61 - code(jacquard_identity::http_status), 62 - help("verify well-known paths or PDS XRPC endpoints") 63 - )] 64 - HttpStatus(StatusCode), 65 - #[error("XRPC error: {0}")] 66 - #[diagnostic( 67 - code(jacquard_identity::xrpc), 68 - help("enable PDS fallback or public resolver if needed") 69 - )] 70 - Xrpc(String), 71 - #[error("URL parse error: {0}")] 72 - #[diagnostic(code(jacquard_identity::url))] 73 - Url(#[from] url::ParseError), 74 - #[error("DNS error: {0}")] 75 - #[cfg(all(feature = "dns", not(target_family = "wasm")))] 76 - #[diagnostic(code(jacquard_identity::dns))] 77 - Dns(#[from] hickory_resolver::error::ResolveError), 78 - #[error("serialize/deserialize error: {0}")] 79 - #[diagnostic(code(jacquard_identity::serde))] 80 - Serde(#[from] serde_json::Error), 81 - #[error("invalid DID document: {0}")] 82 - #[diagnostic( 83 - code(jacquard_identity::invalid_doc), 84 - help("validate keys and services; ensure AtprotoPersonalDataServer service exists") 85 - )] 86 - InvalidDoc(String), 87 - #[error(transparent)] 88 - #[diagnostic(code(jacquard_identity::data))] 89 - Data(#[from] AtDataError), 90 - /// DID document id did not match requested DID; includes the fetched document 91 - #[error("DID doc id mismatch")] 92 - #[diagnostic( 93 - code(jacquard_identity::doc_id_mismatch), 94 - help("document id differs from requested DID; do not trust this document") 95 - )] 96 - DocIdMismatch { 97 - expected: Did<'static>, 98 - doc: DidDocument<'static>, 99 - }, 100 - } 101 - 102 29 /// Source to fetch PLC (did:plc) documents from. 103 30 /// 104 31 /// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). ··· 155 82 156 83 impl DidDocResponse { 157 84 /// Parse as borrowed DidDocument<'_> 158 - pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 85 + pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>> { 159 86 if self.status.is_success() { 160 87 if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) { 161 88 Ok(doc) ··· 175 102 extra_data: BTreeMap::new(), 176 103 }) 177 104 } else { 178 - Err(IdentityError::MissingPdsEndpoint) 105 + Err(IdentityError::missing_pds_endpoint()) 179 106 } 180 107 } else { 181 - Err(IdentityError::HttpStatus(self.status)) 108 + Err(IdentityError::http_status(self.status)) 182 109 } 183 110 } 184 111 185 112 /// Parse and validate that the DID in the document matches the requested DID if present. 186 113 /// 187 114 /// On mismatch, returns an error that contains the owned document for inspection. 188 - pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 115 + pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>> { 189 116 let doc = self.parse()?; 190 117 if let Some(expected) = &self.requested { 191 118 if doc.id.as_str() != expected.as_str() { 192 - return Err(IdentityError::DocIdMismatch { 193 - expected: expected.clone(), 194 - doc: doc.clone().into_static(), 195 - }); 119 + return Err(IdentityError::doc_id_mismatch( 120 + expected.clone(), 121 + doc.clone().into_static(), 122 + )); 196 123 } 197 124 } 198 125 Ok(doc) 199 126 } 200 127 201 128 /// Parse as owned DidDocument<'static> 202 - pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> { 129 + pub fn into_owned(self) -> Result<DidDocument<'static>> { 203 130 if self.status.is_success() { 204 131 if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 205 132 Ok(doc.into_static()) ··· 220 147 } 221 148 .into_static()) 222 149 } else { 223 - Err(IdentityError::MissingPdsEndpoint) 150 + Err(IdentityError::missing_pds_endpoint()) 224 151 } 225 152 } else { 226 - Err(IdentityError::HttpStatus(self.status)) 153 + Err(IdentityError::http_status(self.status)) 227 154 } 228 155 } 229 156 } ··· 334 261 335 262 /// Resolve handle 336 263 #[cfg(not(target_arch = "wasm32"))] 337 - fn resolve_handle( 338 - &self, 339 - handle: &Handle<'_>, 340 - ) -> impl Future<Output = Result<Did<'static>, IdentityError>> 264 + fn resolve_handle(&self, handle: &Handle<'_>) -> impl Future<Output = Result<Did<'static>>> 341 265 where 342 266 Self: Sync; 343 267 344 268 /// Resolve handle 345 269 #[cfg(target_arch = "wasm32")] 346 - fn resolve_handle( 347 - &self, 348 - handle: &Handle<'_>, 349 - ) -> impl Future<Output = Result<Did<'static>, IdentityError>>; 270 + fn resolve_handle(&self, handle: &Handle<'_>) -> impl Future<Output = Result<Did<'static>>>; 350 271 351 272 /// Resolve DID document 352 273 #[cfg(not(target_arch = "wasm32"))] 353 - fn resolve_did_doc( 354 - &self, 355 - did: &Did<'_>, 356 - ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> 274 + fn resolve_did_doc(&self, did: &Did<'_>) -> impl Future<Output = Result<DidDocResponse>> 357 275 where 358 276 Self: Sync; 359 277 360 278 /// Resolve DID document 361 279 #[cfg(target_arch = "wasm32")] 362 - fn resolve_did_doc( 363 - &self, 364 - did: &Did<'_>, 365 - ) -> impl Future<Output = Result<DidDocResponse, IdentityError>>; 280 + fn resolve_did_doc(&self, did: &Did<'_>) -> impl Future<Output = Result<DidDocResponse>>; 366 281 367 282 /// Resolve DID doc from an identifier 368 283 #[cfg(not(target_arch = "wasm32"))] 369 284 fn resolve_ident( 370 285 &self, 371 286 actor: &AtIdentifier<'_>, 372 - ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> 287 + ) -> impl Future<Output = Result<DidDocResponse>> 373 288 where 374 289 Self: Sync, 375 290 { ··· 389 304 fn resolve_ident( 390 305 &self, 391 306 actor: &AtIdentifier<'_>, 392 - ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> { 307 + ) -> impl Future<Output = Result<DidDocResponse>> { 393 308 async move { 394 309 match actor { 395 310 AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, ··· 406 321 fn resolve_ident_owned( 407 322 &self, 408 323 actor: &AtIdentifier<'_>, 409 - ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> 324 + ) -> impl Future<Output = Result<DidDocument<'static>>> 410 325 where 411 326 Self: Sync, 412 327 { ··· 426 341 fn resolve_ident_owned( 427 342 &self, 428 343 actor: &AtIdentifier<'_>, 429 - ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> { 344 + ) -> impl Future<Output = Result<DidDocument<'static>>> { 430 345 async move { 431 346 match actor { 432 347 AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, ··· 443 358 fn resolve_did_doc_owned( 444 359 &self, 445 360 did: &Did<'_>, 446 - ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> 361 + ) -> impl Future<Output = Result<DidDocument<'static>>> 447 362 where 448 363 Self: Sync, 449 364 { ··· 455 370 fn resolve_did_doc_owned( 456 371 &self, 457 372 did: &Did<'_>, 458 - ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> { 373 + ) -> impl Future<Output = Result<DidDocument<'static>>> { 459 374 async { self.resolve_did_doc(did).await?.into_owned() } 460 375 } 461 376 462 377 /// Return the PDS url for a DID 463 378 #[cfg(not(target_arch = "wasm32"))] 464 - fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> 379 + fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url>> 465 380 where 466 381 Self: Sync, 467 382 { ··· 471 386 // Default-on doc id equality check 472 387 if self.options().validate_doc_id { 473 388 if doc.id.as_str() != did.as_str() { 474 - return Err(IdentityError::DocIdMismatch { 475 - expected: did.clone().into_static(), 476 - doc: doc.clone().into_static(), 477 - }); 389 + return Err(IdentityError::doc_id_mismatch( 390 + did.clone().into_static(), 391 + doc.clone().into_static(), 392 + )); 478 393 } 479 394 } 480 - doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 395 + doc.pds_endpoint() 396 + .ok_or_else(|| IdentityError::missing_pds_endpoint()) 481 397 } 482 398 } 483 399 484 400 /// Return the PDS url for a DID 485 401 #[cfg(target_arch = "wasm32")] 486 - fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> { 402 + fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url>> { 487 403 async { 488 404 let resp = self.resolve_did_doc(did).await?; 489 405 let doc = resp.parse()?; 490 406 // Default-on doc id equality check 491 407 if self.options().validate_doc_id { 492 408 if doc.id.as_str() != did.as_str() { 493 - return Err(IdentityError::DocIdMismatch { 494 - expected: did.clone().into_static(), 495 - doc: doc.clone().into_static(), 496 - }); 409 + return Err(IdentityError::doc_id_mismatch( 410 + did.clone().into_static(), 411 + doc.clone().into_static(), 412 + )); 497 413 } 498 414 } 499 - doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 415 + doc.pds_endpoint() 416 + .ok_or_else(|| IdentityError::missing_pds_endpoint()) 500 417 } 501 418 } 502 419 ··· 505 422 fn pds_for_handle( 506 423 &self, 507 424 handle: &Handle<'_>, 508 - ) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> 425 + ) -> impl Future<Output = Result<(Did<'static>, Url)>> 509 426 where 510 427 Self: Sync, 511 428 { ··· 521 438 fn pds_for_handle( 522 439 &self, 523 440 handle: &Handle<'_>, 524 - ) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> { 441 + ) -> impl Future<Output = Result<(Did<'static>, Url)>> { 525 442 async { 526 443 let did = self.resolve_handle(handle).await?; 527 444 let pds = self.pds_for_did(&did).await?; ··· 537 454 } 538 455 539 456 /// Resolve handle 540 - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 457 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>> { 541 458 self.as_ref().resolve_handle(handle).await 542 459 } 543 460 544 461 /// Resolve DID document 545 - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 462 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse> { 546 463 self.as_ref().resolve_did_doc(did).await 547 464 } 548 465 } ··· 554 471 } 555 472 556 473 /// Resolve handle 557 - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 474 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>> { 558 475 self.as_ref().resolve_handle(handle).await 559 476 } 560 477 561 478 /// Resolve DID document 562 - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 479 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse> { 563 480 self.as_ref().resolve_did_doc(did).await 564 481 } 565 482 } 566 483 484 + /// Error type for identity resolution operations 485 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 486 + #[error("{kind}")] 487 + pub struct IdentityError { 488 + #[diagnostic_source] 489 + kind: IdentityErrorKind, 490 + #[source] 491 + source: Option<BoxError>, 492 + #[help] 493 + help: Option<SmolStr>, 494 + context: Option<SmolStr>, 495 + } 496 + 497 + /// Error categories for identity resolution 498 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 499 + pub enum IdentityErrorKind { 500 + /// Unsupported DID method 501 + #[error("unsupported DID method: {0}")] 502 + #[diagnostic( 503 + code(jacquard::identity::unsupported_method), 504 + help("supported DID methods: did:web, did:plc") 505 + )] 506 + UnsupportedDidMethod(SmolStr), 507 + 508 + /// Invalid well-known atproto-did content 509 + #[error("invalid well-known atproto-did content")] 510 + #[diagnostic( 511 + code(jacquard::identity::invalid_well_known), 512 + help("expected first non-empty line to be a DID") 513 + )] 514 + InvalidWellKnown, 515 + 516 + /// Missing PDS endpoint in DID document 517 + #[error("missing PDS endpoint in DID document")] 518 + #[diagnostic( 519 + code(jacquard::identity::missing_pds), 520 + help("ensure DID document contains AtprotoPersonalDataServer service") 521 + )] 522 + MissingPdsEndpoint, 523 + 524 + /// Transport-level error 525 + #[error("transport error")] 526 + #[diagnostic( 527 + code(jacquard::identity::transport), 528 + help("check network connectivity and TLS configuration") 529 + )] 530 + Transport, 531 + 532 + /// HTTP status error 533 + #[error("HTTP {0}")] 534 + #[diagnostic( 535 + code(jacquard::identity::http_status), 536 + help("verify well-known paths or PDS XRPC endpoints") 537 + )] 538 + HttpStatus(StatusCode), 539 + 540 + /// XRPC error 541 + #[error("XRPC error: {0}")] 542 + #[diagnostic( 543 + code(jacquard::identity::xrpc), 544 + help("enable PDS fallback or public resolver if needed") 545 + )] 546 + Xrpc(SmolStr), 547 + 548 + /// URL parse error 549 + #[error("URL parse error")] 550 + #[diagnostic(code(jacquard::identity::url))] 551 + Url, 552 + 553 + /// DNS resolution error 554 + #[cfg(all(feature = "dns", not(target_family = "wasm")))] 555 + #[error("DNS resolution error")] 556 + #[diagnostic( 557 + code(jacquard::identity::dns), 558 + help("check DNS configuration and connectivity") 559 + )] 560 + Dns, 561 + 562 + /// Serialization/deserialization error 563 + #[error("serialization error")] 564 + #[diagnostic(code(jacquard::identity::serialization))] 565 + Serialization, 566 + 567 + /// Invalid DID document 568 + #[error("invalid DID document: {0}")] 569 + #[diagnostic( 570 + code(jacquard::identity::invalid_doc), 571 + help("validate keys and services in DID document") 572 + )] 573 + InvalidDoc(SmolStr), 574 + 575 + /// DID document id mismatch - includes the fetched document for inspection 576 + #[error("DID document id mismatch")] 577 + #[diagnostic( 578 + code(jacquard::identity::doc_mismatch), 579 + help("document id differs from requested DID; do not trust this document") 580 + )] 581 + DocIdMismatch { 582 + expected: Did<'static>, 583 + doc: DidDocument<'static>, 584 + }, 585 + } 586 + 587 + impl IdentityError { 588 + /// Create a new error with the given kind and optional source 589 + pub fn new(kind: IdentityErrorKind, source: Option<BoxError>) -> Self { 590 + Self { 591 + kind, 592 + source, 593 + help: None, 594 + context: None, 595 + } 596 + } 597 + 598 + /// Get the error kind 599 + pub fn kind(&self) -> &IdentityErrorKind { 600 + &self.kind 601 + } 602 + 603 + /// Get the source error if present 604 + pub fn source_err(&self) -> Option<&BoxError> { 605 + self.source.as_ref() 606 + } 607 + 608 + /// Get the context string if present 609 + pub fn context(&self) -> Option<&str> { 610 + self.context.as_ref().map(|s| s.as_str()) 611 + } 612 + 613 + /// Add help text to this error 614 + pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 615 + self.help = Some(help.into()); 616 + self 617 + } 618 + 619 + /// Add context to this error 620 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 621 + self.context = Some(context.into()); 622 + self 623 + } 624 + 625 + // Constructors for each kind 626 + 627 + /// Create an unsupported DID method error 628 + pub fn unsupported_did_method(method: impl Into<SmolStr>) -> Self { 629 + Self::new(IdentityErrorKind::UnsupportedDidMethod(method.into()), None) 630 + } 631 + 632 + /// Create an invalid well-known error 633 + pub fn invalid_well_known() -> Self { 634 + Self::new(IdentityErrorKind::InvalidWellKnown, None) 635 + } 636 + 637 + /// Create a missing PDS endpoint error 638 + pub fn missing_pds_endpoint() -> Self { 639 + Self::new(IdentityErrorKind::MissingPdsEndpoint, None) 640 + } 641 + 642 + /// Create a transport error 643 + pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 644 + Self::new(IdentityErrorKind::Transport, Some(Box::new(source))) 645 + } 646 + 647 + /// Create an HTTP status error 648 + pub fn http_status(status: StatusCode) -> Self { 649 + Self::new(IdentityErrorKind::HttpStatus(status), None) 650 + } 651 + 652 + /// Create an XRPC error 653 + pub fn xrpc(msg: impl Into<SmolStr>) -> Self { 654 + Self::new(IdentityErrorKind::Xrpc(msg.into()), None) 655 + } 656 + 657 + /// Create a URL parse error 658 + pub fn url(source: impl std::error::Error + Send + Sync + 'static) -> Self { 659 + Self::new(IdentityErrorKind::Url, Some(Box::new(source))) 660 + } 661 + 662 + /// Create a DNS error 663 + #[cfg(all(feature = "dns", not(target_family = "wasm")))] 664 + pub fn dns(source: impl std::error::Error + Send + Sync + 'static) -> Self { 665 + Self::new(IdentityErrorKind::Dns, Some(Box::new(source))) 666 + } 667 + 668 + /// Create a serialization error 669 + pub fn serialization(source: impl std::error::Error + Send + Sync + 'static) -> Self { 670 + Self::new(IdentityErrorKind::Serialization, Some(Box::new(source))) 671 + } 672 + 673 + /// Create an invalid doc error 674 + pub fn invalid_doc(msg: impl Into<SmolStr>) -> Self { 675 + Self::new(IdentityErrorKind::InvalidDoc(msg.into()), None) 676 + } 677 + 678 + /// Create a doc id mismatch error 679 + pub fn doc_id_mismatch(expected: Did<'static>, doc: DidDocument<'static>) -> Self { 680 + Self::new(IdentityErrorKind::DocIdMismatch { expected, doc }, None) 681 + } 682 + } 683 + 684 + /// Result type for identity operations 685 + pub type Result<T> = std::result::Result<T, IdentityError>; 686 + 687 + // ============================================================================ 688 + // Conversions from external errors 689 + // ============================================================================ 690 + 691 + // #[allow(deprecated)] 692 + // impl From<jacquard_common::error::TransportError> for IdentityError { 693 + // fn from(e: jacquard_common::error::TransportError) -> Self { 694 + // Self::transport(e).with_context("transport-level error during identity resolution") 695 + // } 696 + // } 697 + 698 + impl From<url::ParseError> for IdentityError { 699 + fn from(e: url::ParseError) -> Self { 700 + let msg = smol_str::format_smolstr!("{:?}", e); 701 + Self::new(IdentityErrorKind::Url, Some(Box::new(e))).with_context(msg) 702 + } 703 + } 704 + 705 + // Identity resolution errors -> ClientError 706 + impl From<IdentityError> for jacquard_common::error::ClientError { 707 + fn from(e: IdentityError) -> Self { 708 + Self::identity_resolution(e) 709 + } 710 + } 711 + 712 + #[cfg(all(feature = "dns", not(target_family = "wasm")))] 713 + impl From<hickory_resolver::error::ResolveError> for IdentityError { 714 + fn from(e: hickory_resolver::error::ResolveError) -> Self { 715 + let msg = smol_str::format_smolstr!("{:?}", e); 716 + Self::new(IdentityErrorKind::Dns, Some(Box::new(e))) 717 + .with_context(msg) 718 + .with_help("check DNS configuration and network connectivity") 719 + } 720 + } 721 + 722 + impl From<serde_json::Error> for IdentityError { 723 + fn from(e: serde_json::Error) -> Self { 724 + let msg = smol_str::format_smolstr!("{:?}", e); 725 + Self::new(IdentityErrorKind::Serialization, Some(Box::new(e))) 726 + .with_context(msg) 727 + .with_help("ensure response is valid JSON") 728 + } 729 + } 730 + 731 + impl From<AtDataError> for IdentityError { 732 + fn from(e: AtDataError) -> Self { 733 + let msg = smol_str::format_smolstr!("{:?}", e); 734 + Self::new(IdentityErrorKind::Serialization, Some(Box::new(e))) 735 + .with_context(msg) 736 + .with_help("AT Protocol data validation failed") 737 + } 738 + } 739 + 740 + impl From<reqwest::Error> for IdentityError { 741 + fn from(e: reqwest::Error) -> Self { 742 + Self::transport(e).with_context("HTTP request failed during identity resolution") 743 + } 744 + } 745 + 567 746 #[cfg(test)] 568 747 mod tests { 569 748 use super::*; ··· 590 769 requested: Some(requested), 591 770 }; 592 771 match resp.parse_validated() { 593 - Err(IdentityError::DocIdMismatch { expected, doc }) => { 594 - assert_eq!(expected.as_str(), "did:plc:alice"); 595 - assert_eq!(doc.id.as_str(), "did:plc:bob"); 596 - } 772 + Err(e) => match e.kind() { 773 + IdentityErrorKind::DocIdMismatch { expected, doc } => { 774 + assert_eq!(expected.as_str(), "did:plc:alice"); 775 + assert_eq!(doc.id.as_str(), "did:plc:bob"); 776 + } 777 + _ => panic!("unexpected error kind: {:?}", e), 778 + }, 597 779 other => panic!("unexpected result: {:?}", other), 598 780 } 599 781 }
+30 -11
crates/jacquard-oauth/src/client.rs
··· 11 11 }; 12 12 use jacquard_common::{ 13 13 AuthorizationToken, CowStr, IntoStatic, 14 - error::{AuthError, ClientError, TransportError, XrpcResult}, 14 + error::{AuthError, ClientError, XrpcResult}, 15 15 http_client::HttpClient, 16 16 types::{did::Did, string::Handle}, 17 17 xrpc::{ ··· 493 493 .dpop_call(&mut dpop) 494 494 .send(build_http_request(&base_uri, &request, &opts)?) 495 495 .await 496 - .map_err(|e| TransportError::Other(Box::new(e)))?; 496 + .map_err(|e| ClientError::transport(e))?; 497 497 let resp = process_response(http_response); 498 498 drop(guard); 499 499 if is_invalid_token_response(&resp) { 500 500 opts.auth = Some( 501 501 self.refresh() 502 502 .await 503 - .map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?, 503 + .map_err(|e| ClientError::transport(e))?, 504 504 ); 505 505 let guard = self.data.read().await; 506 506 let mut dpop = guard.dpop_data.clone(); ··· 509 509 .dpop_call(&mut dpop) 510 510 .send(build_http_request(&base_uri, &request, &opts)?) 511 511 .await 512 - .map_err(|e| TransportError::Other(Box::new(e)))?; 512 + .map_err(|e| ClientError::transport(e))?; 513 513 process_response(http_response) 514 514 } else { 515 515 resp ··· 538 538 self.client.send_http_streaming(request).await 539 539 } 540 540 541 + #[cfg(not(target_arch = "wasm32"))] 541 542 async fn send_http_bidirectional<Str>( 542 543 &self, 543 544 parts: http::request::Parts, ··· 551 552 { 552 553 self.client.send_http_bidirectional(parts, body).await 553 554 } 555 + 556 + #[cfg(target_arch = "wasm32")] 557 + async fn send_http_bidirectional<Str>( 558 + &self, 559 + parts: http::request::Parts, 560 + body: Str, 561 + ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> 562 + where 563 + Str: n0_future::Stream< 564 + Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>, 565 + > + 'static, 566 + { 567 + self.client.send_http_bidirectional(parts, body).await 568 + } 554 569 } 555 570 556 571 #[cfg(feature = "streaming")] ··· 626 641 <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp, 627 642 { 628 643 use jacquard_common::StreamError; 629 - use n0_future::{StreamExt, TryStreamExt}; 644 + use n0_future::TryStreamExt; 630 645 631 646 let base_uri = self.base_uri().await; 632 647 let mut opts = self.options.read().await.clone(); ··· 677 692 .into_parts(); 678 693 679 694 let body_stream = 680 - jacquard_common::stream::ByteStream::new(stream.0.map_ok(|f| f.buffer).boxed()); 695 + jacquard_common::stream::ByteStream::new(Box::pin(stream.0.map_ok(|f| f.buffer))); 681 696 682 697 let guard = self.data.read().await; 683 698 let mut dpop = guard.dpop_data.clone(); ··· 707 722 } 708 723 709 724 fn is_invalid_token_response<R: XrpcResp>(response: &XrpcResult<Response<R>>) -> bool { 725 + use jacquard_common::error::ClientErrorKind; 726 + 710 727 match response { 711 - Err(ClientError::Auth(AuthError::InvalidToken)) => true, 712 - Err(ClientError::Auth(AuthError::Other(value))) => value 713 - .to_str() 714 - .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")), 728 + Err(e) => match e.kind() { 729 + ClientErrorKind::Auth(AuthError::InvalidToken) => true, 730 + ClientErrorKind::Auth(AuthError::Other(value)) => value 731 + .to_str() 732 + .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")), 733 + _ => false, 734 + }, 715 735 Ok(resp) => match resp.parse() { 716 736 Err(XrpcError::Auth(AuthError::InvalidToken)) => true, 717 737 _ => false, 718 738 }, 719 - _ => false, 720 739 } 721 740 } 722 741
+330 -51
crates/jacquard-oauth/src/request.rs
··· 14 14 use serde::Serialize; 15 15 use serde_json::Value; 16 16 use smol_str::ToSmolStr; 17 - use thiserror::Error; 18 17 19 18 use crate::{ 20 19 FALLBACK_ALG, ··· 40 39 const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str = 41 40 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 42 41 43 - #[derive(Error, Debug, miette::Diagnostic)] 44 - pub enum RequestError { 42 + use smol_str::SmolStr; 43 + 44 + pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>; 45 + 46 + /// OAuth request error for token operations and auth flows 47 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 48 + #[error("{kind}")] 49 + pub struct RequestError { 50 + #[diagnostic_source] 51 + kind: RequestErrorKind, 52 + #[source] 53 + source: Option<BoxError>, 54 + #[help] 55 + help: Option<SmolStr>, 56 + context: Option<SmolStr>, 57 + url: Option<SmolStr>, 58 + details: Option<SmolStr>, 59 + location: Option<SmolStr>, 60 + } 61 + 62 + /// Error categories for OAuth request operations 63 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 64 + pub enum RequestErrorKind { 65 + /// No endpoint available 45 66 #[error("no {0} endpoint available")] 46 67 #[diagnostic( 47 68 code(jacquard_oauth::request::no_endpoint), 48 69 help("server does not advertise this endpoint") 49 70 )] 50 - NoEndpoint(CowStr<'static>), 71 + NoEndpoint(SmolStr), 72 + 73 + /// Token response verification failed 51 74 #[error("token response verification failed")] 52 75 #[diagnostic(code(jacquard_oauth::request::token_verification))] 53 76 TokenVerification, 77 + 78 + /// Unsupported authentication method 54 79 #[error("unsupported authentication method")] 55 80 #[diagnostic( 56 81 code(jacquard_oauth::request::unsupported_auth_method), ··· 59 84 ) 60 85 )] 61 86 UnsupportedAuthMethod, 87 + 88 + /// No refresh token available 62 89 #[error("no refresh token available")] 63 90 #[diagnostic(code(jacquard_oauth::request::no_refresh_token))] 64 91 NoRefreshToken, 65 - #[error("failed to parse DID: {0}")] 92 + 93 + /// Invalid DID 94 + #[error("failed to parse DID")] 66 95 #[diagnostic(code(jacquard_oauth::request::invalid_did))] 67 - InvalidDid(#[from] AtStrError), 68 - #[error(transparent)] 96 + InvalidDid, 97 + 98 + /// DPoP client error 99 + #[error("dpop error")] 69 100 #[diagnostic(code(jacquard_oauth::request::dpop))] 70 - DpopClient(#[from] crate::dpop::Error), 71 - #[error(transparent)] 101 + Dpop, 102 + 103 + /// Session storage error 104 + #[error("storage error")] 72 105 #[diagnostic(code(jacquard_oauth::request::storage))] 73 - Storage(#[from] SessionStoreError), 106 + Storage, 74 107 75 - #[error(transparent)] 108 + /// Resolver error 109 + #[error("resolver error")] 76 110 #[diagnostic(code(jacquard_oauth::request::resolver))] 77 - ResolverError(#[from] crate::resolver::ResolverError), 78 - // #[error(transparent)] 79 - // OAuthSession(#[from] crate::oauth_session::Error), 80 - #[error(transparent)] 111 + Resolver, 112 + 113 + /// HTTP build error 114 + #[error("http build error")] 81 115 #[diagnostic(code(jacquard_oauth::request::http_build))] 82 - Http(#[from] http::Error), 116 + HttpBuild, 117 + 118 + /// HTTP status error 83 119 #[error("http status: {0}")] 84 120 #[diagnostic( 85 121 code(jacquard_oauth::request::http_status), 86 122 help("see server response for details") 87 123 )] 88 124 HttpStatus(StatusCode), 89 - #[error("http status: {0}, body: {1:?}")] 125 + 126 + /// HTTP status with error body 127 + #[error("http status: {status}, body: {body:?}")] 90 128 #[diagnostic( 91 129 code(jacquard_oauth::request::http_status_body), 92 130 help("server returned error JSON; inspect fields like `error`, `error_description`") 93 131 )] 94 - HttpStatusWithBody(StatusCode, Value), 95 - #[error(transparent)] 132 + HttpStatusWithBody { status: StatusCode, body: Value }, 133 + 134 + /// Identity resolution error 135 + #[error("identity error")] 96 136 #[diagnostic(code(jacquard_oauth::request::identity))] 97 - Identity(#[from] IdentityError), 98 - #[error(transparent)] 137 + Identity, 138 + 139 + /// Keyset error 140 + #[error("keyset error")] 99 141 #[diagnostic(code(jacquard_oauth::request::keyset))] 100 - Keyset(#[from] crate::keyset::Error), 101 - #[error(transparent)] 142 + Keyset, 143 + 144 + /// Form serialization error 145 + #[error("form serialization error")] 102 146 #[diagnostic(code(jacquard_oauth::request::serde_form))] 103 - SerdeHtmlForm(#[from] serde_html_form::ser::Error), 104 - #[error(transparent)] 147 + SerdeHtmlForm, 148 + 149 + /// JSON error 150 + #[error("json error")] 105 151 #[diagnostic(code(jacquard_oauth::request::serde_json))] 106 - SerdeJson(#[from] serde_json::Error), 107 - #[error(transparent)] 152 + SerdeJson, 153 + 154 + /// Atproto metadata error 155 + #[error("atproto error")] 108 156 #[diagnostic(code(jacquard_oauth::request::atproto))] 109 - Atproto(#[from] crate::atproto::Error), 157 + Atproto, 158 + } 159 + 160 + impl RequestError { 161 + /// Create a new error with the given kind and optional source 162 + pub fn new(kind: RequestErrorKind, source: Option<BoxError>) -> Self { 163 + Self { 164 + kind, 165 + source, 166 + help: None, 167 + context: None, 168 + url: None, 169 + details: None, 170 + location: None, 171 + } 172 + } 173 + 174 + /// Get the error kind 175 + pub fn kind(&self) -> &RequestErrorKind { 176 + &self.kind 177 + } 178 + 179 + /// Get the source error if present 180 + pub fn source_err(&self) -> Option<&BoxError> { 181 + self.source.as_ref() 182 + } 183 + 184 + /// Get the context string if present 185 + pub fn context(&self) -> Option<&str> { 186 + self.context.as_ref().map(|s| s.as_str()) 187 + } 188 + 189 + /// Get the URL if present 190 + pub fn url(&self) -> Option<&str> { 191 + self.url.as_ref().map(|s| s.as_str()) 192 + } 193 + 194 + /// Get the details if present 195 + pub fn details(&self) -> Option<&str> { 196 + self.details.as_ref().map(|s| s.as_str()) 197 + } 198 + 199 + /// Get the location if present 200 + pub fn location(&self) -> Option<&str> { 201 + self.location.as_ref().map(|s| s.as_str()) 202 + } 203 + 204 + /// Add help text to this error 205 + pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 206 + self.help = Some(help.into()); 207 + self 208 + } 209 + 210 + /// Add context to this error 211 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 212 + self.context = Some(context.into()); 213 + self 214 + } 215 + 216 + /// Add URL to this error 217 + pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 218 + self.url = Some(url.into()); 219 + self 220 + } 221 + 222 + /// Add details to this error 223 + pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self { 224 + self.details = Some(details.into()); 225 + self 226 + } 227 + 228 + /// Add location to this error 229 + pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self { 230 + self.location = Some(location.into()); 231 + self 232 + } 233 + 234 + // Constructors for each kind 235 + 236 + /// Create a no endpoint error 237 + pub fn no_endpoint(endpoint: impl Into<SmolStr>) -> Self { 238 + Self::new(RequestErrorKind::NoEndpoint(endpoint.into()), None) 239 + } 240 + 241 + /// Create a token verification error 242 + pub fn token_verification() -> Self { 243 + Self::new(RequestErrorKind::TokenVerification, None) 244 + } 245 + 246 + /// Create an unsupported authentication method error 247 + pub fn unsupported_auth_method() -> Self { 248 + Self::new(RequestErrorKind::UnsupportedAuthMethod, None) 249 + } 250 + 251 + /// Create a no refresh token error 252 + pub fn no_refresh_token() -> Self { 253 + Self::new(RequestErrorKind::NoRefreshToken, None) 254 + } 255 + 256 + /// Create an invalid DID error 257 + pub fn invalid_did(source: impl std::error::Error + Send + Sync + 'static) -> Self { 258 + Self::new(RequestErrorKind::InvalidDid, Some(Box::new(source))) 259 + } 260 + 261 + /// Create a DPoP error 262 + pub fn dpop(source: impl std::error::Error + Send + Sync + 'static) -> Self { 263 + Self::new(RequestErrorKind::Dpop, Some(Box::new(source))) 264 + } 265 + 266 + /// Create a storage error 267 + pub fn storage(source: impl std::error::Error + Send + Sync + 'static) -> Self { 268 + Self::new(RequestErrorKind::Storage, Some(Box::new(source))) 269 + } 270 + 271 + /// Create a resolver error 272 + pub fn resolver(source: impl std::error::Error + Send + Sync + 'static) -> Self { 273 + Self::new(RequestErrorKind::Resolver, Some(Box::new(source))) 274 + } 275 + 276 + /// Create an HTTP build error 277 + pub fn http_build(source: impl std::error::Error + Send + Sync + 'static) -> Self { 278 + Self::new(RequestErrorKind::HttpBuild, Some(Box::new(source))) 279 + } 280 + 281 + /// Create an HTTP status error 282 + pub fn http_status(status: StatusCode) -> Self { 283 + Self::new(RequestErrorKind::HttpStatus(status), None) 284 + } 285 + 286 + /// Create an HTTP status with body error 287 + pub fn http_status_with_body(status: StatusCode, body: Value) -> Self { 288 + Self::new(RequestErrorKind::HttpStatusWithBody { status, body }, None) 289 + } 290 + 291 + /// Create an identity error 292 + pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self { 293 + Self::new(RequestErrorKind::Identity, Some(Box::new(source))) 294 + } 295 + 296 + /// Create a keyset error 297 + pub fn keyset(source: impl std::error::Error + Send + Sync + 'static) -> Self { 298 + Self::new(RequestErrorKind::Keyset, Some(Box::new(source))) 299 + } 300 + 301 + /// Create an atproto metadata error 302 + pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self { 303 + Self::new(RequestErrorKind::Atproto, Some(Box::new(source))) 304 + } 305 + } 306 + 307 + // From impls for common error types 308 + 309 + impl From<AtStrError> for RequestError { 310 + fn from(e: AtStrError) -> Self { 311 + let msg = smol_str::format_smolstr!("{:?}", e); 312 + Self::new(RequestErrorKind::InvalidDid, Some(Box::new(e))) 313 + .with_context(msg) 314 + .with_help("ensure DID is correctly formatted (e.g., did:plc:abc123)") 315 + } 316 + } 317 + 318 + impl From<crate::dpop::Error> for RequestError { 319 + fn from(e: crate::dpop::Error) -> Self { 320 + let msg = smol_str::format_smolstr!("{:?}", e); 321 + Self::new(RequestErrorKind::Dpop, Some(Box::new(e))) 322 + .with_context(msg) 323 + .with_help("check DPoP key configuration and nonce handling") 324 + } 325 + } 326 + 327 + impl From<SessionStoreError> for RequestError { 328 + fn from(e: SessionStoreError) -> Self { 329 + let msg = smol_str::format_smolstr!("{:?}", e); 330 + Self::new(RequestErrorKind::Storage, Some(Box::new(e))) 331 + .with_context(msg) 332 + .with_help("verify session store is accessible and writable") 333 + } 334 + } 335 + 336 + impl From<crate::resolver::ResolverError> for RequestError { 337 + fn from(e: crate::resolver::ResolverError) -> Self { 338 + let msg = smol_str::format_smolstr!("{:?}", e); 339 + Self::new(RequestErrorKind::Resolver, Some(Box::new(e))) 340 + .with_context(msg) 341 + .with_help("check identity resolution and OAuth metadata endpoints") 342 + } 343 + } 344 + 345 + impl From<http::Error> for RequestError { 346 + fn from(e: http::Error) -> Self { 347 + let msg = smol_str::format_smolstr!("{:?}", e); 348 + Self::new(RequestErrorKind::HttpBuild, Some(Box::new(e))) 349 + .with_context(msg) 350 + .with_help("verify request URIs and headers are valid") 351 + } 352 + } 353 + 354 + impl From<IdentityError> for RequestError { 355 + fn from(e: IdentityError) -> Self { 356 + let msg = smol_str::format_smolstr!("{:?}", e); 357 + Self::new(RequestErrorKind::Identity, Some(Box::new(e))) 358 + .with_context(msg) 359 + .with_help("check handle/DID is valid and identity resolver is configured") 360 + } 361 + } 362 + 363 + impl From<crate::keyset::Error> for RequestError { 364 + fn from(e: crate::keyset::Error) -> Self { 365 + let msg = smol_str::format_smolstr!("{:?}", e); 366 + Self::new(RequestErrorKind::Keyset, Some(Box::new(e))) 367 + .with_context(msg) 368 + .with_help("verify keyset configuration and signing algorithm support") 369 + } 370 + } 371 + 372 + impl From<serde_html_form::ser::Error> for RequestError { 373 + fn from(e: serde_html_form::ser::Error) -> Self { 374 + let msg = smol_str::format_smolstr!("{:?}", e); 375 + Self::new(RequestErrorKind::SerdeHtmlForm, Some(Box::new(e))) 376 + .with_context(msg) 377 + .with_help("check OAuth request parameters are serializable") 378 + } 379 + } 380 + 381 + impl From<serde_json::Error> for RequestError { 382 + fn from(e: serde_json::Error) -> Self { 383 + let msg = smol_str::format_smolstr!("{:?}", e); 384 + Self::new(RequestErrorKind::SerdeJson, Some(Box::new(e))) 385 + .with_context(msg) 386 + .with_help("verify OAuth response body is valid JSON") 387 + } 388 + } 389 + 390 + impl From<crate::atproto::Error> for RequestError { 391 + fn from(e: crate::atproto::Error) -> Self { 392 + let msg = smol_str::format_smolstr!("{:?}", e); 393 + Self::new(RequestErrorKind::Atproto, Some(Box::new(e))) 394 + .with_context(msg) 395 + .with_help("ensure client metadata matches atproto requirements") 396 + } 110 397 } 111 398 112 399 pub type Result<T> = core::result::Result<T, RequestError>; ··· 191 478 let (code_challenge, verifier) = generate_pkce(); 192 479 193 480 let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else { 194 - return Err(RequestError::TokenVerification); 481 + return Err(RequestError::token_verification()); 195 482 }; 196 483 let mut dpop_data = DpopReqData { 197 484 dpop_key, ··· 247 534 .require_pushed_authorization_requests 248 535 == Some(true) 249 536 { 250 - Err(RequestError::NoEndpoint(CowStr::new_static( 251 - "pushed_authorization_request", 252 - ))) 537 + Err(RequestError::no_endpoint("pushed_authorization_request")) 253 538 } else { 254 539 todo!("use of PAR is mandatory") 255 540 } ··· 265 550 T: OAuthResolver + DpopExt + Send + Sync + 'static, 266 551 { 267 552 let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else { 268 - return Err(RequestError::NoRefreshToken); 553 + return Err(RequestError::no_refresh_token()); 269 554 }; 270 555 271 556 // /!\ IMPORTANT /!\ ··· 343 628 ) 344 629 .await?; 345 630 let Some(sub) = token_response.sub else { 346 - return Err(RequestError::TokenVerification); 631 + return Err(RequestError::token_verification()); 347 632 }; 348 633 let sub = Did::new_owned(sub)?; 349 634 let iss = metadata.server_metadata.issuer.clone(); ··· 408 693 D: DpopDataSource, 409 694 { 410 695 let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else { 411 - return Err(RequestError::NoEndpoint(request.name())); 696 + return Err(RequestError::no_endpoint(request.name())); 412 697 }; 413 698 let client_assertions = build_auth( 414 699 metadata.keyset.as_ref(), ··· 429 714 .method(Method::POST) 430 715 .header("Content-Type", "application/x-www-form-urlencoded") 431 716 .body(body.into_bytes())?; 432 - let res = client 433 - .dpop_server_call(data_source) 434 - .send(req) 435 - .await 436 - .map_err(RequestError::DpopClient)?; 717 + let res = client.dpop_server_call(data_source).send(req).await?; 437 718 if res.status() == request.expected_status() { 438 719 let body = res.body(); 439 720 if body.is_empty() { ··· 444 725 Ok(output) 445 726 } 446 727 } else if res.status().is_client_error() { 447 - Err(RequestError::HttpStatusWithBody( 728 + Err(RequestError::http_status_with_body( 448 729 res.status(), 449 730 serde_json::from_slice(res.body())?, 450 731 )) 451 732 } else { 452 - Err(RequestError::HttpStatus(res.status())) 733 + Err(RequestError::http_status(res.status())) 453 734 } 454 735 } 455 736 ··· 560 841 } 561 842 } 562 843 563 - Err(RequestError::UnsupportedAuthMethod) 844 + Err(RequestError::unsupported_auth_method()) 564 845 } 565 846 566 847 #[cfg(test)] ··· 642 923 server.issuer = CowStr::from("https://issuer"); 643 924 server.authorization_endpoint = CowStr::from("https://issuer/authorize"); 644 925 server.token_endpoint = CowStr::from("https://issuer/token"); 926 + server.token_endpoint_auth_methods_supported = Some(vec![CowStr::from("none")]); 645 927 OAuthMetadata { 646 928 server_metadata: server, 647 929 client_metadata: OAuthClientMetadata { ··· 669 951 let err = super::par(&MockClient::default(), None, None, &meta) 670 952 .await 671 953 .unwrap_err(); 672 - match err { 673 - RequestError::NoEndpoint(name) => { 674 - assert_eq!(name.as_ref(), "pushed_authorization_request"); 675 - } 676 - other => panic!("unexpected: {other:?}"), 677 - } 954 + assert!( 955 + matches!(err.kind(), RequestErrorKind::NoEndpoint(name) if name == "pushed_authorization_request") 956 + ); 678 957 } 679 958 680 959 #[tokio::test] ··· 706 985 }, 707 986 }; 708 987 let err = super::refresh(&client, session, &meta).await.unwrap_err(); 709 - matches!(err, RequestError::NoRefreshToken); 988 + assert!(matches!(err.kind(), RequestErrorKind::NoRefreshToken)); 710 989 } 711 990 712 991 #[tokio::test] ··· 734 1013 let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta) 735 1014 .await 736 1015 .unwrap_err(); 737 - matches!(err, RequestError::TokenVerification); 1016 + assert!(matches!(err.kind(), RequestErrorKind::TokenVerification)); 738 1017 } 739 1018 }
+370 -152
crates/jacquard-oauth/src/resolver.rs
··· 4 4 use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 5 5 use http::{Request, StatusCode}; 6 6 use jacquard_common::CowStr; 7 + use jacquard_common::IntoStatic; 7 8 use jacquard_common::types::did_doc::DidDocument; 8 9 use jacquard_common::types::ident::AtIdentifier; 9 - use jacquard_common::{IntoStatic, error::TransportError}; 10 10 use jacquard_common::{http_client::HttpClient, types::did::Did}; 11 11 use jacquard_identity::resolver::{IdentityError, IdentityResolver}; 12 + use smol_str::SmolStr; 12 13 use url::Url; 13 14 14 15 /// Compare two issuer strings strictly but without spuriously failing on trivial differences. ··· 51 52 } 52 53 } 53 54 54 - #[derive(thiserror::Error, Debug, miette::Diagnostic)] 55 - pub enum ResolverError { 55 + pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>; 56 + 57 + /// OAuth resolver error for identity and metadata resolution 58 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 59 + #[error("{kind}")] 60 + pub struct ResolverError { 61 + #[diagnostic_source] 62 + kind: ResolverErrorKind, 63 + #[source] 64 + source: Option<BoxError>, 65 + #[help] 66 + help: Option<SmolStr>, 67 + context: Option<SmolStr>, 68 + url: Option<SmolStr>, 69 + details: Option<SmolStr>, 70 + location: Option<SmolStr>, 71 + } 72 + 73 + /// Error categories for OAuth resolver operations 74 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 75 + pub enum ResolverErrorKind { 76 + /// Resource not found 56 77 #[error("resource not found")] 57 78 #[diagnostic( 58 79 code(jacquard_oauth::resolver::not_found), 59 80 help("check the base URL or identifier") 60 81 )] 61 82 NotFound, 83 + 84 + /// Invalid AT identifier 62 85 #[error("invalid at identifier: {0}")] 63 86 #[diagnostic( 64 87 code(jacquard_oauth::resolver::at_identifier), 65 88 help("ensure a valid handle or DID was provided") 66 89 )] 67 - AtIdentifier(String), 90 + AtIdentifier(SmolStr), 91 + 92 + /// Invalid DID 68 93 #[error("invalid did: {0}")] 69 94 #[diagnostic( 70 95 code(jacquard_oauth::resolver::did), 71 96 help("ensure DID is correctly formed (did:plc or did:web)") 72 97 )] 73 - Did(String), 98 + Did(SmolStr), 99 + 100 + /// Invalid DID document 74 101 #[error("invalid did document: {0}")] 75 102 #[diagnostic( 76 103 code(jacquard_oauth::resolver::did_document), 77 104 help("verify the DID document structure and service entries") 78 105 )] 79 - DidDocument(String), 106 + DidDocument(SmolStr), 107 + 108 + /// Protected resource metadata is invalid 80 109 #[error("protected resource metadata is invalid: {0}")] 81 110 #[diagnostic( 82 111 code(jacquard_oauth::resolver::protected_resource_metadata), 83 112 help("PDS must advertise an authorization server in its protected resource metadata") 84 113 )] 85 - ProtectedResourceMetadata(String), 114 + ProtectedResourceMetadata(SmolStr), 115 + 116 + /// Authorization server metadata is invalid 86 117 #[error("authorization server metadata is invalid: {0}")] 87 118 #[diagnostic( 88 119 code(jacquard_oauth::resolver::authorization_server_metadata), 89 120 help("issuer must match and include the PDS resource") 90 121 )] 91 - AuthorizationServerMetadata(String), 92 - #[error("error resolving identity: {0}")] 122 + AuthorizationServerMetadata(SmolStr), 123 + 124 + /// Identity resolution error 125 + #[error("error resolving identity")] 93 126 #[diagnostic(code(jacquard_oauth::resolver::identity))] 94 - IdentityResolverError(#[from] IdentityError), 127 + Identity, 128 + 129 + /// Unsupported DID method 95 130 #[error("unsupported did method: {0:?}")] 96 131 #[diagnostic( 97 132 code(jacquard_oauth::resolver::unsupported_did_method), 98 133 help("supported DID methods: did:web, did:plc") 99 134 )] 100 135 UnsupportedDidMethod(Did<'static>), 101 - #[error(transparent)] 136 + 137 + /// HTTP transport error 138 + #[error("transport error")] 102 139 #[diagnostic(code(jacquard_oauth::resolver::transport))] 103 - Transport(#[from] TransportError), 104 - #[error("http status: {0:?}")] 140 + Transport, 141 + 142 + /// HTTP status error 143 + #[error("http status: {0}")] 105 144 #[diagnostic( 106 145 code(jacquard_oauth::resolver::http_status), 107 146 help("check well-known paths and server configuration") 108 147 )] 109 148 HttpStatus(StatusCode), 110 - #[error(transparent)] 149 + 150 + /// JSON serialization error 151 + #[error("json error")] 111 152 #[diagnostic(code(jacquard_oauth::resolver::serde_json))] 112 - SerdeJson(#[from] serde_json::Error), 113 - #[error(transparent)] 153 + SerdeJson, 154 + 155 + /// Form serialization error 156 + #[error("form serialization error")] 114 157 #[diagnostic(code(jacquard_oauth::resolver::serde_form))] 115 - SerdeHtmlForm(#[from] serde_html_form::ser::Error), 116 - #[error(transparent)] 158 + SerdeHtmlForm, 159 + 160 + /// URL parsing error 161 + #[error("url parsing error")] 117 162 #[diagnostic(code(jacquard_oauth::resolver::url))] 118 - Uri(#[from] url::ParseError), 163 + Uri, 164 + } 165 + 166 + impl ResolverError { 167 + /// Create a new error with the given kind and optional source 168 + pub fn new(kind: ResolverErrorKind, source: Option<BoxError>) -> Self { 169 + Self { 170 + kind, 171 + source, 172 + help: None, 173 + context: None, 174 + url: None, 175 + details: None, 176 + location: None, 177 + } 178 + } 179 + 180 + /// Get the error kind 181 + pub fn kind(&self) -> &ResolverErrorKind { 182 + &self.kind 183 + } 184 + 185 + /// Get the source error if present 186 + pub fn source_err(&self) -> Option<&BoxError> { 187 + self.source.as_ref() 188 + } 189 + 190 + /// Get the context string if present 191 + pub fn context(&self) -> Option<&str> { 192 + self.context.as_ref().map(|s| s.as_str()) 193 + } 194 + 195 + /// Get the URL if present 196 + pub fn url(&self) -> Option<&str> { 197 + self.url.as_ref().map(|s| s.as_str()) 198 + } 199 + 200 + /// Get the details if present 201 + pub fn details(&self) -> Option<&str> { 202 + self.details.as_ref().map(|s| s.as_str()) 203 + } 204 + 205 + /// Get the location if present 206 + pub fn location(&self) -> Option<&str> { 207 + self.location.as_ref().map(|s| s.as_str()) 208 + } 209 + 210 + /// Add help text to this error 211 + pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 212 + self.help = Some(help.into()); 213 + self 214 + } 215 + 216 + /// Add context to this error 217 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 218 + self.context = Some(context.into()); 219 + self 220 + } 221 + 222 + /// Add URL to this error 223 + pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 224 + self.url = Some(url.into()); 225 + self 226 + } 227 + 228 + /// Add details to this error 229 + pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self { 230 + self.details = Some(details.into()); 231 + self 232 + } 233 + 234 + /// Add location to this error 235 + pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self { 236 + self.location = Some(location.into()); 237 + self 238 + } 239 + 240 + // Constructors for each kind 241 + 242 + /// Create a not found error 243 + pub fn not_found() -> Self { 244 + Self::new(ResolverErrorKind::NotFound, None) 245 + } 246 + 247 + /// Create an invalid AT identifier error 248 + pub fn at_identifier(msg: impl Into<SmolStr>) -> Self { 249 + Self::new(ResolverErrorKind::AtIdentifier(msg.into()), None) 250 + } 251 + 252 + /// Create an invalid DID error 253 + pub fn did(msg: impl Into<SmolStr>) -> Self { 254 + Self::new(ResolverErrorKind::Did(msg.into()), None) 255 + } 256 + 257 + /// Create an invalid DID document error 258 + pub fn did_document(msg: impl Into<SmolStr>) -> Self { 259 + Self::new(ResolverErrorKind::DidDocument(msg.into()), None) 260 + } 261 + 262 + /// Create a protected resource metadata error 263 + pub fn protected_resource_metadata(msg: impl Into<SmolStr>) -> Self { 264 + Self::new( 265 + ResolverErrorKind::ProtectedResourceMetadata(msg.into()), 266 + None, 267 + ) 268 + } 269 + 270 + /// Create an authorization server metadata error 271 + pub fn authorization_server_metadata(msg: impl Into<SmolStr>) -> Self { 272 + Self::new( 273 + ResolverErrorKind::AuthorizationServerMetadata(msg.into()), 274 + None, 275 + ) 276 + } 277 + 278 + /// Create an identity resolution error 279 + pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self { 280 + Self::new(ResolverErrorKind::Identity, Some(Box::new(source))) 281 + } 282 + 283 + /// Create an unsupported DID method error 284 + pub fn unsupported_did_method(did: Did<'static>) -> Self { 285 + Self::new(ResolverErrorKind::UnsupportedDidMethod(did), None) 286 + } 287 + 288 + /// Create a transport error 289 + pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 290 + Self::new(ResolverErrorKind::Transport, Some(Box::new(source))) 291 + } 292 + 293 + /// Create an HTTP status error 294 + pub fn http_status(status: StatusCode) -> Self { 295 + Self::new(ResolverErrorKind::HttpStatus(status), None) 296 + } 119 297 } 120 298 299 + /// Result type for resolver operations 300 + pub type Result<T> = std::result::Result<T, ResolverError>; 301 + 302 + // From impls for common error types 303 + 304 + impl From<IdentityError> for ResolverError { 305 + fn from(e: IdentityError) -> Self { 306 + let msg = smol_str::format_smolstr!("{:?}", e); 307 + Self::new(ResolverErrorKind::Identity, Some(Box::new(e))) 308 + .with_context(msg) 309 + .with_help("verify handle/DID is valid and resolver configuration") 310 + } 311 + } 312 + 313 + impl From<jacquard_common::error::ClientError> for ResolverError { 314 + fn from(e: jacquard_common::error::ClientError) -> Self { 315 + let msg = smol_str::format_smolstr!("{:?}", e); 316 + Self::new(ResolverErrorKind::Transport, Some(Box::new(e))) 317 + .with_context(msg) 318 + .with_help("check network connectivity and well-known endpoint availability") 319 + } 320 + } 321 + 322 + impl From<serde_json::Error> for ResolverError { 323 + fn from(e: serde_json::Error) -> Self { 324 + let msg = smol_str::format_smolstr!("{:?}", e); 325 + Self::new(ResolverErrorKind::SerdeJson, Some(Box::new(e))) 326 + .with_context(msg) 327 + .with_help("verify OAuth metadata response format is valid JSON") 328 + } 329 + } 330 + 331 + impl From<serde_html_form::ser::Error> for ResolverError { 332 + fn from(e: serde_html_form::ser::Error) -> Self { 333 + let msg = smol_str::format_smolstr!("{:?}", e); 334 + Self::new(ResolverErrorKind::SerdeHtmlForm, Some(Box::new(e))) 335 + .with_context(msg) 336 + .with_help("check form parameters are serializable") 337 + } 338 + } 339 + 340 + impl From<url::ParseError> for ResolverError { 341 + fn from(e: url::ParseError) -> Self { 342 + let msg = smol_str::format_smolstr!("{:?}", e); 343 + Self::new(ResolverErrorKind::Uri, Some(Box::new(e))) 344 + .with_context(msg) 345 + .with_help("ensure URLs are well-formed (e.g., https://example.com)") 346 + } 347 + } 348 + 349 + // // Deprecated - for compatibility with old TransportError usage 350 + // #[allow(deprecated)] 351 + // impl From<jacquard_common::error::TransportError> for ResolverError { 352 + // fn from(e: jacquard_common::error::TransportError) -> Self { 353 + // Self::transport(e) 354 + // } 355 + // } 356 + 121 357 #[cfg(not(target_arch = "wasm32"))] 122 358 async fn verify_issuer_impl<T: OAuthResolver + Sync + ?Sized>( 123 359 resolver: &T, 124 360 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 125 361 sub: &Did<'_>, 126 - ) -> Result<Url, ResolverError> { 362 + ) -> Result<Url> { 127 363 let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 128 364 if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { 129 - return Err(ResolverError::AuthorizationServerMetadata( 130 - "issuer mismatch".to_string(), 365 + return Err(ResolverError::authorization_server_metadata( 366 + "issuer mismatch", 131 367 )); 132 368 } 133 369 Ok(identity 134 370 .pds_endpoint() 135 - .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 371 + .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) 136 372 } 137 373 138 374 #[cfg(target_arch = "wasm32")] ··· 140 376 resolver: &T, 141 377 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 142 378 sub: &Did<'_>, 143 - ) -> Result<Url, ResolverError> { 379 + ) -> Result<Url> { 144 380 let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 145 381 if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { 146 - return Err(ResolverError::AuthorizationServerMetadata( 147 - "issuer mismatch".to_string(), 382 + return Err(ResolverError::authorization_server_metadata( 383 + "issuer mismatch", 148 384 )); 149 385 } 150 386 Ok(identity 151 387 .pds_endpoint() 152 - .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 388 + .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) 153 389 } 154 390 155 391 #[cfg(not(target_arch = "wasm32"))] 156 392 async fn resolve_oauth_impl<T: OAuthResolver + Sync + ?Sized>( 157 393 resolver: &T, 158 394 input: &str, 159 - ) -> Result< 160 - ( 161 - OAuthAuthorizationServerMetadata<'static>, 162 - Option<DidDocument<'static>>, 163 - ), 164 - ResolverError, 165 - > { 395 + ) -> Result<( 396 + OAuthAuthorizationServerMetadata<'static>, 397 + Option<DidDocument<'static>>, 398 + )> { 166 399 // Allow using an entryway, or PDS url, directly as login input (e.g. 167 400 // when the user forgot their handle, or when the handle does not 168 401 // resolve to a DID) 169 402 Ok(if input.starts_with("https://") { 170 - let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 403 + let url = Url::parse(input).map_err(|_| ResolverError::not_found())?; 171 404 (resolver.resolve_from_service(&url).await?, None) 172 405 } else { 173 406 let (metadata, identity) = resolver.resolve_from_identity(input).await?; ··· 179 412 async fn resolve_oauth_impl<T: OAuthResolver + ?Sized>( 180 413 resolver: &T, 181 414 input: &str, 182 - ) -> Result< 183 - ( 184 - OAuthAuthorizationServerMetadata<'static>, 185 - Option<DidDocument<'static>>, 186 - ), 187 - ResolverError, 188 - > { 415 + ) -> Result<( 416 + OAuthAuthorizationServerMetadata<'static>, 417 + Option<DidDocument<'static>>, 418 + )> { 189 419 // Allow using an entryway, or PDS url, directly as login input (e.g. 190 420 // when the user forgot their handle, or when the handle does not 191 421 // resolve to a DID) 192 422 Ok(if input.starts_with("https://") { 193 - let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 423 + let url = Url::parse(input).map_err(|_| ResolverError::not_found())?; 194 424 (resolver.resolve_from_service(&url).await?, None) 195 425 } else { 196 426 let (metadata, identity) = resolver.resolve_from_identity(input).await?; ··· 202 432 async fn resolve_from_service_impl<T: OAuthResolver + Sync + ?Sized>( 203 433 resolver: &T, 204 434 input: &Url, 205 - ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 435 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 206 436 // Assume first that input is a PDS URL (as required by ATPROTO) 207 437 if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 208 438 return Ok(metadata); ··· 215 445 async fn resolve_from_service_impl<T: OAuthResolver + ?Sized>( 216 446 resolver: &T, 217 447 input: &Url, 218 - ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 448 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 219 449 // Assume first that input is a PDS URL (as required by ATPROTO) 220 450 if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 221 451 return Ok(metadata); ··· 228 458 async fn resolve_from_identity_impl<T: OAuthResolver + Sync + ?Sized>( 229 459 resolver: &T, 230 460 input: &str, 231 - ) -> Result< 232 - ( 233 - OAuthAuthorizationServerMetadata<'static>, 234 - DidDocument<'static>, 235 - ), 236 - ResolverError, 237 - > { 238 - let actor = 239 - AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 461 + ) -> Result<( 462 + OAuthAuthorizationServerMetadata<'static>, 463 + DidDocument<'static>, 464 + )> { 465 + let actor = AtIdentifier::new(input) 466 + .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 240 467 let identity = resolver.resolve_ident_owned(&actor).await?; 241 468 if let Some(pds) = &identity.pds_endpoint() { 242 469 let metadata = resolver.get_resource_server_metadata(pds).await?; 243 470 Ok((metadata, identity)) 244 471 } else { 245 - Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 472 + Err(ResolverError::did_document("Did doc lacking pds")) 246 473 } 247 474 } 248 475 ··· 250 477 async fn resolve_from_identity_impl<T: OAuthResolver + ?Sized>( 251 478 resolver: &T, 252 479 input: &str, 253 - ) -> Result< 254 - ( 255 - OAuthAuthorizationServerMetadata<'static>, 256 - DidDocument<'static>, 257 - ), 258 - ResolverError, 259 - > { 260 - let actor = 261 - AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 480 + ) -> Result<( 481 + OAuthAuthorizationServerMetadata<'static>, 482 + DidDocument<'static>, 483 + )> { 484 + let actor = AtIdentifier::new(input) 485 + .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 262 486 let identity = resolver.resolve_ident_owned(&actor).await?; 263 487 if let Some(pds) = &identity.pds_endpoint() { 264 488 let metadata = resolver.get_resource_server_metadata(pds).await?; 265 489 Ok((metadata, identity)) 266 490 } else { 267 - Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 491 + Err(ResolverError::did_document("Did doc lacking pds")) 268 492 } 269 493 } 270 494 ··· 272 496 async fn get_authorization_server_metadata_impl<T: HttpClient + Sync + ?Sized>( 273 497 client: &T, 274 498 issuer: &Url, 275 - ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 499 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 276 500 let mut md = resolve_authorization_server(client, issuer).await?; 277 501 // Normalize issuer string to the input URL representation to avoid slash quirks 278 502 md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); ··· 283 507 async fn get_authorization_server_metadata_impl<T: HttpClient + ?Sized>( 284 508 client: &T, 285 509 issuer: &Url, 286 - ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 510 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 287 511 let mut md = resolve_authorization_server(client, issuer).await?; 288 512 // Normalize issuer string to the input URL representation to avoid slash quirks 289 513 md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); ··· 294 518 async fn get_resource_server_metadata_impl<T: OAuthResolver + Sync + ?Sized>( 295 519 resolver: &T, 296 520 pds: &Url, 297 - ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 521 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 298 522 let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 299 523 // ATPROTO requires one, and only one, authorization server entry 300 524 // > That document MUST contain a single item in the authorization_servers array. ··· 302 526 let issuer = match &rs_metadata.authorization_servers { 303 527 Some(servers) if !servers.is_empty() => { 304 528 if servers.len() > 1 { 305 - return Err(ResolverError::ProtectedResourceMetadata(format!( 306 - "unable to determine authorization server for PDS: {pds}" 307 - ))); 529 + return Err(ResolverError::protected_resource_metadata( 530 + smol_str::format_smolstr!( 531 + "unable to determine authorization server for PDS: {pds}" 532 + ), 533 + )); 308 534 } 309 535 &servers[0] 310 536 } 311 537 _ => { 312 - return Err(ResolverError::ProtectedResourceMetadata(format!( 313 - "no authorization server found for PDS: {pds}" 314 - ))); 538 + return Err(ResolverError::protected_resource_metadata( 539 + smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), 540 + )); 315 541 } 316 542 }; 317 543 let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; ··· 322 548 .strip_suffix('/') 323 549 .unwrap_or(rs_metadata.resource.as_str()); 324 550 if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 325 - return Err(ResolverError::AuthorizationServerMetadata(format!( 326 - "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 327 - rs_metadata.resource, protected_resources 328 - ))); 551 + return Err(ResolverError::authorization_server_metadata( 552 + smol_str::format_smolstr!( 553 + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 554 + rs_metadata.resource, 555 + protected_resources 556 + ), 557 + )); 329 558 } 330 559 } 331 560 ··· 347 576 async fn get_resource_server_metadata_impl<T: OAuthResolver + ?Sized>( 348 577 resolver: &T, 349 578 pds: &Url, 350 - ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 579 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 351 580 let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 352 581 // ATPROTO requires one, and only one, authorization server entry 353 582 // > That document MUST contain a single item in the authorization_servers array. ··· 355 584 let issuer = match &rs_metadata.authorization_servers { 356 585 Some(servers) if !servers.is_empty() => { 357 586 if servers.len() > 1 { 358 - return Err(ResolverError::ProtectedResourceMetadata(format!( 359 - "unable to determine authorization server for PDS: {pds}" 360 - ))); 587 + return Err(ResolverError::protected_resource_metadata( 588 + smol_str::format_smolstr!( 589 + "unable to determine authorization server for PDS: {pds}" 590 + ), 591 + )); 361 592 } 362 593 &servers[0] 363 594 } 364 595 _ => { 365 - return Err(ResolverError::ProtectedResourceMetadata(format!( 366 - "no authorization server found for PDS: {pds}" 367 - ))); 596 + return Err(ResolverError::protected_resource_metadata( 597 + smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), 598 + )); 368 599 } 369 600 }; 370 601 let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; ··· 375 606 .strip_suffix('/') 376 607 .unwrap_or(rs_metadata.resource.as_str()); 377 608 if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 378 - return Err(ResolverError::AuthorizationServerMetadata(format!( 379 - "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 380 - rs_metadata.resource, protected_resources 381 - ))); 609 + return Err(ResolverError::authorization_server_metadata( 610 + smol_str::format_smolstr!( 611 + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 612 + rs_metadata.resource, 613 + protected_resources 614 + ), 615 + )); 382 616 } 383 617 } 384 618 ··· 403 637 &self, 404 638 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 405 639 sub: &Did<'_>, 406 - ) -> impl Future<Output = Result<Url, ResolverError>> + Send 640 + ) -> impl Future<Output = Result<Url>> + Send 407 641 where 408 642 Self: Sync, 409 643 { ··· 415 649 &self, 416 650 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 417 651 sub: &Did<'_>, 418 - ) -> impl Future<Output = Result<Url, ResolverError>> { 652 + ) -> impl Future<Output = Result<Url>> { 419 653 verify_issuer_impl(self, server_metadata, sub) 420 654 } 421 655 ··· 424 658 &self, 425 659 input: &str, 426 660 ) -> impl Future< 427 - Output = Result< 428 - ( 429 - OAuthAuthorizationServerMetadata<'static>, 430 - Option<DidDocument<'static>>, 431 - ), 432 - ResolverError, 433 - >, 661 + Output = Result<( 662 + OAuthAuthorizationServerMetadata<'static>, 663 + Option<DidDocument<'static>>, 664 + )>, 434 665 > + Send 435 666 where 436 667 Self: Sync, ··· 443 674 &self, 444 675 input: &str, 445 676 ) -> impl Future< 446 - Output = Result< 447 - ( 448 - OAuthAuthorizationServerMetadata<'static>, 449 - Option<DidDocument<'static>>, 450 - ), 451 - ResolverError, 452 - >, 677 + Output = Result<( 678 + OAuthAuthorizationServerMetadata<'static>, 679 + Option<DidDocument<'static>>, 680 + )>, 453 681 > { 454 682 resolve_oauth_impl(self, input) 455 683 } ··· 458 686 fn resolve_from_service( 459 687 &self, 460 688 input: &Url, 461 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send 689 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 462 690 where 463 691 Self: Sync, 464 692 { ··· 469 697 fn resolve_from_service( 470 698 &self, 471 699 input: &Url, 472 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> 473 - { 700 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 474 701 resolve_from_service_impl(self, input) 475 702 } 476 703 ··· 479 706 &self, 480 707 input: &str, 481 708 ) -> impl Future< 482 - Output = Result< 483 - ( 484 - OAuthAuthorizationServerMetadata<'static>, 485 - DidDocument<'static>, 486 - ), 487 - ResolverError, 488 - >, 709 + Output = Result<( 710 + OAuthAuthorizationServerMetadata<'static>, 711 + DidDocument<'static>, 712 + )>, 489 713 > + Send 490 714 where 491 715 Self: Sync, ··· 498 722 &self, 499 723 input: &str, 500 724 ) -> impl Future< 501 - Output = Result< 502 - ( 503 - OAuthAuthorizationServerMetadata<'static>, 504 - DidDocument<'static>, 505 - ), 506 - ResolverError, 507 - >, 725 + Output = Result<( 726 + OAuthAuthorizationServerMetadata<'static>, 727 + DidDocument<'static>, 728 + )>, 508 729 > { 509 730 resolve_from_identity_impl(self, input) 510 731 } ··· 513 734 fn get_authorization_server_metadata( 514 735 &self, 515 736 issuer: &Url, 516 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send 737 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 517 738 where 518 739 Self: Sync, 519 740 { ··· 524 745 fn get_authorization_server_metadata( 525 746 &self, 526 747 issuer: &Url, 527 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> 528 - { 748 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 529 749 get_authorization_server_metadata_impl(self, issuer) 530 750 } 531 751 ··· 533 753 fn get_resource_server_metadata( 534 754 &self, 535 755 pds: &Url, 536 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send 756 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 537 757 where 538 758 Self: Sync, 539 759 { ··· 544 764 fn get_resource_server_metadata( 545 765 &self, 546 766 pds: &Url, 547 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> 548 - { 767 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 549 768 get_resource_server_metadata_impl(self, pds) 550 769 } 551 770 } ··· 553 772 pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 554 773 client: &T, 555 774 server: &Url, 556 - ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 775 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 557 776 let url = server 558 777 .join("/.well-known/oauth-authorization-server") 559 - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 778 + .map_err(|e| ResolverError::transport(e))?; 560 779 561 780 let req = Request::builder() 562 781 .uri(url.to_string()) 563 782 .body(Vec::new()) 564 - .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 783 + .map_err(|e| ResolverError::transport(e))?; 565 784 let res = client 566 785 .send_http(req) 567 786 .await 568 - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 787 + .map_err(|e| ResolverError::transport(e))?; 569 788 if res.status() == StatusCode::OK { 570 - let mut metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body()) 571 - .map_err(ResolverError::SerdeJson)?; 789 + let mut metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body())?; 572 790 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 573 791 // Accept semantically equivalent issuer (normalize to the requested URL form) 574 792 if issuer_equivalent(&metadata.issuer, server.as_str()) { 575 793 metadata.issuer = server.as_str().into(); 576 794 Ok(metadata.into_static()) 577 795 } else { 578 - Err(ResolverError::AuthorizationServerMetadata(format!( 579 - "invalid issuer: {}", 580 - metadata.issuer 581 - ))) 796 + Err(ResolverError::authorization_server_metadata( 797 + smol_str::format_smolstr!("invalid issuer: {}", metadata.issuer), 798 + )) 582 799 } 583 800 } else { 584 - Err(ResolverError::HttpStatus(res.status())) 801 + Err(ResolverError::http_status(res.status())) 585 802 } 586 803 } 587 804 588 805 pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 589 806 client: &T, 590 807 server: &Url, 591 - ) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> { 808 + ) -> Result<OAuthProtectedResourceMetadata<'static>> { 592 809 let url = server 593 810 .join("/.well-known/oauth-protected-resource") 594 - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 811 + .map_err(|e| ResolverError::transport(e))?; 595 812 596 813 let req = Request::builder() 597 814 .uri(url.to_string()) 598 815 .body(Vec::new()) 599 - .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 816 + .map_err(|e| ResolverError::transport(e))?; 600 817 let res = client 601 818 .send_http(req) 602 819 .await 603 - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 820 + .map_err(|e| ResolverError::transport(e))?; 604 821 if res.status() == StatusCode::OK { 605 - let mut metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body()) 606 - .map_err(ResolverError::SerdeJson)?; 822 + let mut metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body())?; 607 823 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 608 824 // Accept semantically equivalent resource URL (normalize to the requested URL form) 609 825 if issuer_equivalent(&metadata.resource, server.as_str()) { 610 826 metadata.resource = server.as_str().into(); 611 827 Ok(metadata.into_static()) 612 828 } else { 613 - Err(ResolverError::AuthorizationServerMetadata(format!( 614 - "invalid resource: {}", 615 - metadata.resource 616 - ))) 829 + Err(ResolverError::authorization_server_metadata( 830 + smol_str::format_smolstr!("invalid resource: {}", metadata.resource), 831 + )) 617 832 } 618 833 } else { 619 - Err(ResolverError::HttpStatus(res.status())) 834 + Err(ResolverError::http_status(res.status())) 620 835 } 621 836 } 622 837 ··· 662 877 let err = super::resolve_authorization_server(&client, &issuer) 663 878 .await 664 879 .unwrap_err(); 665 - matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND)); 880 + assert!(matches!( 881 + err.kind(), 882 + ResolverErrorKind::HttpStatus(StatusCode::NOT_FOUND) 883 + )); 666 884 } 667 885 668 886 #[tokio::test] ··· 678 896 let err = super::resolve_authorization_server(&client, &issuer) 679 897 .await 680 898 .unwrap_err(); 681 - matches!(err, ResolverError::SerdeJson(_)); 899 + assert!(matches!(err.kind(), ResolverErrorKind::SerdeJson)); 682 900 } 683 901 684 902 #[test]
+1 -1
crates/jacquard-oauth/src/session.rs
··· 263 263 server_metadata: client 264 264 .get_authorization_server_metadata(&self.session_data.authserver_url) 265 265 .await 266 - .map_err(|e| Error::ServerAgent(crate::request::RequestError::ResolverError(e)))?, 266 + .map_err(|e| Error::ServerAgent(crate::request::RequestError::resolver(e)))?, 267 267 client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset) 268 268 .unwrap() 269 269 .into_static(),
+318 -406
crates/jacquard/src/client.rs
··· 18 18 19 19 /// App-password session implementation with auto-refresh 20 20 pub mod credential_session; 21 + /// Agent error type 22 + pub mod error; 21 23 /// Token storage and on-disk persistence formats 22 24 pub mod token; 23 25 /// Trait for fetch-modify-put patterns on array-based endpoints 24 26 pub mod vec_update; 25 27 28 + use crate::client::credential_session::{CredentialSession, SessionKey}; 29 + use crate::client::vec_update::VecUpdate; 26 30 use core::future::Future; 27 - use jacquard_common::error::TransportError; 28 - pub use jacquard_common::error::{ClientError, XrpcResult}; 31 + pub use error::*; 32 + #[cfg(feature = "api")] 33 + use jacquard_api::com_atproto::{ 34 + repo::{ 35 + create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, 36 + get_record::GetRecordResponse, put_record::PutRecordOutput, 37 + }, 38 + server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput}, 39 + }; 40 + use jacquard_common::error::XrpcResult; 41 + pub use jacquard_common::error::{ClientError, XrpcResult as ClientResult}; 29 42 use jacquard_common::http_client::HttpClient; 30 43 pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError}; 31 44 use jacquard_common::types::blob::{Blob, MimeType}; ··· 49 62 use jacquard_oauth::client::OAuthSession; 50 63 use jacquard_oauth::dpop::DpopExt; 51 64 use jacquard_oauth::resolver::OAuthResolver; 52 - 53 65 use serde::Serialize; 66 + #[cfg(feature = "api")] 67 + use std::marker::Send; 68 + use std::option::Option; 54 69 pub use token::FileAuthStore; 55 70 56 - use crate::client::credential_session::{CredentialSession, SessionKey}; 57 - use crate::client::vec_update::VecUpdate; 71 + /// Identifies the active authentication mode for an agent/session. 72 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 73 + pub enum AgentKind { 74 + /// App password (Bearer) session 75 + AppPassword, 76 + /// OAuth (DPoP) session 77 + OAuth, 78 + } 58 79 59 - use jacquard_common::error::{AuthError, DecodeError}; 60 - use jacquard_common::types::nsid::Nsid; 61 - use jacquard_common::xrpc::GenericXrpcError; 80 + /// Common interface for stateful sessions used by the Agent wrapper. 81 + /// 82 + /// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP). 83 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 84 + pub trait AgentSession: XrpcClient + HttpClient + Send + Sync { 85 + /// Identify the kind of session. 86 + fn session_kind(&self) -> AgentKind; 87 + /// Return current DID and an optional session id (always Some for OAuth). 88 + fn session_info(&self) 89 + -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>>; 90 + /// Current base endpoint. 91 + fn endpoint(&self) -> impl Future<Output = url::Url>; 92 + /// Override per-session call options. 93 + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()>; 94 + /// Refresh the session and return a fresh AuthorizationToken. 95 + fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>>; 96 + } 62 97 63 - /// Error type for Agent convenience methods 64 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 65 - pub enum AgentError { 66 - /// Transport/network layer failure 67 - #[error(transparent)] 68 - #[diagnostic(transparent)] 69 - Client(#[from] ClientError), 98 + /// Alias for an agent over a credential (app‑password) session. 99 + pub type CredentialAgent<S, T> = Agent<CredentialSession<S, T>>; 100 + /// Alias for an agent over an OAuth (DPoP) session. 101 + pub type OAuthAgent<T, S> = Agent<OAuthSession<T, S>>; 70 102 71 - /// No session available for operations requiring authentication 72 - #[error("No session available - cannot determine repo")] 73 - NoSession, 103 + /// BasicClient: in-memory store + public resolver over a credential session. 104 + pub type BasicClient = Agent< 105 + CredentialSession< 106 + MemorySessionStore<SessionKey, AtpSession>, 107 + jacquard_identity::PublicResolver, 108 + >, 109 + >; 74 110 75 - /// Authentication error from XRPC layer 76 - #[error("Authentication error: {0}")] 77 - #[diagnostic(transparent)] 78 - Auth( 79 - #[from] 80 - #[diagnostic_source] 81 - AuthError, 82 - ), 111 + impl BasicClient { 112 + /// Create an unauthenticated BasicClient for public API access. 113 + /// 114 + /// Uses an in-memory session store and public resolver. Suitable for 115 + /// read-only operations on public data without authentication. 116 + /// 117 + /// # Example 118 + /// 119 + /// ```no_run 120 + /// # use jacquard::client::BasicClient; 121 + /// # use jacquard::types::string::AtUri; 122 + /// # use jacquard_api::app_bsky::feed::post::Post; 123 + /// use crate::jacquard::client::AgentSessionExt; 124 + /// # #[tokio::main] 125 + /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 126 + /// let client = BasicClient::unauthenticated(); 127 + /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); 128 + /// let response = client.get_record::<Post<'_>>(&uri).await?; 129 + /// # Ok(()) 130 + /// # } 131 + /// ``` 132 + pub fn unauthenticated() -> Self { 133 + use std::sync::Arc; 134 + let http = reqwest::Client::new(); 135 + let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); 136 + let store = MemorySessionStore::default(); 137 + let session = CredentialSession::new(Arc::new(store), Arc::new(resolver)); 138 + Agent::new(session) 139 + } 140 + } 83 141 84 - /// Generic XRPC error (InvalidRequest, etc.) 85 - #[error("XRPC error: {0}")] 86 - Generic(GenericXrpcError), 142 + impl Default for BasicClient { 143 + fn default() -> Self { 144 + Self::unauthenticated() 145 + } 146 + } 87 147 88 - /// Response deserialization failed 89 - #[error("Failed to decode response: {0}")] 90 - #[diagnostic(transparent)] 91 - Decode( 92 - #[from] 93 - #[diagnostic_source] 94 - DecodeError, 95 - ), 148 + /// MemoryCredentialSession: credential session with in memory store and identity resolver 149 + pub type MemoryCredentialSession = CredentialSession< 150 + MemorySessionStore<SessionKey, AtpSession>, 151 + jacquard_identity::PublicResolver, 152 + >; 96 153 97 - /// Record operation failed with typed error from endpoint 98 - /// Context: which repo/collection/rkey we were operating on 99 - #[error("Record operation failed on {collection}/{rkey:?} in repo {repo}: {error}")] 100 - RecordOperation { 101 - /// The repository DID 102 - repo: Did<'static>, 103 - /// The collection NSID 104 - collection: Nsid<'static>, 105 - /// The record key 106 - rkey: RecordKey<Rkey<'static>>, 107 - /// The underlying error 108 - error: Box<dyn std::error::Error + Send + Sync>, 109 - }, 154 + impl MemoryCredentialSession { 155 + /// Create an unauthenticated MemoryCredentialSession. 156 + /// 157 + /// Uses an in memory store and a public resolver. 158 + /// Equivalent to a BasicClient that isn't wrapped in Agent 159 + pub fn unauthenticated() -> Self { 160 + use std::sync::Arc; 161 + let http = reqwest::Client::new(); 162 + let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); 163 + let store = MemorySessionStore::default(); 164 + CredentialSession::new(Arc::new(store), Arc::new(resolver)) 165 + } 110 166 111 - /// Multi-step operation failed at sub-step (e.g., get failed in update_record) 112 - #[error("Operation failed at step '{step}': {error}")] 113 - SubOperation { 114 - /// Description of which step failed 115 - step: &'static str, 116 - /// The underlying error 117 - error: Box<dyn std::error::Error + Send + Sync>, 118 - }, 167 + /// Create a MemoryCredentialSession and authenticate with the provided details 168 + /// 169 + /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL. 170 + /// - `session_id`: optional session label; defaults to "session". 171 + /// - Persists and activates the session, and updates the base endpoint to the user's PDS. 172 + /// 173 + /// # Example 174 + /// ```no_run 175 + /// # use jacquard::client::BasicClient; 176 + /// # use jacquard::types::string::AtUri; 177 + /// # use jacquard::api::app_bsky::feed::post::Post; 178 + /// # use jacquard::types::string::Datetime; 179 + /// # use jacquard::CowStr; 180 + /// use jacquard::client::MemoryCredentialSession; 181 + /// use jacquard::client::{Agent, AgentSessionExt}; 182 + /// # #[tokio::main] 183 + /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 184 + /// # let (identifier, password, post_text): (CowStr<'_>, CowStr<'_>, CowStr<'_>) = todo!(); 185 + /// let (session, _) = MemoryCredentialSession::authenticated(identifier, password, None).await?; 186 + /// let agent = Agent::from(session); 187 + /// let post = Post::builder().text(post_text).created_at(Datetime::now()).build(); 188 + /// let output = agent.create_record(post, None).await?; 189 + /// # Ok(()) 190 + /// # } 191 + /// ``` 192 + pub async fn authenticated( 193 + identifier: CowStr<'_>, 194 + password: CowStr<'_>, 195 + session_id: Option<CowStr<'_>>, 196 + ) -> ClientResult<(Self, AtpSession)> { 197 + let session = MemoryCredentialSession::unauthenticated(); 198 + let auth = session 199 + .login(identifier, password, session_id, None, None) 200 + .await?; 201 + Ok((session, auth)) 202 + } 119 203 } 120 204 121 - impl IntoStatic for AgentError { 122 - type Output = AgentError; 123 - 124 - fn into_static(self) -> Self::Output { 125 - match self { 126 - AgentError::RecordOperation { 127 - repo, 128 - collection, 129 - rkey, 130 - error, 131 - } => AgentError::RecordOperation { 132 - repo: repo.into_static(), 133 - collection: collection.into_static(), 134 - rkey: rkey.into_static(), 135 - error, 136 - }, 137 - AgentError::SubOperation { step, error } => AgentError::SubOperation { step, error }, 138 - // Error types are already 'static 139 - AgentError::Client(e) => AgentError::Client(e), 140 - AgentError::NoSession => AgentError::NoSession, 141 - AgentError::Auth(e) => AgentError::Auth(e), 142 - AgentError::Generic(e) => AgentError::Generic(e), 143 - AgentError::Decode(e) => AgentError::Decode(e), 144 - } 205 + impl Default for MemoryCredentialSession { 206 + fn default() -> Self { 207 + MemoryCredentialSession::unauthenticated() 145 208 } 146 209 } 147 210 ··· 184 247 } 185 248 } 186 249 187 - /// Identifies the active authentication mode for an agent/session. 188 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 189 - pub enum AgentKind { 190 - /// App password (Bearer) session 191 - AppPassword, 192 - /// OAuth (DPoP) session 193 - OAuth, 194 - } 195 - 196 - /// Common interface for stateful sessions used by the Agent wrapper. 197 - /// 198 - /// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP). 199 - #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 200 - pub trait AgentSession: XrpcClient + HttpClient + Send + Sync { 201 - /// Identify the kind of session. 202 - fn session_kind(&self) -> AgentKind; 203 - /// Return current DID and an optional session id (always Some for OAuth). 204 - fn session_info(&self) 205 - -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>>; 206 - /// Current base endpoint. 207 - fn endpoint(&self) -> impl Future<Output = url::Url>; 208 - /// Override per-session call options. 209 - fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()>; 210 - /// Refresh the session and return a fresh AuthorizationToken. 211 - fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>>; 212 - } 213 - 214 - impl<S, T, W> AgentSession for CredentialSession<S, T, W> 215 - where 216 - S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static, 217 - T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static, 218 - W: Send + Sync, 219 - { 220 - fn session_kind(&self) -> AgentKind { 221 - AgentKind::AppPassword 222 - } 223 - fn session_info( 224 - &self, 225 - ) -> impl Future< 226 - Output = std::option::Option<( 227 - jacquard_common::types::did::Did<'static>, 228 - std::option::Option<CowStr<'static>>, 229 - )>, 230 - > { 231 - async move { 232 - CredentialSession::<S, T, W>::session_info(self) 233 - .await 234 - .map(|(did, sid)| (did, Some(sid))) 235 - } 236 - } 237 - fn endpoint(&self) -> impl Future<Output = url::Url> { 238 - async move { CredentialSession::<S, T, W>::endpoint(self).await } 239 - } 240 - fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> { 241 - async move { CredentialSession::<S, T, W>::set_options(self, opts).await } 242 - } 243 - fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> { 244 - async move { 245 - Ok(CredentialSession::<S, T, W>::refresh(self) 246 - .await? 247 - .into_static()) 248 - } 249 - } 250 - } 251 - 252 - impl<T, S, W> AgentSession for OAuthSession<T, S, W> 253 - where 254 - S: ClientAuthStore + Send + Sync + 'static, 255 - T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, 256 - W: Send + Sync, 257 - { 258 - fn session_kind(&self) -> AgentKind { 259 - AgentKind::OAuth 260 - } 261 - fn session_info( 262 - &self, 263 - ) -> impl Future< 264 - Output = std::option::Option<( 265 - jacquard_common::types::did::Did<'static>, 266 - std::option::Option<CowStr<'static>>, 267 - )>, 268 - > { 269 - async { 270 - let (did, sid) = OAuthSession::<T, S, W>::session_info(self).await; 271 - Some((did.into_static(), Some(sid.into_static()))) 272 - } 273 - } 274 - fn endpoint(&self) -> impl Future<Output = url::Url> { 275 - async { self.endpoint().await } 276 - } 277 - fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> { 278 - async { self.set_options(opts).await } 279 - } 280 - fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> { 281 - async { 282 - self.refresh() 283 - .await 284 - .map(|t| t.into_static()) 285 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e)))) 286 - } 287 - } 288 - } 289 - 290 250 /// Thin wrapper over a stateful session providing a uniform `XrpcClient`. 291 251 pub struct Agent<A: AgentSession> { 292 252 inner: A, ··· 319 279 } 320 280 321 281 /// Refresh the session and return a fresh token. 322 - pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> { 282 + pub async fn refresh(&self) -> ClientResult<AuthorizationToken<'static>> { 323 283 self.inner.refresh().await 324 284 } 325 285 } 326 286 327 - #[cfg(feature = "api")] 328 - use jacquard_api::com_atproto::{ 329 - repo::{ 330 - create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, 331 - get_record::GetRecordResponse, put_record::PutRecordOutput, 332 - }, 333 - server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput}, 334 - }; 335 - 336 - /// doc 287 + /// Output type for a collection record retrieval operation 337 288 pub type CollectionOutput<'a, R> = <<R as Collection>::Record as XrpcResp>::Output<'a>; 338 - /// doc 289 + /// Error type for a collection record retrieval operation 339 290 pub type CollectionErr<'a, R> = <<R as Collection>::Record as XrpcResp>::Err<'a>; 340 - /// doc 291 + /// Response type for the get request of a vec update operation 341 292 pub type VecGetResponse<U> = <<U as VecUpdate>::GetRequest as XrpcRequest>::Response; 342 - /// doc 293 + /// Response type for the put request of a vec update operation 343 294 pub type VecPutResponse<U> = <<U as VecUpdate>::PutRequest as XrpcRequest>::Response; 295 + 296 + type CollectionError<'a, R> = <<R as Collection>::Record as XrpcResp>::Err<'a>; 297 + 298 + type VecUpdateGetError<'a, U> = 299 + <<<U as VecUpdate>::GetRequest as XrpcRequest>::Response as XrpcResp>::Err<'a>; 300 + 301 + type VecUpdatePutError<'a, U> = 302 + <<<U as VecUpdate>::PutRequest as XrpcRequest>::Response as XrpcResp>::Err<'a>; 344 303 345 304 /// Extension trait providing convenience methods for common repository operations. 346 305 /// ··· 423 382 &self, 424 383 record: R, 425 384 rkey: Option<RecordKey<Rkey<'_>>>, 426 - ) -> impl Future<Output = Result<CreateRecordOutput<'static>, AgentError>> 385 + ) -> impl Future<Output = Result<CreateRecordOutput<'static>>> 427 386 where 428 387 R: Collection + serde::Serialize, 429 388 { ··· 435 394 use jacquard_common::types::ident::AtIdentifier; 436 395 use jacquard_common::types::value::to_data; 437 396 438 - let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; 397 + let (did, _) = self 398 + .session_info() 399 + .await 400 + .ok_or_else(AgentError::no_session)?; 439 401 440 - let data = to_data(&record).map_err(|e| AgentError::SubOperation { 441 - step: "serialize record", 442 - error: Box::new(e), 443 - })?; 402 + let data = 403 + to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; 444 404 445 405 let request = CreateRecord::new() 446 406 .repo(AtIdentifier::Did(did)) ··· 451 411 452 412 let response = self.send(request).await?; 453 413 response.into_output().map_err(|e| match e { 454 - XrpcError::Auth(auth) => AgentError::Auth(auth), 455 - XrpcError::Generic(g) => AgentError::Generic(g), 456 - XrpcError::Decode(e) => AgentError::Decode(e), 457 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 458 - step: "create record", 459 - error: Box::new(typed), 460 - }, 414 + XrpcError::Auth(auth) => AgentError::from(auth), 415 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 416 + XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed), 461 417 }) 462 418 } 463 419 } ··· 491 447 fn get_record<R>( 492 448 &self, 493 449 uri: &AtUri<'_>, 494 - ) -> impl Future<Output = Result<Response<R::Record>, ClientError>> 450 + ) -> impl Future<Output = ClientResult<Response<R::Record>>> 495 451 where 496 452 R: Collection, 497 453 { ··· 503 459 // Validate that URI's collection matches the expected type 504 460 if let Some(uri_collection) = uri.collection() { 505 461 if uri_collection.as_str() != R::nsid().as_str() { 506 - return Err(ClientError::Transport(TransportError::Other( 507 - format!( 462 + return Err(ClientError::invalid_request(format!( 508 463 "Collection mismatch: URI contains '{}' but type parameter expects '{}'", 509 464 uri_collection, 510 465 R::nsid() 511 - ) 512 - .into(), 513 - ))); 466 + )) 467 + .with_help("ensure the URI collection matches the record type")); 514 468 } 515 469 } 516 470 517 471 let rkey = uri.rkey().ok_or_else(|| { 518 - ClientError::Transport(TransportError::Other("AtUri missing rkey".into())) 472 + ClientError::invalid_request("AtUri missing rkey") 473 + .with_help("ensure the URI includes a record key after the collection") 519 474 })?; 520 475 521 476 // Resolve authority (DID or handle) to get DID and PDS ··· 523 478 let (repo_did, pds_url) = match uri.authority() { 524 479 AtIdentifier::Did(did) => { 525 480 let pds = self.pds_for_did(did).await.map_err(|e| { 526 - ClientError::Transport(TransportError::Other( 527 - format!("Failed to resolve PDS for {}: {}", did, e).into(), 528 - )) 481 + ClientError::from(e) 482 + .with_context("DID document resolution failed during record retrieval") 529 483 })?; 530 484 (did.clone(), pds) 531 485 } 532 486 AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 533 - ClientError::Transport(TransportError::Other( 534 - format!("Failed to resolve handle {}: {}", handle, e).into(), 535 - )) 487 + ClientError::from(e) 488 + .with_context("handle resolution failed during record retrieval") 536 489 })?, 537 490 }; 538 491 ··· 545 498 .build(); 546 499 547 500 let response: Response<GetRecordResponse> = { 548 - let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) 549 - .map_err(|e| ClientError::Transport(TransportError::from(e)))?; 501 + let http_request = 502 + xrpc::build_http_request(&pds_url, &request, &self.opts().await)?; 550 503 551 504 let http_response = self 552 505 .send_http(http_request) 553 506 .await 554 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 507 + .map_err(|e| ClientError::transport(e))?; 555 508 556 509 xrpc::process_response(http_response) 557 510 }?; ··· 566 519 fn fetch_record<R>( 567 520 &self, 568 521 uri: &RecordUri<'_, R>, 569 - ) -> impl Future<Output = Result<CollectionOutput<'static, R>, ClientError>> 522 + ) -> impl Future<Output = Result<CollectionOutput<'static, R>>> 570 523 where 571 524 R: Collection, 572 525 for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>, 573 - for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>>, 526 + for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>> + Send + Sync, 574 527 { 575 528 let uri = uri.as_uri(); 576 529 async move { 577 530 let response = self.get_record::<R>(uri).await?; 578 531 let response: Response<R::Record> = response.transmute(); 579 - let output = response 580 - .into_output() 581 - .map_err(|e| ClientError::Transport(TransportError::Other(e.to_string().into())))?; 582 - // TODO: fix this to use a better error lol 532 + let output = response.into_output().map_err(|e| match e { 533 + XrpcError::Auth(auth) => AgentError::from(auth), 534 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 535 + XrpcError::Xrpc(typed) => { 536 + AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) 537 + .with_details(typed.to_string()) 538 + } 539 + })?; 583 540 Ok(output) 584 541 } 585 542 } ··· 614 571 &self, 615 572 uri: &AtUri<'_>, 616 573 f: impl FnOnce(&mut R), 617 - ) -> impl Future<Output = Result<PutRecordOutput<'static>, AgentError>> 574 + ) -> impl Future<Output = Result<PutRecordOutput<'static>>> 618 575 where 619 576 R: Collection + Serialize, 620 577 R: for<'a> From<CollectionOutput<'a, R>>, 578 + for<'a> <CollectionError<'a, R> as IntoStatic>::Output: 579 + IntoStatic + std::error::Error + Send + Sync, 580 + for<'a> CollectionError<'a, R>: Send + Sync + std::error::Error + IntoStatic, 621 581 { 622 582 async move { 623 583 #[cfg(feature = "tracing")] ··· 629 589 630 590 // Parse to get R<'_> borrowing from response buffer 631 591 let record = response.parse().map_err(|e| match e { 632 - XrpcError::Auth(auth) => AgentError::Auth(auth), 633 - XrpcError::Generic(g) => AgentError::Generic(g), 634 - XrpcError::Decode(e) => AgentError::Decode(e), 635 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 636 - step: "get record", 637 - error: format!("{:?}", typed).into(), 638 - }, 592 + XrpcError::Auth(auth) => AgentError::from(auth), 593 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 594 + XrpcError::Xrpc(typed) => { 595 + AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) 596 + .with_details(typed.to_string()) 597 + } 639 598 })?; 640 599 641 600 // Convert to owned ··· 647 606 // Put it back 648 607 let rkey = uri 649 608 .rkey() 650 - .ok_or(AgentError::SubOperation { 651 - step: "extract rkey", 652 - error: "AtUri missing rkey".into(), 609 + .ok_or_else(|| { 610 + AgentError::sub_operation( 611 + "extract rkey", 612 + std::io::Error::new(std::io::ErrorKind::InvalidInput, "AtUri missing rkey"), 613 + ) 653 614 })? 654 615 .clone() 655 616 .into_static(); ··· 664 625 fn delete_record<R>( 665 626 &self, 666 627 rkey: RecordKey<Rkey<'_>>, 667 - ) -> impl Future<Output = Result<DeleteRecordOutput<'static>, AgentError>> 628 + ) -> impl Future<Output = Result<DeleteRecordOutput<'static>>> 668 629 where 669 630 R: Collection, 670 631 { ··· 675 636 use jacquard_api::com_atproto::repo::delete_record::DeleteRecord; 676 637 use jacquard_common::types::ident::AtIdentifier; 677 638 678 - let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; 639 + let (did, _) = self 640 + .session_info() 641 + .await 642 + .ok_or_else(AgentError::no_session)?; 679 643 680 644 let request = DeleteRecord::new() 681 645 .repo(AtIdentifier::Did(did)) ··· 685 649 686 650 let response = self.send(request).await?; 687 651 response.into_output().map_err(|e| match e { 688 - XrpcError::Auth(auth) => AgentError::Auth(auth), 689 - XrpcError::Generic(g) => AgentError::Generic(g), 690 - XrpcError::Decode(e) => AgentError::Decode(e), 691 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 692 - step: "delete record", 693 - error: Box::new(typed), 694 - }, 652 + XrpcError::Auth(auth) => AgentError::from(auth), 653 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 654 + XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed), 695 655 }) 696 656 } 697 657 } ··· 704 664 &self, 705 665 rkey: RecordKey<Rkey<'static>>, 706 666 record: R, 707 - ) -> impl Future<Output = Result<PutRecordOutput<'static>, AgentError>> 667 + ) -> impl Future<Output = Result<PutRecordOutput<'static>>> 708 668 where 709 669 R: Collection + serde::Serialize, 710 670 { ··· 716 676 use jacquard_common::types::ident::AtIdentifier; 717 677 use jacquard_common::types::value::to_data; 718 678 719 - let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; 679 + let (did, _) = self 680 + .session_info() 681 + .await 682 + .ok_or_else(AgentError::no_session)?; 720 683 721 - let data = to_data(&record).map_err(|e| AgentError::SubOperation { 722 - step: "serialize record", 723 - error: Box::new(e), 724 - })?; 684 + let data = 685 + to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; 725 686 726 687 let request = PutRecord::new() 727 688 .repo(AtIdentifier::Did(did)) ··· 732 693 733 694 let response = self.send(request).await?; 734 695 response.into_output().map_err(|e| match e { 735 - XrpcError::Auth(auth) => AgentError::Auth(auth), 736 - XrpcError::Generic(g) => AgentError::Generic(g), 737 - XrpcError::Decode(e) => AgentError::Decode(e), 738 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 739 - step: "put record", 740 - error: Box::new(typed), 741 - }, 696 + XrpcError::Auth(auth) => AgentError::from(auth), 697 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 698 + XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed), 742 699 }) 743 700 } 744 701 } ··· 767 724 &self, 768 725 data: impl Into<bytes::Bytes>, 769 726 mime_type: MimeType<'_>, 770 - ) -> impl Future<Output = Result<Blob<'static>, AgentError>> { 727 + ) -> impl Future<Output = Result<Blob<'static>>> { 771 728 async move { 772 729 #[cfg(feature = "tracing")] 773 730 let _span = tracing::debug_span!("upload_blob", mime_type = %mime_type).entered(); ··· 783 740 784 741 opts.extra_headers.push(( 785 742 CONTENT_TYPE, 786 - http::HeaderValue::from_str(mime_type.as_str()).map_err(|e| { 787 - AgentError::SubOperation { 788 - step: "set Content-Type header", 789 - error: Box::new(e), 790 - } 791 - })?, 743 + http::HeaderValue::from_str(mime_type.as_str()) 744 + .map_err(|e| AgentError::sub_operation("set Content-Type header", e))?, 792 745 )); 793 746 let response = self.send_with_opts(request, opts).await?; 794 747 let debug: serde_json::Value = serde_json::from_slice(response.buffer()).unwrap(); 795 748 println!("json: {}", serde_json::to_string_pretty(&debug).unwrap()); 796 749 let output = response.into_output().map_err(|e| match e { 797 - XrpcError::Auth(auth) => AgentError::Auth(auth), 798 - XrpcError::Generic(g) => AgentError::Generic(g), 799 - XrpcError::Decode(e) => AgentError::Decode(e), 800 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 801 - step: "upload blob", 802 - error: Box::new(typed), 803 - }, 750 + XrpcError::Auth(auth) => AgentError::from(auth), 751 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 752 + XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed), 804 753 })?; 805 754 Ok(output.blob.blob().clone().into_static()) 806 755 } ··· 822 771 fn update_vec<U>( 823 772 &self, 824 773 modify: impl FnOnce(&mut Vec<<U as VecUpdate>::Item>), 825 - ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>, AgentError>> 774 + ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>>> 826 775 where 827 776 U: VecUpdate, 828 777 <U as VecUpdate>::PutRequest: Send + Sync, 829 778 <U as VecUpdate>::GetRequest: Send + Sync, 830 779 VecGetResponse<U>: Send + Sync, 831 780 VecPutResponse<U>: Send + Sync, 781 + for<'a> VecUpdateGetError<'a, U>: Send + Sync + std::error::Error + IntoStatic, 782 + for<'a> VecUpdatePutError<'a, U>: Send + Sync + std::error::Error + IntoStatic, 783 + for<'a> <VecUpdateGetError<'a, U> as IntoStatic>::Output: 784 + Send + Sync + std::error::Error + IntoStatic + 'static, 785 + for<'a> <VecUpdatePutError<'a, U> as IntoStatic>::Output: 786 + Send + Sync + std::error::Error + IntoStatic + 'static, 832 787 { 833 788 async { 834 789 // Fetch current data 835 790 let get_request = U::build_get(); 836 791 let response = self.send(get_request).await?; 837 792 let output = response.parse().map_err(|e| match e { 838 - XrpcError::Auth(auth) => AgentError::Auth(auth), 839 - XrpcError::Generic(g) => AgentError::Generic(g), 840 - XrpcError::Decode(e) => AgentError::Decode(e), 841 - XrpcError::Xrpc(_) => AgentError::SubOperation { 842 - step: "get vec", 843 - error: format!("{:?}", e).into(), 844 - }, 793 + XrpcError::Auth(auth) => AgentError::from(auth), 794 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 795 + XrpcError::Xrpc(typed) => { 796 + AgentError::sub_operation("update vec", typed.into_static()) 797 + } 845 798 })?; 846 799 847 800 // Extract vec (converts to owned via IntoStatic) ··· 872 825 fn update_vec_item<U>( 873 826 &self, 874 827 item: <U as VecUpdate>::Item, 875 - ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>, AgentError>> 828 + ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>>> 876 829 where 877 830 U: VecUpdate, 878 831 <U as VecUpdate>::PutRequest: Send + Sync, 879 832 <U as VecUpdate>::GetRequest: Send + Sync, 880 833 VecGetResponse<U>: Send + Sync, 881 834 VecPutResponse<U>: Send + Sync, 835 + for<'a> VecUpdateGetError<'a, U>: Send + Sync + std::error::Error + IntoStatic, 836 + for<'a> VecUpdatePutError<'a, U>: Send + Sync + std::error::Error + IntoStatic, 837 + for<'a> <VecUpdateGetError<'a, U> as IntoStatic>::Output: 838 + Send + Sync + std::error::Error + IntoStatic + 'static, 839 + for<'a> <VecUpdatePutError<'a, U> as IntoStatic>::Output: 840 + Send + Sync + std::error::Error + IntoStatic + 'static, 882 841 { 883 842 async { 884 843 self.update_vec::<U>(|vec| { ··· 896 855 #[cfg(feature = "api")] 897 856 impl<T: AgentSession + IdentityResolver> AgentSessionExt for T {} 898 857 858 + impl<S, T, W> AgentSession for CredentialSession<S, T, W> 859 + where 860 + S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static, 861 + T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static, 862 + W: Send + Sync, 863 + { 864 + fn session_kind(&self) -> AgentKind { 865 + AgentKind::AppPassword 866 + } 867 + fn session_info( 868 + &self, 869 + ) -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> { 870 + async move { 871 + CredentialSession::<S, T, W>::session_info(self) 872 + .await 873 + .map(|(did, sid)| (did, Some(sid))) 874 + } 875 + } 876 + fn endpoint(&self) -> impl Future<Output = url::Url> { 877 + async move { CredentialSession::<S, T, W>::endpoint(self).await } 878 + } 879 + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> { 880 + async move { CredentialSession::<S, T, W>::set_options(self, opts).await } 881 + } 882 + fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>> { 883 + async move { 884 + Ok(CredentialSession::<S, T, W>::refresh(self) 885 + .await? 886 + .into_static()) 887 + } 888 + } 889 + } 890 + 891 + impl<T, S, W> AgentSession for OAuthSession<T, S, W> 892 + where 893 + S: ClientAuthStore + Send + Sync + 'static, 894 + T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, 895 + W: Send + Sync, 896 + { 897 + fn session_kind(&self) -> AgentKind { 898 + AgentKind::OAuth 899 + } 900 + fn session_info( 901 + &self, 902 + ) -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> { 903 + async { 904 + let (did, sid) = OAuthSession::<T, S, W>::session_info(self).await; 905 + Some((did.into_static(), Some(sid.into_static()))) 906 + } 907 + } 908 + fn endpoint(&self) -> impl Future<Output = url::Url> { 909 + async { self.endpoint().await } 910 + } 911 + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> { 912 + async { self.set_options(opts).await } 913 + } 914 + fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>> { 915 + async { 916 + self.refresh() 917 + .await 918 + .map(|t| t.into_static()) 919 + .map_err(|e| ClientError::transport(e).with_context("OAuth token refresh failed")) 920 + } 921 + } 922 + } 923 + 899 924 impl<A: AgentSession> HttpClient for Agent<A> { 900 925 type Error = <A as HttpClient>::Error; 901 926 ··· 1103 1128 fn resolve_handle( 1104 1129 &self, 1105 1130 handle: &Handle<'_>, 1106 - ) -> impl Future<Output = Result<Did<'static>, IdentityError>> { 1131 + ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> { 1107 1132 async { self.inner.resolve_handle(handle).await } 1108 1133 } 1109 1134 1110 1135 fn resolve_did_doc( 1111 1136 &self, 1112 1137 did: &Did<'_>, 1113 - ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> { 1138 + ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> { 1114 1139 async { self.inner.resolve_did_doc(did).await } 1115 1140 } 1116 1141 } ··· 1134 1159 async { self.set_options(opts).await } 1135 1160 } 1136 1161 1137 - fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> { 1162 + fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>> { 1138 1163 async { self.refresh().await } 1139 1164 } 1140 1165 } ··· 1144 1169 Self::new(inner) 1145 1170 } 1146 1171 } 1147 - 1148 - /// Alias for an agent over a credential (app‑password) session. 1149 - pub type CredentialAgent<S, T> = Agent<CredentialSession<S, T>>; 1150 - /// Alias for an agent over an OAuth (DPoP) session. 1151 - pub type OAuthAgent<T, S> = Agent<OAuthSession<T, S>>; 1152 - 1153 - /// BasicClient: in-memory store + public resolver over a credential session. 1154 - pub type BasicClient = Agent< 1155 - CredentialSession< 1156 - MemorySessionStore<SessionKey, AtpSession>, 1157 - jacquard_identity::PublicResolver, 1158 - >, 1159 - >; 1160 - 1161 - impl BasicClient { 1162 - /// Create an unauthenticated BasicClient for public API access. 1163 - /// 1164 - /// Uses an in-memory session store and public resolver. Suitable for 1165 - /// read-only operations on public data without authentication. 1166 - /// 1167 - /// # Example 1168 - /// 1169 - /// ```no_run 1170 - /// # use jacquard::client::BasicClient; 1171 - /// # use jacquard::types::string::AtUri; 1172 - /// # use jacquard_api::app_bsky::feed::post::Post; 1173 - /// use crate::jacquard::client::AgentSessionExt; 1174 - /// # #[tokio::main] 1175 - /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 1176 - /// let client = BasicClient::unauthenticated(); 1177 - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); 1178 - /// let response = client.get_record::<Post<'_>>(&uri).await?; 1179 - /// # Ok(()) 1180 - /// # } 1181 - /// ``` 1182 - pub fn unauthenticated() -> Self { 1183 - use std::sync::Arc; 1184 - let http = reqwest::Client::new(); 1185 - let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); 1186 - let store = MemorySessionStore::default(); 1187 - let session = CredentialSession::new(Arc::new(store), Arc::new(resolver)); 1188 - Agent::new(session) 1189 - } 1190 - } 1191 - 1192 - impl Default for BasicClient { 1193 - fn default() -> Self { 1194 - Self::unauthenticated() 1195 - } 1196 - } 1197 - 1198 - /// MemoryCredentialSession: credential session with in memory store and identity resolver 1199 - pub type MemoryCredentialSession = CredentialSession< 1200 - MemorySessionStore<SessionKey, AtpSession>, 1201 - jacquard_identity::PublicResolver, 1202 - >; 1203 - 1204 - impl MemoryCredentialSession { 1205 - /// Create an unauthenticated MemoryCredentialSession. 1206 - /// 1207 - /// Uses an in memory store and a public resolver. 1208 - /// Equivalent to a BasicClient that isn't wrapped in Agent 1209 - pub fn unauthenticated() -> Self { 1210 - use std::sync::Arc; 1211 - let http = reqwest::Client::new(); 1212 - let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); 1213 - let store = MemorySessionStore::default(); 1214 - CredentialSession::new(Arc::new(store), Arc::new(resolver)) 1215 - } 1216 - 1217 - /// Create a MemoryCredentialSession and authenticate with the provided details 1218 - /// 1219 - /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL. 1220 - /// - `session_id`: optional session label; defaults to "session". 1221 - /// - Persists and activates the session, and updates the base endpoint to the user's PDS. 1222 - /// 1223 - /// # Example 1224 - /// ```no_run 1225 - /// # use jacquard::client::BasicClient; 1226 - /// # use jacquard::types::string::AtUri; 1227 - /// # use jacquard::api::app_bsky::feed::post::Post; 1228 - /// # use jacquard::types::string::Datetime; 1229 - /// # use jacquard::CowStr; 1230 - /// use jacquard::client::MemoryCredentialSession; 1231 - /// use jacquard::client::{Agent, AgentSessionExt}; 1232 - /// # #[tokio::main] 1233 - /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 1234 - /// # let (identifier, password, post_text): (CowStr<'_>, CowStr<'_>, CowStr<'_>) = todo!(); 1235 - /// let (session, _) = MemoryCredentialSession::authenticated(identifier, password, None).await?; 1236 - /// let agent = Agent::from(session); 1237 - /// let post = Post::builder().text(post_text).created_at(Datetime::now()).build(); 1238 - /// let output = agent.create_record(post, None).await?; 1239 - /// # Ok(()) 1240 - /// # } 1241 - /// ``` 1242 - pub async fn authenticated( 1243 - identifier: CowStr<'_>, 1244 - password: CowStr<'_>, 1245 - session_id: Option<CowStr<'_>>, 1246 - ) -> Result<(Self, AtpSession), ClientError> { 1247 - let session = MemoryCredentialSession::unauthenticated(); 1248 - let auth = session 1249 - .login(identifier, password, session_id, None, None) 1250 - .await?; 1251 - Ok((session, auth)) 1252 - } 1253 - } 1254 - 1255 - impl Default for MemoryCredentialSession { 1256 - fn default() -> Self { 1257 - MemoryCredentialSession::unauthenticated() 1258 - } 1259 - }
+97 -81
crates/jacquard/src/client/credential_session.rs
··· 5 5 }; 6 6 use jacquard_common::{ 7 7 AuthorizationToken, CowStr, IntoStatic, 8 - error::{AuthError, ClientError, TransportError, XrpcResult}, 8 + error::{AuthError, ClientError, XrpcResult}, 9 9 http_client::HttpClient, 10 10 session::SessionStore, 11 11 types::{did::Did, string::Handle}, ··· 144 144 T: HttpClient, 145 145 { 146 146 /// Refresh the active session by calling `com.atproto.server.refreshSession`. 147 - pub async fn refresh(&self) -> Result<AuthorizationToken<'_>, ClientError> { 147 + pub async fn refresh(&self) -> std::result::Result<AuthorizationToken<'_>, ClientError> { 148 148 let key = self 149 149 .key 150 150 .read() 151 151 .await 152 152 .clone() 153 - .ok_or(ClientError::Auth(AuthError::NotAuthenticated))?; 153 + .ok_or_else(|| ClientError::auth(AuthError::NotAuthenticated))?; 154 154 let session = self.store.get(&key).await; 155 155 let endpoint = self.endpoint().await; 156 156 let mut opts = self.options.read().await.clone(); ··· 163 163 .await?; 164 164 let refresh = response 165 165 .parse() 166 - .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?; 166 + .map_err(|_| ClientError::auth(AuthError::RefreshFailed) 167 + .with_help("ensure refresh token is valid and not expired") 168 + .with_url("com.atproto.server.refreshSession"))?; 167 169 168 170 let new_session: AtpSession = refresh.into(); 169 171 let token = AuthorizationToken::Bearer(new_session.access_jwt.clone()); 170 172 self.store 171 173 .set(key, new_session) 172 174 .await 173 - .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?; 175 + .map_err(|e| ClientError::from(e) 176 + .with_context("failed to persist refreshed session to store"))?; 174 177 175 178 Ok(token) 176 179 } ··· 193 196 session_id: Option<CowStr<'_>>, 194 197 allow_takendown: Option<bool>, 195 198 auth_factor_token: Option<CowStr<'_>>, 196 - ) -> Result<AtpSession, ClientError> 199 + ) -> std::result::Result<AtpSession, ClientError> 197 200 where 198 201 S: Any + 'static, 199 202 { ··· 205 208 let pds = if identifier.as_ref().starts_with("http://") 206 209 || identifier.as_ref().starts_with("https://") 207 210 { 208 - Url::parse(identifier.as_ref()).map_err(|e| { 209 - ClientError::Transport(TransportError::InvalidRequest(e.to_string())) 210 - })? 211 + Url::parse(identifier.as_ref()) 212 + .map_err(|e: url::ParseError| ClientError::from(e) 213 + .with_help("identifier should be a valid https:// URL, handle, or DID"))? 211 214 } else if identifier.as_ref().starts_with("did:") { 212 - let did = Did::new(identifier.as_ref()).map_err(|e| { 213 - ClientError::Transport(TransportError::InvalidRequest(format!( 214 - "invalid did: {:?}", 215 - e 216 - ))) 217 - })?; 215 + let did = Did::new(identifier.as_ref()) 216 + .map_err(|e| ClientError::invalid_request(format!("invalid did: {:?}", e)) 217 + .with_help("DID format should be did:method:identifier (e.g., did:plc:abc123)"))?; 218 218 let resp = self 219 219 .client 220 220 .resolve_did_doc(&did) 221 221 .await 222 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 223 - resp.into_owned() 224 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? 222 + .map_err(|e| ClientError::from(e) 223 + .with_context("DID document resolution failed during login"))?; 224 + resp.into_owned()? 225 225 .pds_endpoint() 226 - .ok_or_else(|| { 227 - ClientError::Transport(TransportError::InvalidRequest( 228 - "missing PDS endpoint".into(), 229 - )) 230 - })? 226 + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") 227 + .with_help("DID document must include a PDS service endpoint"))? 231 228 } else { 232 229 // treat as handle 233 - let handle = 234 - jacquard_common::types::string::Handle::new(identifier.as_ref()).map_err(|e| { 235 - ClientError::Transport(TransportError::InvalidRequest(format!( 236 - "invalid handle: {:?}", 237 - e 238 - ))) 239 - })?; 230 + let handle = jacquard_common::types::string::Handle::new(identifier.as_ref()) 231 + .map_err(|e| ClientError::invalid_request(format!("invalid handle: {:?}", e)) 232 + .with_help("handle format should be domain.tld (e.g., alice.bsky.social)"))?; 240 233 let did = self 241 234 .client 242 235 .resolve_handle(&handle) 243 236 .await 244 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 237 + .map_err(|e| ClientError::from(e) 238 + .with_context("handle resolution failed during login"))?; 245 239 let resp = self 246 240 .client 247 241 .resolve_did_doc(&did) 248 242 .await 249 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 250 - resp.into_owned() 251 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? 243 + .map_err(|e| ClientError::from(e) 244 + .with_context("DID document resolution failed during login"))?; 245 + resp.into_owned()? 252 246 .pds_endpoint() 253 - .ok_or_else(|| { 254 - ClientError::Transport(TransportError::InvalidRequest( 255 - "missing PDS endpoint".into(), 256 - )) 257 - })? 247 + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") 248 + .with_help("DID document must include a PDS service endpoint"))? 258 249 }; 259 250 260 251 // Build and send createSession ··· 275 266 .await?; 276 267 let out = resp 277 268 .parse() 278 - .map_err(|_| ClientError::Auth(AuthError::NotAuthenticated))?; 269 + .map_err(|_| ClientError::auth(AuthError::NotAuthenticated) 270 + .with_help("check identifier and password are correct") 271 + .with_url("com.atproto.server.createSession"))?; 279 272 let session = AtpSession::from(out); 280 273 281 274 let sid = session_id.unwrap_or_else(|| CowStr::new_static("session")); ··· 283 276 self.store 284 277 .set(key.clone(), session.clone()) 285 278 .await 286 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 279 + .map_err(|e| ClientError::from(e) 280 + .with_context("failed to persist session to store"))?; 287 281 // If using FileAuthStore, persist PDS for faster resume 288 282 if let Some(file_store) = 289 283 (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() ··· 298 292 } 299 293 300 294 /// Restore a previously persisted app-password session and set base endpoint. 301 - pub async fn restore(&self, did: Did<'_>, session_id: CowStr<'_>) -> Result<(), ClientError> 295 + pub async fn restore( 296 + &self, 297 + did: Did<'_>, 298 + session_id: CowStr<'_>, 299 + ) -> std::result::Result<(), ClientError> 302 300 where 303 301 S: Any + 'static, 304 302 { ··· 309 307 310 308 let key = (did.clone().into_static(), session_id.clone().into_static()); 311 309 let Some(sess) = self.store.get(&key).await else { 312 - return Err(ClientError::Auth(AuthError::NotAuthenticated)); 310 + return Err(ClientError::auth(AuthError::NotAuthenticated)); 313 311 }; 314 312 // Try to read cached PDS; otherwise resolve via DID 315 313 let pds = if let Some(file_store) = ··· 323 321 let resp = self 324 322 .client 325 323 .resolve_did_doc(&did) 326 - .await 327 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 328 - resp.into_owned() 329 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? 324 + .await?; 325 + resp.into_owned()? 330 326 .pds_endpoint() 331 - .ok_or_else(|| { 332 - ClientError::Transport(TransportError::InvalidRequest( 333 - "missing PDS endpoint".into(), 334 - )) 335 - })? 327 + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") 328 + .with_help("DID document must include a PDS service endpoint"))? 336 329 }); 337 330 338 331 // Activate ··· 341 334 // ensure store has the session (no-op if it existed) 342 335 self.store 343 336 .set((sess.did.clone(), session_id.into_static()), sess) 344 - .await 345 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 337 + .await?; 346 338 if let Some(file_store) = 347 339 (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 348 340 { ··· 356 348 &self, 357 349 did: Did<'_>, 358 350 session_id: CowStr<'_>, 359 - ) -> Result<(), ClientError> 351 + ) -> std::result::Result<(), ClientError> 360 352 where 361 353 S: Any + 'static, 362 354 { 363 355 let key = (did.clone().into_static(), session_id.into_static()); 364 356 if self.store.get(&key).await.is_none() { 365 - return Err(ClientError::Auth(AuthError::NotAuthenticated)); 357 + return Err(ClientError::auth(AuthError::NotAuthenticated)); 366 358 } 367 359 // Endpoint from store if cached, else resolve 368 360 let pds = if let Some(file_store) = ··· 376 368 let resp = self 377 369 .client 378 370 .resolve_did_doc(&did) 379 - .await 380 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 381 - resp.into_owned() 382 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? 371 + .await?; 372 + resp.into_owned()? 383 373 .pds_endpoint() 384 - .ok_or_else(|| { 385 - ClientError::Transport(TransportError::InvalidRequest( 386 - "missing PDS endpoint".into(), 387 - )) 388 - })? 374 + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") 375 + .with_help("DID document must include a PDS service endpoint"))? 389 376 }); 390 377 *self.key.write().await = Some(key.clone()); 391 378 *self.endpoint.write().await = Some(pds); ··· 398 385 } 399 386 400 387 /// Clear and delete the current session from the store. 401 - pub async fn logout(&self) -> Result<(), ClientError> { 388 + pub async fn logout(&self) -> std::result::Result<(), ClientError> { 402 389 let Some(key) = self.key.read().await.clone() else { 403 390 return Ok(()); 404 391 }; 405 392 self.store 406 393 .del(&key) 407 - .await 408 - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 394 + .await?; 409 395 *self.key.write().await = None; 410 396 Ok(()) 411 397 } ··· 484 470 #[inline] 485 471 fn is_expired<R: XrpcResp>(response: &XrpcResult<Response<R>>) -> bool { 486 472 match response { 487 - Err(ClientError::Auth(AuthError::TokenExpired)) => true, 473 + Err(e) 474 + if matches!( 475 + e.kind(), 476 + jacquard_common::error::ClientErrorKind::Auth(AuthError::TokenExpired) 477 + ) => 478 + { 479 + true 480 + } 488 481 Ok(resp) => match resp.parse() { 489 482 Err(XrpcError::Auth(AuthError::TokenExpired)) => true, 490 483 _ => false, ··· 503 496 async fn send_http_streaming( 504 497 &self, 505 498 request: http::Request<Vec<u8>>, 506 - ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> { 499 + ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> 500 + { 507 501 self.client.send_http_streaming(request).await 508 502 } 509 503 504 + #[cfg(not(target_arch = "wasm32"))] 510 505 async fn send_http_bidirectional<Str>( 511 506 &self, 512 507 parts: http::request::Parts, 513 508 body: Str, 514 509 ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> 515 510 where 516 - Str: n0_future::Stream<Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>> 517 - + Send 511 + Str: n0_future::Stream< 512 + Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>, 513 + > + Send 518 514 + 'static, 515 + { 516 + self.client.send_http_bidirectional(parts, body).await 517 + } 518 + 519 + #[cfg(target_arch = "wasm32")] 520 + async fn send_http_bidirectional<Str>( 521 + &self, 522 + parts: http::request::Parts, 523 + body: Str, 524 + ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> 525 + where 526 + Str: n0_future::Stream< 527 + Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>, 528 + > + 'static, 519 529 { 520 530 self.client.send_http_bidirectional(parts, body).await 521 531 } ··· 589 599 <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp, 590 600 { 591 601 use jacquard_common::StreamError; 592 - use n0_future::{StreamExt, TryStreamExt}; 602 + use n0_future::TryStreamExt; 593 603 594 604 let base_uri = self.base_uri().await; 595 605 let mut opts = self.options.read().await.clone(); ··· 640 650 .into_parts(); 641 651 642 652 let body_stream = 643 - jacquard_common::stream::ByteStream::new(stream.0.map_ok(|f| f.buffer).boxed()); 653 + jacquard_common::stream::ByteStream::new(Box::pin(stream.0.map_ok(|f| f.buffer))); 644 654 645 655 // Clone the stream for potential retry 646 656 let (body1, body2) = body_stream.tee(); ··· 672 682 http::HeaderValue::from_str(&format!("DPoP {}", t.as_ref())) 673 683 } 674 684 } 675 - .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?; 685 + .map_err(|e| { 686 + StreamError::protocol(format!("Invalid authorization token: {}", e)) 687 + })?; 676 688 builder = builder.header(http::header::AUTHORIZATION, hv); 677 689 } 678 690 if let Some(proxy) = &opts.atproto_proxy { ··· 704 716 .await 705 717 .map_err(StreamError::transport)?; 706 718 let (resp_parts, resp_body) = response.into_parts(); 707 - Ok(jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( 708 - resp_parts, resp_body, 709 - )) 719 + Ok( 720 + jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( 721 + resp_parts, resp_body, 722 + ), 723 + ) 710 724 } else { 711 - Ok(jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( 712 - resp_parts, resp_body, 713 - )) 725 + Ok( 726 + jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( 727 + resp_parts, resp_body, 728 + ), 729 + ) 714 730 } 715 731 } 716 732 }
+279
crates/jacquard/src/client/error.rs
··· 1 + use jacquard_common::error::{AuthError, ClientError}; 2 + use jacquard_common::types::did::Did; 3 + use jacquard_common::types::nsid::Nsid; 4 + use jacquard_common::types::string::{RecordKey, Rkey}; 5 + use jacquard_common::xrpc::XrpcError; 6 + use jacquard_common::{Data, IntoStatic}; 7 + use smol_str::SmolStr; 8 + 9 + /// Boxed error type for wrapping arbitrary errors 10 + pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>; 11 + 12 + /// Error type for Agent convenience methods 13 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 14 + #[error("{kind}")] 15 + pub struct AgentError { 16 + #[diagnostic_source] 17 + kind: AgentErrorKind, 18 + #[source] 19 + source: Option<BoxError>, 20 + #[help] 21 + help: Option<SmolStr>, 22 + context: Option<SmolStr>, 23 + url: Option<SmolStr>, 24 + details: Option<SmolStr>, 25 + location: Option<SmolStr>, 26 + xrpc: Option<Data<'static>>, 27 + } 28 + 29 + /// Error categories for Agent operations 30 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 31 + pub enum AgentErrorKind { 32 + /// Transport/network layer failure 33 + #[error("client error")] 34 + #[diagnostic(code(jacquard::agent::client))] 35 + Client, 36 + 37 + /// No session available for operations requiring authentication 38 + #[error("no session available")] 39 + #[diagnostic( 40 + code(jacquard::agent::no_session), 41 + help("ensure agent is authenticated before performing operations") 42 + )] 43 + NoSession, 44 + 45 + /// Authentication error from XRPC layer 46 + #[error("auth error: {0}")] 47 + #[diagnostic(code(jacquard::agent::auth))] 48 + Auth(AuthError), 49 + 50 + /// Record operation failed with typed error from endpoint 51 + #[error("record operation failed on {collection}/{rkey:?} in repo {repo}")] 52 + #[diagnostic(code(jacquard::agent::record_operation))] 53 + RecordOperation { 54 + /// The repository DID 55 + repo: Did<'static>, 56 + /// The collection NSID 57 + collection: Nsid<'static>, 58 + /// The record key 59 + rkey: RecordKey<Rkey<'static>>, 60 + }, 61 + 62 + /// Multi-step operation failed at sub-step (e.g., get failed in update_record) 63 + #[error("operation failed at step '{step}'")] 64 + #[diagnostic(code(jacquard::agent::sub_operation))] 65 + SubOperation { 66 + /// Description of which step failed 67 + step: &'static str, 68 + }, 69 + /// XRPC error 70 + #[error("xrpc error")] 71 + #[diagnostic(code(jacquard::agent::xrpc))] 72 + XrpcError, 73 + } 74 + 75 + impl AgentError { 76 + /// Create a new error with the given kind and optional source 77 + pub fn new(kind: AgentErrorKind, source: Option<BoxError>) -> Self { 78 + Self { 79 + kind, 80 + source, 81 + help: None, 82 + context: None, 83 + url: None, 84 + details: None, 85 + location: None, 86 + xrpc: None, 87 + } 88 + } 89 + 90 + /// Get the error kind 91 + pub fn kind(&self) -> &AgentErrorKind { 92 + &self.kind 93 + } 94 + 95 + /// Get the source error if present 96 + pub fn source_err(&self) -> Option<&BoxError> { 97 + self.source.as_ref() 98 + } 99 + 100 + /// Get the context string if present 101 + pub fn context(&self) -> Option<&str> { 102 + self.context.as_ref().map(|s| s.as_str()) 103 + } 104 + 105 + /// Get the URL if present 106 + pub fn url(&self) -> Option<&str> { 107 + self.url.as_ref().map(|s| s.as_str()) 108 + } 109 + 110 + /// Get the details if present 111 + pub fn details(&self) -> Option<&str> { 112 + self.details.as_ref().map(|s| s.as_str()) 113 + } 114 + 115 + /// Get the location if present 116 + pub fn location(&self) -> Option<&str> { 117 + self.location.as_ref().map(|s| s.as_str()) 118 + } 119 + 120 + /// Add help text to this error 121 + pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 122 + self.help = Some(help.into()); 123 + self 124 + } 125 + 126 + /// Add context to this error 127 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 128 + self.context = Some(context.into()); 129 + self 130 + } 131 + 132 + /// Add URL to this error 133 + pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 134 + self.url = Some(url.into()); 135 + self 136 + } 137 + 138 + /// Add details to this error 139 + pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self { 140 + self.details = Some(details.into()); 141 + self 142 + } 143 + 144 + /// Add location to this error 145 + pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self { 146 + self.location = Some(location.into()); 147 + self 148 + } 149 + 150 + /// Add XRPC error data to this error for observability 151 + pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self 152 + where 153 + E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize, 154 + { 155 + use jacquard_common::types::value::to_data; 156 + // Attempt to serialize XrpcError to Data for observability 157 + if let Ok(data) = to_data(&xrpc) { 158 + self.xrpc = Some(data.into_static()); 159 + } 160 + self 161 + } 162 + 163 + /// Create an XRPC error with attached error data for observability 164 + pub fn xrpc<E>(error: XrpcError<E>) -> Self 165 + where 166 + E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize + Send + Sync, 167 + <E as IntoStatic>::Output: IntoStatic + std::error::Error + Send + Sync, 168 + { 169 + use jacquard_common::types::value::to_data; 170 + // Attempt to serialize XrpcError to Data for observability 171 + if let Ok(data) = to_data(&error) { 172 + let mut error = Self::new( 173 + AgentErrorKind::XrpcError, 174 + Some(Box::new(error.into_static())), 175 + ); 176 + error.xrpc = Some(data.into_static()); 177 + error 178 + } else { 179 + Self::new( 180 + AgentErrorKind::XrpcError, 181 + Some(Box::new(error.into_static())), 182 + ) 183 + } 184 + } 185 + 186 + // Constructors 187 + 188 + /// Create a no session error 189 + pub fn no_session() -> Self { 190 + Self::new(AgentErrorKind::NoSession, None) 191 + } 192 + 193 + /// Create a sub-operation error for multi-step operations 194 + pub fn sub_operation( 195 + step: &'static str, 196 + source: impl std::error::Error + Send + Sync + 'static, 197 + ) -> Self { 198 + Self::new( 199 + AgentErrorKind::SubOperation { step }, 200 + Some(Box::new(source)), 201 + ) 202 + } 203 + 204 + /// Create a record operation error 205 + pub fn record_operation( 206 + repo: Did<'static>, 207 + collection: Nsid<'static>, 208 + rkey: RecordKey<Rkey<'static>>, 209 + source: impl std::error::Error + Send + Sync + 'static, 210 + ) -> Self { 211 + Self::new( 212 + AgentErrorKind::RecordOperation { 213 + repo, 214 + collection, 215 + rkey, 216 + }, 217 + Some(Box::new(source)), 218 + ) 219 + } 220 + 221 + /// Create an authentication error 222 + pub fn auth(auth_error: AuthError) -> Self { 223 + Self::new(AgentErrorKind::Auth(auth_error), None) 224 + } 225 + } 226 + 227 + impl From<ClientError> for AgentError { 228 + fn from(e: ClientError) -> Self { 229 + Self::new(AgentErrorKind::Client, Some(Box::new(e))) 230 + } 231 + } 232 + 233 + impl From<AuthError> for AgentError { 234 + fn from(e: AuthError) -> Self { 235 + Self::new(AgentErrorKind::Auth(e), None) 236 + .with_help("check authentication credentials and session state") 237 + } 238 + } 239 + 240 + /// Result type for Agent operations 241 + pub type Result<T> = core::result::Result<T, AgentError>; 242 + 243 + impl IntoStatic for AgentError { 244 + type Output = AgentError; 245 + 246 + fn into_static(self) -> Self::Output { 247 + match self.kind { 248 + AgentErrorKind::RecordOperation { 249 + repo, 250 + collection, 251 + rkey, 252 + } => Self { 253 + kind: AgentErrorKind::RecordOperation { 254 + repo: repo.into_static(), 255 + collection: collection.into_static(), 256 + rkey: rkey.into_static(), 257 + }, 258 + source: self.source, 259 + help: self.help, 260 + context: self.context, 261 + url: self.url, 262 + details: self.details, 263 + location: self.location, 264 + xrpc: self.xrpc, 265 + }, 266 + AgentErrorKind::Auth(auth) => Self { 267 + kind: AgentErrorKind::Auth(auth.into_static()), 268 + source: self.source, 269 + help: self.help, 270 + context: self.context, 271 + url: self.url, 272 + details: self.details, 273 + location: self.location, 274 + xrpc: self.xrpc, 275 + }, 276 + _ => self, 277 + } 278 + } 279 + }
+3 -3
crates/jacquard/src/moderation.rs
··· 2 2 //! 3 3 //! This is an attempt to semi-generalize the Bluesky moderation system. It avoids 4 4 //! depending on their lexicons as much as reasonably possible. This works via a 5 - //! trait, [`Labeled`], which represents things that have labels for moderation 5 + //! trait, [`Labeled`][crate::moderation::Labeled], which represents things that have labels for moderation 6 6 //! applied to them. This way the moderation application functions can operate 7 7 //! primarily via the trait, and are thus generic over lexicon types, and are 8 8 //! easy to use with your own types. 9 9 //! 10 10 //! For more complex types which might have labels applied to components, 11 - //! there is the [`Moderateable`] trait. A mostly complete implementation for 11 + //! there is the [`Moderateable`][crate::moderation::Moderateable] trait. A mostly complete implementation for 12 12 //! `FeedViewPost` is available for reference. The trait method outputs a `Vec` 13 13 //! of tuples, where the first element is a string tag and the second is the 14 14 //! moderation decision for the tagged element. This lets application developers ··· 16 16 //! mostly match Bluesky behaviour (respecting "!hide", and such) by default. 17 17 //! 18 18 //! I've taken the time to go through the generated API bindings and implement 19 - //! the [`Labeled`] trait for a number of types. It's a fairly easy trait to 19 + //! the [`Labeled`][crate::moderation::Labeled] trait for a number of types. It's a fairly easy trait to 20 20 //! implement, just not really automatable. 21 21 //! 22 22 //!
+11 -14
crates/jacquard/src/moderation/fetch.rs
··· 9 9 }; 10 10 use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels}; 11 11 use jacquard_common::cowstr::ToCowStr; 12 - use jacquard_common::error::{ClientError, TransportError}; 12 + use jacquard_common::error::ClientError; 13 13 use jacquard_common::types::collection::Collection; 14 14 use jacquard_common::types::string::Did; 15 15 use jacquard_common::types::uri::RecordUri; ··· 30 30 31 31 let response = client.send(request).await?; 32 32 let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e { 33 - XrpcError::Auth(auth) => ClientError::Auth(auth), 34 - XrpcError::Generic(g) => { 35 - ClientError::Transport(TransportError::Other(g.to_string().into())) 36 - } 37 - XrpcError::Decode(e) => ClientError::Decode(e), 38 - XrpcError::Xrpc(typed) => { 39 - ClientError::Transport(TransportError::Other(format!("{:?}", typed).into())) 40 - } 33 + XrpcError::Auth(auth) => ClientError::auth(auth), 34 + XrpcError::Generic(g) => ClientError::decode(g.to_string()), 35 + XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)), 36 + XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)), 41 37 })?; 42 38 43 39 let mut defs = LabelerDefs::new(); ··· 81 77 pub async fn fetch_labeler_defs_direct( 82 78 client: &(impl AgentSessionExt + Sync), 83 79 dids: Vec<Did<'_>>, 84 - ) -> Result<LabelerDefs<'static>, ClientError> { 80 + ) -> Result<LabelerDefs<'static>, AgentError> { 85 81 #[cfg(feature = "tracing")] 86 82 let _span = tracing::debug_span!("fetch_labeler_defs_direct", count = dids.len()).entered(); 87 83 ··· 90 86 for did in dids { 91 87 let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str()); 92 88 let record_uri = Service::uri(uri).map_err(|e| { 93 - ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into())) 89 + AgentError::from(ClientError::invalid_request(format!("Invalid URI: {}", e))) 94 90 })?; 95 91 96 92 let output = client.fetch_record(&record_uri).await?; ··· 135 131 .await? 136 132 .into_output() 137 133 .map_err(|e| match e { 138 - XrpcError::Generic(e) => AgentError::Generic(e), 139 - _ => unimplemented!(), // We know the error at this point is always GenericXrpcError 134 + XrpcError::Auth(auth) => AgentError::from(auth), 135 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 136 + XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)), 140 137 })?; 141 138 Ok((labels.labels, labels.cursor)) 142 139 } ··· 157 154 where 158 155 R: Collection + From<CollectionOutput<'static, R>>, 159 156 for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>, 160 - for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>>, 157 + for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>> + Send + Sync, 161 158 { 162 159 let record: R = client.fetch_record(record_uri).await?.into(); 163 160 let (labels, _) =
+145
rustdoc-host.nix
··· 1 + { config, pkgs, lib, ... }: 2 + 3 + { 4 + # Basic system config 5 + networking.firewall.allowedTCPPorts = [ 80 443 ]; 6 + 7 + # Rust toolchain for building docs 8 + environment.systemPackages = with pkgs; [ 9 + rustup 10 + git 11 + cargo 12 + ]; 13 + 14 + # Build script to generate docs 15 + environment.etc."rustdoc-build.sh" = { 16 + text = '' 17 + #!/usr/bin/env bash 18 + set -euo pipefail 19 + 20 + REPO_URL="''${1:-https://github.com/orual/jacquard.git}" 21 + BRANCH="''${2:-main}" 22 + BUILD_DIR="/var/www/rustdoc/build" 23 + OUTPUT_DIR="/var/www/rustdoc/docs" 24 + 25 + echo "Building docs from $REPO_URL ($BRANCH)..." 26 + 27 + # Clean and clone 28 + rm -rf "$BUILD_DIR" 29 + git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$BUILD_DIR" 30 + cd "$BUILD_DIR" 31 + 32 + # Build docs with all features for jacquard-api 33 + export RUSTDOCFLAGS="--html-in-header /etc/rustdoc-analytics.html" 34 + cargo doc \ 35 + --no-deps \ 36 + --workspace \ 37 + --all-features \ 38 + --document-private-items 39 + 40 + # Copy to serving directory 41 + rm -rf "$OUTPUT_DIR" 42 + cp -r target/doc "$OUTPUT_DIR" 43 + 44 + # Create index redirect 45 + cat > "$OUTPUT_DIR/index.html" <<EOF 46 + <!DOCTYPE html> 47 + <html> 48 + <head> 49 + <meta http-equiv="refresh" content="0; url=jacquard/index.html"> 50 + <title>Jacquard Documentation</title> 51 + </head> 52 + <body> 53 + <p>Redirecting to <a href="jacquard/index.html">jacquard documentation</a>...</p> 54 + </body> 55 + </html> 56 + EOF 57 + 58 + chown -R nginx:nginx "$OUTPUT_DIR" 59 + echo "Build complete! Docs available at $OUTPUT_DIR" 60 + ''; 61 + mode = "0755"; 62 + }; 63 + 64 + # Optional analytics snippet (empty by default) 65 + environment.etc."rustdoc-analytics.html" = { 66 + text = '' 67 + <!-- Add analytics/plausible/umami script here if desired --> 68 + ''; 69 + }; 70 + 71 + # Nginx to serve the docs 72 + services.nginx = { 73 + enable = true; 74 + recommendedGzipSettings = true; 75 + recommendedOptimisation = true; 76 + recommendedProxySettings = true; 77 + recommendedTlsSettings = true; 78 + 79 + virtualHosts."docs.example.com" = { 80 + # Set this to your actual domain 81 + # serverName = "docs.jacquard.dev"; 82 + 83 + # For cloudflare tunnel, you don't need ACME here 84 + # If you want direct HTTPS: 85 + # enableACME = true; 86 + # forceSSL = true; 87 + 88 + root = "/var/www/rustdoc/docs"; 89 + 90 + locations."/" = { 91 + tryFiles = "$uri $uri/ =404"; 92 + extraConfig = '' 93 + # Cache static assets 94 + location ~* \.(css|js|woff|woff2)$ { 95 + expires 1y; 96 + add_header Cache-Control "public, immutable"; 97 + } 98 + 99 + # CORS headers for cross-origin font loading 100 + location ~* \.(woff|woff2)$ { 101 + add_header Access-Control-Allow-Origin "*"; 102 + } 103 + ''; 104 + }; 105 + }; 106 + }; 107 + 108 + # Create serving directory 109 + systemd.tmpfiles.rules = [ 110 + "d /var/www/rustdoc 0755 nginx nginx -" 111 + "d /var/www/rustdoc/build 0755 nginx nginx -" 112 + "d /var/www/rustdoc/docs 0755 nginx nginx -" 113 + ]; 114 + 115 + # Optional: systemd service for periodic rebuilds 116 + systemd.services.rustdoc-build = { 117 + description = "Build Jacquard documentation"; 118 + serviceConfig = { 119 + Type = "oneshot"; 120 + ExecStart = "${pkgs.bash}/bin/bash /etc/rustdoc-build.sh"; 121 + User = "nginx"; 122 + }; 123 + }; 124 + 125 + # Optional: timer to rebuild daily 126 + systemd.timers.rustdoc-build = { 127 + wantedBy = [ "timers.target" ]; 128 + timerConfig = { 129 + OnCalendar = "daily"; 130 + Persistent = true; 131 + }; 132 + }; 133 + 134 + # Optional: webhook receiver for rebuild-on-push 135 + # Uncomment if you want webhook triggers 136 + # services.webhook = { 137 + # enable = true; 138 + # hooks = { 139 + # rebuild-docs = { 140 + # execute-command = "/etc/rustdoc-build.sh"; 141 + # command-working-directory = "/tmp"; 142 + # }; 143 + # }; 144 + # }; 145 + }