From 3a87cc05ca5b1add089e99ecfab9bb249f7df14a Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 19 Oct 2025 22:12:51 -0400 Subject: [PATCH] big error type rework --- .gitignore | 4 +- Cargo.lock | 1 + .../src/app_bsky/video/upload_video.rs | 4 +- .../src/com_atproto/repo/import_repo.rs | 4 +- .../src/com_atproto/repo/upload_blob.rs | 4 +- .../garden_lexicon/ngerakines/semeion/sign.rs | 4 +- .../jacquard-axum/tests/service_auth_tests.rs | 2 +- crates/jacquard-common/Cargo.toml | 3 + crates/jacquard-common/src/error.rs | 405 ++++++++-- crates/jacquard-common/src/http_client.rs | 47 +- crates/jacquard-common/src/stream.rs | 21 +- crates/jacquard-common/src/xrpc.rs | 99 ++- crates/jacquard-common/src/xrpc/streaming.rs | 41 +- crates/jacquard-identity/src/lib.rs | 120 +-- crates/jacquard-identity/src/resolver.rs | 444 +++++++---- crates/jacquard-oauth/src/client.rs | 41 +- crates/jacquard-oauth/src/request.rs | 381 +++++++-- crates/jacquard-oauth/src/resolver.rs | 522 +++++++++---- crates/jacquard-oauth/src/session.rs | 2 +- crates/jacquard/src/client.rs | 736 ++++++++---------- .../jacquard/src/client/credential_session.rs | 178 +++-- crates/jacquard/src/client/error.rs | 279 +++++++ crates/jacquard/src/moderation.rs | 6 +- crates/jacquard/src/moderation/fetch.rs | 25 +- rustdoc-host.nix | 145 ++++ 25 files changed, 2439 insertions(+), 1079 deletions(-) create mode 100644 crates/jacquard/src/client/error.rs create mode 100644 rustdoc-host.nix diff --git a/.gitignore b/.gitignore index 575cb300..1d816629 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,8 @@ /.pre-commit-config.yaml CLAUDE.md AGENTS.md -crates/jacquard-lexicon/tests/fixtures/lexicons/atproto crates/jacquard-lexicon/target -codegen_plan.md -/lex_js /plans /docs /binaries/releases/ +rustdoc-host.nix diff --git a/Cargo.lock b/Cargo.lock index 5f41021d..8394f6e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2353,6 +2353,7 @@ dependencies = [ "futures", "futures-lite", "genawaiter", + "getrandom 0.2.16", "getrandom 0.3.4", "http", "ipld-core", diff --git a/crates/jacquard-api/src/app_bsky/video/upload_video.rs b/crates/jacquard-api/src/app_bsky/video/upload_video.rs index 4d4e860f..42e42c92 100644 --- a/crates/jacquard-api/src/app_bsky/video/upload_video.rs +++ b/crates/jacquard-api/src/app_bsky/video/upload_video.rs @@ -56,9 +56,7 @@ impl jacquard_common::xrpc::XrpcRequest for UploadVideo { fn encode_body(&self) -> Result, jacquard_common::xrpc::EncodeError> { Ok(self.body.to_vec()) } - fn decode_body<'de>( - body: &'de [u8], - ) -> Result, jacquard_common::error::DecodeError> + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult> where Self: serde::Deserialize<'de>, { diff --git a/crates/jacquard-api/src/com_atproto/repo/import_repo.rs b/crates/jacquard-api/src/com_atproto/repo/import_repo.rs index e7f57cd8..9fae08aa 100644 --- a/crates/jacquard-api/src/com_atproto/repo/import_repo.rs +++ b/crates/jacquard-api/src/com_atproto/repo/import_repo.rs @@ -40,9 +40,7 @@ impl jacquard_common::xrpc::XrpcRequest for ImportRepo { fn encode_body(&self) -> Result, jacquard_common::xrpc::EncodeError> { Ok(self.body.to_vec()) } - fn decode_body<'de>( - body: &'de [u8], - ) -> Result, jacquard_common::error::DecodeError> + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult> where Self: serde::Deserialize<'de>, { diff --git a/crates/jacquard-api/src/com_atproto/repo/upload_blob.rs b/crates/jacquard-api/src/com_atproto/repo/upload_blob.rs index 3c659944..250e3ccd 100644 --- a/crates/jacquard-api/src/com_atproto/repo/upload_blob.rs +++ b/crates/jacquard-api/src/com_atproto/repo/upload_blob.rs @@ -56,9 +56,7 @@ impl jacquard_common::xrpc::XrpcRequest for UploadBlob { fn encode_body(&self) -> Result, jacquard_common::xrpc::EncodeError> { Ok(self.body.to_vec()) } - fn decode_body<'de>( - body: &'de [u8], - ) -> Result, jacquard_common::error::DecodeError> + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult> where Self: serde::Deserialize<'de>, { diff --git a/crates/jacquard-api/src/garden_lexicon/ngerakines/semeion/sign.rs b/crates/jacquard-api/src/garden_lexicon/ngerakines/semeion/sign.rs index f98c559b..bd01db65 100644 --- a/crates/jacquard-api/src/garden_lexicon/ngerakines/semeion/sign.rs +++ b/crates/jacquard-api/src/garden_lexicon/ngerakines/semeion/sign.rs @@ -69,9 +69,7 @@ impl jacquard_common::xrpc::XrpcRequest for Sign { fn encode_body(&self) -> Result, jacquard_common::xrpc::EncodeError> { Ok(self.body.to_vec()) } - fn decode_body<'de>( - body: &'de [u8], - ) -> Result, jacquard_common::error::DecodeError> + fn decode_body<'de>(body: &'de [u8]) -> jacquard_common::error::XrpcResult> where Self: serde::Deserialize<'de>, { diff --git a/crates/jacquard-axum/tests/service_auth_tests.rs b/crates/jacquard-axum/tests/service_auth_tests.rs index 4b4f8a28..98cc9c0a 100644 --- a/crates/jacquard-axum/tests/service_auth_tests.rs +++ b/crates/jacquard-axum/tests/service_auth_tests.rs @@ -120,7 +120,7 @@ impl IdentityResolver for MockResolver { &self, _handle: &jacquard_common::types::string::Handle<'_>, ) -> impl Future, IdentityError>> + Send { - async { Err(IdentityError::InvalidWellKnown) } + async { Err(IdentityError::invalid_well_known()) } } fn resolve_did_doc( diff --git a/crates/jacquard-common/Cargo.toml b/crates/jacquard-common/Cargo.toml index bf5ff7b9..262a8d1b 100644 --- a/crates/jacquard-common/Cargo.toml +++ b/crates/jacquard-common/Cargo.toml @@ -64,6 +64,9 @@ zstd = { version = "0.13", optional = true } [target.'cfg(target_family = "wasm")'.dependencies] getrandom = { version = "0.3.4", features = ["wasm_js"] } +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] reqwest = { workspace = true, optional = true, features = [ "http2", "system-proxy", "rustls-tls"] } tokio-util = { version = "0.7.16", features = ["io"] } diff --git a/crates/jacquard-common/src/error.rs b/crates/jacquard-common/src/error.rs index ee9b454b..2a9829d3 100644 --- a/crates/jacquard-common/src/error.rs +++ b/crates/jacquard-common/src/error.rs @@ -2,72 +2,239 @@ use crate::xrpc::EncodeError; use bytes::Bytes; +use smol_str::SmolStr; -/// Client error type wrapping all possible error conditions +/// Boxed error type for wrapping arbitrary errors +pub type BoxError = Box; + +/// Client error type for all XRPC client operations #[derive(Debug, thiserror::Error, miette::Diagnostic)] -pub enum ClientError { - /// HTTP transport error - #[error("HTTP transport error: {0}")] - Transport( - #[from] - #[diagnostic_source] - TransportError, - ), +#[error("{kind}")] +pub struct ClientError { + #[diagnostic_source] + kind: ClientErrorKind, + #[source] + source: Option, + #[help] + help: Option, + context: Option, + url: Option, + details: Option, + location: Option, +} + +/// Error categories for client operations +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum ClientErrorKind { + /// HTTP transport error (connection, timeout, etc.) + #[error("transport error")] + #[diagnostic(code(jacquard::client::transport))] + Transport, + + /// Request validation/construction failed + #[error("invalid request: {0}")] + #[diagnostic( + code(jacquard::client::invalid_request), + help("check request parameters and format") + )] + InvalidRequest(SmolStr), /// Request serialization failed - #[error("{0}")] - Encode( - #[from] - #[diagnostic_source] - EncodeError, - ), + #[error("encode error: {0}")] + #[diagnostic( + code(jacquard::client::encode), + help("check request body format and encoding") + )] + Encode(SmolStr), /// Response deserialization failed - #[error("{0}")] - Decode( - #[from] - #[diagnostic_source] - DecodeError, - ), + #[error("decode error: {0}")] + #[diagnostic( + code(jacquard::client::decode), + help("check response format and encoding") + )] + Decode(SmolStr), - /// HTTP error response - #[error("HTTP {0}")] - Http( - #[from] - #[diagnostic_source] - HttpError, - ), + /// HTTP error response (non-200 status) + #[error("HTTP {status}")] + #[diagnostic(code(jacquard::client::http))] + Http { + /// HTTP status code + status: http::StatusCode, + }, - /// Authentication error - #[error("Authentication error: {0}")] - Auth( - #[from] - #[diagnostic_source] - AuthError, - ), + /// Authentication/authorization error + #[error("auth error: {0}")] + #[diagnostic(code(jacquard::client::auth))] + Auth(AuthError), + + /// Identity resolution error (handle→DID, DID→Doc) + #[error("identity resolution failed")] + #[diagnostic( + code(jacquard::client::identity_resolution), + help("check handle/DID is valid and network is accessible") + )] + IdentityResolution, + + /// Storage/persistence error + #[error("storage error")] + #[diagnostic( + code(jacquard::client::storage), + help("check storage backend is accessible and has sufficient permissions") + )] + Storage, } -/// Transport-level errors that occur during HTTP communication -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -pub enum TransportError { - /// Failed to establish connection to server - #[error("Connection error: {0}")] - Connect(String), +impl ClientError { + /// Create a new error with the given kind and optional source + pub fn new(kind: ClientErrorKind, source: Option) -> Self { + Self { + kind, + source, + help: None, + context: None, + url: None, + details: None, + location: None, + } + } + + /// Get the error kind + pub fn kind(&self) -> &ClientErrorKind { + &self.kind + } + + /// Get the source error if present + pub fn source_err(&self) -> Option<&BoxError> { + self.source.as_ref() + } + + /// Get the context string if present + pub fn context(&self) -> Option<&str> { + self.context.as_ref().map(|s| s.as_str()) + } + + /// Get the URL if present + pub fn url(&self) -> Option<&str> { + self.url.as_ref().map(|s| s.as_str()) + } - /// Request timed out - #[error("Request timeout")] - Timeout, + /// Get the details if present + pub fn details(&self) -> Option<&str> { + self.details.as_ref().map(|s| s.as_str()) + } - /// Request construction failed (malformed URI, headers, etc.) - #[error("Invalid request: {0}")] - InvalidRequest(String), + /// Get the location if present + pub fn location(&self) -> Option<&str> { + self.location.as_ref().map(|s| s.as_str()) + } - /// Other transport error - #[error("Transport error: {0}")] - Other(Box), + /// Add help text to this error + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Add context to this error + pub fn with_context(mut self, context: impl Into) -> Self { + self.context = Some(context.into()); + self + } + + /// Add URL to this error + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Add details to this error + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } + + /// Add location to this error + pub fn with_location(mut self, location: impl Into) -> Self { + self.location = Some(location.into()); + self + } + + // Constructors for each kind + + /// Create a transport error + pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(ClientErrorKind::Transport, Some(Box::new(source))) + } + + /// Create an invalid request error + pub fn invalid_request(msg: impl Into) -> Self { + Self::new(ClientErrorKind::InvalidRequest(msg.into()), None) + } + + /// Create an encode error + pub fn encode(msg: impl Into) -> Self { + Self::new(ClientErrorKind::Encode(msg.into()), None) + } + + /// Create a decode error + pub fn decode(msg: impl Into) -> Self { + Self::new(ClientErrorKind::Decode(msg.into()), None) + } + + /// Create an HTTP error with status code and optional body + pub fn http(status: http::StatusCode, body: Option) -> Self { + let http_err = HttpError { status, body }; + Self::new(ClientErrorKind::Http { status }, Some(Box::new(http_err))) + } + + /// Create an authentication error + pub fn auth(auth_error: AuthError) -> Self { + Self::new(ClientErrorKind::Auth(auth_error), None) + } + + /// Create an identity resolution error + pub fn identity_resolution(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(ClientErrorKind::IdentityResolution, Some(Box::new(source))) + } + + /// Create a storage error + pub fn storage(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(ClientErrorKind::Storage, Some(Box::new(source))) + } } +/// Result type for client operations +pub type XrpcResult = std::result::Result; + +// ============================================================================ +// Old error types (deprecated) +// ============================================================================ + +/// Transport-level errors that occur during HTTP communication +// #[deprecated(since = "0.8.0", note = "Use ClientError::transport() instead")] +// #[derive(Debug, thiserror::Error, miette::Diagnostic)] +// pub enum TransportError { +// /// Failed to establish connection to server +// #[error("Connection error: {0}")] +// Connect(String), + +// /// Request timed out +// #[error("Request timeout")] +// Timeout, + +// /// Request construction failed (malformed URI, headers, etc.) +// #[error("Invalid request: {0}")] +// InvalidRequest(String), + +// /// Other transport error +// #[error("Transport error: {0}")] +// Other(Box), +// } + /// Response deserialization errors +/// +/// Preserves detailed error information from various deserialization backends. +/// Can be converted to string for serialization while maintaining the full error context. #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum DecodeError { /// JSON deserialization failed @@ -134,35 +301,6 @@ impl std::fmt::Display for HttpError { } } -/// Result type for client operations -pub type XrpcResult = std::result::Result; - -#[cfg(feature = "reqwest-client")] -impl From for TransportError { - #[cfg(not(target_arch = "wasm32"))] - fn from(e: reqwest::Error) -> Self { - if e.is_timeout() { - Self::Timeout - } else if e.is_connect() { - Self::Connect(e.to_string()) - } else if e.is_builder() || e.is_request() { - Self::InvalidRequest(e.to_string()) - } else { - Self::Other(Box::new(e)) - } - } - #[cfg(target_arch = "wasm32")] - fn from(e: reqwest::Error) -> Self { - if e.is_timeout() { - Self::Timeout - } else if e.is_builder() || e.is_request() { - Self::InvalidRequest(e.to_string()) - } else { - Self::Other(Box::new(e)) - } - } -} - /// Authentication and authorization errors #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum AuthError { @@ -200,3 +338,112 @@ impl crate::IntoStatic for AuthError { } } } + +// ============================================================================ +// Conversions from old to new +// ============================================================================ + +#[allow(deprecated)] +// impl From for ClientError { +// fn from(e: TransportError) -> Self { +// Self::transport(e) +// } +// } + +impl From for ClientError { + fn from(e: DecodeError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) + .with_context("response deserialization failed") + } +} + +impl From for ClientError { + fn from(e: HttpError) -> Self { + Self::http(e.status, e.body) + } +} + +impl From for ClientError { + fn from(e: AuthError) -> Self { + Self::auth(e) + } +} + +impl From for ClientError { + fn from(e: EncodeError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ClientErrorKind::Encode(msg), Some(Box::new(e))) + .with_context("request encoding failed") + } +} + +// Platform-specific conversions +#[cfg(feature = "reqwest-client")] +impl From for ClientError { + #[cfg(not(target_arch = "wasm32"))] + fn from(e: reqwest::Error) -> Self { + Self::transport(e) + } + + #[cfg(target_arch = "wasm32")] + fn from(e: reqwest::Error) -> Self { + Self::transport(e) + } +} + +// Serde error conversions +impl From for ClientError { + fn from(e: serde_json::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) + .with_context("JSON deserialization failed") + } +} + +impl From> for ClientError { + fn from(e: serde_ipld_dagcbor::DecodeError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) + .with_context("DAG-CBOR deserialization failed (local I/O)") + } +} + +impl From> for ClientError { + fn from(e: serde_ipld_dagcbor::DecodeError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) + .with_context("DAG-CBOR deserialization failed (remote)") + } +} + +impl From> for ClientError { + fn from(e: serde_ipld_dagcbor::DecodeError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) + .with_context("DAG-CBOR deserialization failed (in-memory)") + } +} + +#[cfg(feature = "websocket")] +impl From> for ClientError { + fn from(e: ciborium::de::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e))) + .with_context("CBOR header deserialization failed") + } +} + +// Session store errors +impl From for ClientError { + fn from(e: crate::session::SessionStoreError) -> Self { + Self::storage(e) + } +} + +// URL parse errors +impl From for ClientError { + fn from(e: url::ParseError) -> Self { + Self::invalid_request(e.to_string()) + } +} diff --git a/crates/jacquard-common/src/http_client.rs b/crates/jacquard-common/src/http_client.rs index f6a88d08..6d1b57a3 100644 --- a/crates/jacquard-common/src/http_client.rs +++ b/crates/jacquard-common/src/http_client.rs @@ -31,6 +31,7 @@ pub trait HttpClientExt: HttpClient { ) -> impl Future, Self::Error>>; /// Send HTTP request with streaming body and receive streaming response + #[cfg(not(target_arch = "wasm32"))] fn send_http_bidirectional( &self, parts: http::request::Parts, @@ -38,6 +39,16 @@ pub trait HttpClientExt: HttpClient { ) -> impl Future, Self::Error>> where S: n0_future::Stream> + Send + 'static; + + /// Send HTTP request with streaming body and receive streaming response (WASM) + #[cfg(target_arch = "wasm32")] + fn send_http_bidirectional( + &self, + parts: http::request::Parts, + body: S, + ) -> impl Future, Self::Error>> + where + S: n0_future::Stream> + 'static; } #[cfg(feature = "reqwest-client")] @@ -180,38 +191,14 @@ impl HttpClientExt for reqwest::Client { #[cfg(target_arch = "wasm32")] async fn send_http_bidirectional( &self, - parts: http::request::Parts, - body: S, + _parts: http::request::Parts, + _body: S, ) -> Result, Self::Error> where - S: n0_future::Stream + Send + 'static, + S: n0_future::Stream> + 'static, { - // Convert stream to reqwest::Body - use futures::StreamExt; - - let mut req = self - .request(parts.method, parts.uri.to_string()) - .body(reqwest_body); - - // Copy headers - for (name, value) in parts.headers.iter() { - req = req.header(name.as_str(), value.as_bytes()); - } - - // Send and convert response - let resp = req.send().await?; - - let mut builder = http::Response::builder().status(resp.status()); - - for (name, value) in resp.headers().iter() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - let stream = resp - .bytes_stream() - .map(|result| result.map_err(|e| StreamError::transport(e))); - let byte_stream = ByteStream::new(stream); - - Ok(builder.body(byte_stream).expect("Failed to build response")) + // WASM reqwest doesn't support streaming request bodies + // This would require ReadableStream/WritableStream integration + unimplemented!("Bidirectional streaming not yet supported on WASM") } } diff --git a/crates/jacquard-common/src/stream.rs b/crates/jacquard-common/src/stream.rs index e9b3effb..f97a73ca 100644 --- a/crates/jacquard-common/src/stream.rs +++ b/crates/jacquard-common/src/stream.rs @@ -159,7 +159,14 @@ impl fmt::Display for StreamError { } use bytes::Bytes; -use n0_future::stream::Boxed; + +/// Boxed stream type with proper Send bounds for native, no Send for WASM +#[cfg(not(target_arch = "wasm32"))] +type Boxed = Pin + Send>>; + +/// Boxed stream type without Send bound for WASM +#[cfg(target_arch = "wasm32")] +type Boxed = Pin>>; /// Platform-agnostic byte stream abstraction pub struct ByteStream { @@ -168,6 +175,7 @@ pub struct ByteStream { impl ByteStream { /// Create a new byte stream from any compatible stream + #[cfg(not(target_arch = "wasm32"))] pub fn new(stream: S) -> Self where S: n0_future::Stream> + Unpin + Send + 'static, @@ -177,6 +185,17 @@ impl ByteStream { } } + /// Create a new byte stream from any compatible stream (WASM) + #[cfg(target_arch = "wasm32")] + pub fn new(stream: S) -> Self + where + S: n0_future::Stream> + Unpin + 'static, + { + Self { + inner: Box::pin(stream), + } + } + /// Check if stream is known to be empty (always false for dynamic streams) pub fn is_empty(&self) -> bool { false diff --git a/crates/jacquard-common/src/xrpc.rs b/crates/jacquard-common/src/xrpc.rs index 48735baa..928d3b4f 100644 --- a/crates/jacquard-common/src/xrpc.rs +++ b/crates/jacquard-common/src/xrpc.rs @@ -24,14 +24,14 @@ pub mod subscription; #[cfg(feature = "streaming")] use crate::StreamError; +use crate::error::DecodeError; use crate::http_client::HttpClient; #[cfg(feature = "streaming")] use crate::http_client::HttpClientExt; use crate::types::value::Data; use crate::{AuthorizationToken, error::AuthError}; use crate::{CowStr, error::XrpcResult}; -use crate::{IntoStatic, error::DecodeError}; -use crate::{error::TransportError, types::value::RawData}; +use crate::{IntoStatic, types::value::RawData}; use bytes::Bytes; use http::{ HeaderName, HeaderValue, Request, StatusCode, @@ -124,11 +124,12 @@ pub trait XrpcRequest: Serialize { /// Decode the request body for procedures. /// /// Default implementation deserializes from JSON. Override for non-JSON encodings. - fn decode_body<'de>(body: &'de [u8]) -> Result, DecodeError> + fn decode_body<'de>(body: &'de [u8]) -> XrpcResult> where Self: Deserialize<'de>, { - let body: Self = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?; + let body: Self = serde_json::from_slice(body) + .map_err(|e| crate::error::ClientError::decode(format!("{:?}", e)))?; Ok(Box::new(body)) } @@ -148,7 +149,7 @@ pub trait XrpcResp { type Output<'de>: Serialize + Deserialize<'de> + IntoStatic; /// Error type for this request - type Err<'de>: Error + Deserialize<'de> + IntoStatic; + type Err<'de>: Error + Deserialize<'de> + Serialize + IntoStatic; /// Output body encoding function, similar to the request-side type fn encode_output(output: &Self::Output<'_>) -> Result, EncodeError> { @@ -158,10 +159,11 @@ pub trait XrpcResp { /// Decode the response output body. /// /// Default implementation deserializes from JSON. Override for non-JSON encodings. - fn decode_output<'de>(body: &'de [u8]) -> Result, DecodeError> + fn decode_output<'de>(body: &'de [u8]) -> core::result::Result, DecodeError> where Self::Output<'de>: Deserialize<'de>, { + #[allow(deprecated)] let body = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?; Ok(body) @@ -444,14 +446,13 @@ impl<'a, C: HttpClient> XrpcCall<'a, C> { R: XrpcRequest, ::Response: Send + Sync, { - let http_request = build_http_request(&self.base, request, &self.opts) - .map_err(crate::error::TransportError::from)?; + let http_request = build_http_request(&self.base, request, &self.opts)?; let http_response = self .client .send_http(http_request) .await - .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; + .map_err(|e| crate::error::ClientError::transport(e))?; process_response(http_response) } @@ -468,9 +469,10 @@ where let status = http_response.status(); // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. + #[allow(deprecated)] if status.as_u16() == 401 { if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { - return Err(crate::error::ClientError::Auth( + return Err(crate::error::ClientError::auth( crate::error::AuthError::Other(hv.clone()), )); } @@ -518,10 +520,12 @@ pub fn build_http_request<'s, R>( base: &Url, req: &R, opts: &CallOptions<'_>, -) -> core::result::Result>, crate::error::TransportError> +) -> XrpcResult>> where R: XrpcRequest, { + use crate::error::ClientError; + let mut url = base.clone(); let mut path = url.path().trim_end_matches('/').to_owned(); path.push_str("/xrpc/"); @@ -529,8 +533,9 @@ where url.set_path(&path); if let XrpcMethod::Query = ::METHOD { - let qs = serde_html_form::to_string(&req) - .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?; + let qs = serde_html_form::to_string(&req).map_err(|e| { + ClientError::invalid_request(format!("Failed to serialize query: {}", e)) + })?; if !qs.is_empty() { url.set_query(Some(&qs)); } else { @@ -558,9 +563,7 @@ where } AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), } - .map_err(|e| { - TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) - })?; + .map_err(|e| ClientError::invalid_request(format!("Invalid authorization token: {}", e)))?; builder = builder.header(Header::Authorization, hv); } @@ -583,14 +586,14 @@ where let body = if let XrpcMethod::Procedure(_) = R::METHOD { req.encode_body() - .map_err(|e| TransportError::InvalidRequest(e.to_string()))? + .map_err(|e| ClientError::invalid_request(format!("Failed to encode body: {}", e)))? } else { vec![] }; builder .body(body) - .map_err(|e| TransportError::InvalidRequest(e.to_string())) + .map_err(|e| ClientError::invalid_request(format!("Failed to build request: {}", e))) } /// XRPC response wrapper that owns the response buffer @@ -980,6 +983,59 @@ where } } +impl Serialize for XrpcError +where + E: std::error::Error + IntoStatic + Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + match self { + // Typed errors already serialize to correct atproto format + XrpcError::Xrpc(e) => e.serialize(serializer), + // Generic errors already have correct format + XrpcError::Generic(g) => g.serialize(serializer), + // Auth and Decode need manual mapping to {"error": "...", "message": ...} + XrpcError::Auth(auth) => { + let mut state = serializer.serialize_struct("XrpcError", 2)?; + let (error, message) = match auth { + AuthError::TokenExpired => ("ExpiredToken", Some("Access token has expired")), + AuthError::InvalidToken => { + ("InvalidToken", Some("Access token is invalid or malformed")) + } + AuthError::RefreshFailed => { + ("RefreshFailed", Some("Token refresh request failed")) + } + AuthError::NotAuthenticated => ( + "AuthenticationRequired", + Some("Request requires authentication but none was provided"), + ), + AuthError::Other(hv) => { + let msg = hv.to_str().unwrap_or("[non-utf8 header]"); + ("AuthenticationError", Some(msg)) + } + }; + state.serialize_field("error", error)?; + if let Some(msg) = message { + state.serialize_field("message", msg)?; + } + state.end() + } + XrpcError::Decode(decode_err) => { + let mut state = serializer.serialize_struct("XrpcError", 2)?; + state.serialize_field("error", "ResponseDecodeError")?; + // Convert DecodeError to string for message field + let msg = format!("{:?}", decode_err); + state.serialize_field("message", &msg)?; + state.end() + } + } + } +} + #[cfg(feature = "streaming")] impl<'a, C: HttpClient + HttpClientExt> XrpcCall<'a, C> { /// Send an XRPC call and stream the binary response. @@ -1016,7 +1072,6 @@ impl<'a, C: HttpClient + HttpClientExt> XrpcCall<'a, C> { <::Response as XrpcStreamResp>::Frame<'static>: XrpcStreamResp, { use futures::TryStreamExt; - use n0_future::StreamExt; let mut url = self.base; let mut path = url.path().trim_end_matches('/').to_owned(); @@ -1061,7 +1116,7 @@ impl<'a, C: HttpClient + HttpClientExt> XrpcCall<'a, C> { .map_err(|e| StreamError::protocol(e.to_string()))? .into_parts(); - let body_stream = stream.0.map_ok(|f| f.buffer).boxed(); + let body_stream = Box::pin(stream.0.map_ok(|f| f.buffer)); let resp = self .client @@ -1086,7 +1141,7 @@ mod tests { #[allow(dead_code)] struct DummyReq; - #[derive(Deserialize, Debug, thiserror::Error)] + #[derive(Deserialize, Serialize, Debug, thiserror::Error)] #[error("{0}")] struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>); @@ -1153,7 +1208,7 @@ mod tests { fn no_double_slash_in_path() { #[derive(Serialize, Deserialize)] struct Req; - #[derive(Deserialize, Debug, thiserror::Error)] + #[derive(Deserialize, Serialize, Debug, thiserror::Error)] #[error("{0}")] struct Err<'a>(#[serde(borrow)] CowStr<'a>); impl IntoStatic for Err<'_> { diff --git a/crates/jacquard-common/src/xrpc/streaming.rs b/crates/jacquard-common/src/xrpc/streaming.rs index 7fcfc9d3..4f635de4 100644 --- a/crates/jacquard-common/src/xrpc/streaming.rs +++ b/crates/jacquard-common/src/xrpc/streaming.rs @@ -3,12 +3,20 @@ use crate::{IntoStatic, StreamError, stream::ByteStream, xrpc::XrpcRequest}; use bytes::Bytes; use http::StatusCode; -use n0_future::{StreamExt, TryStreamExt, stream::Boxed}; +use n0_future::{StreamExt, TryStreamExt}; use serde::{Deserialize, Serialize}; #[cfg(not(target_arch = "wasm32"))] use std::path::Path; use std::{marker::PhantomData, pin::Pin}; +/// Boxed stream type with proper Send bounds for native, no Send for WASM +#[cfg(not(target_arch = "wasm32"))] +type Boxed = Pin + Send>>; + +/// Boxed stream type without Send bound for WASM +#[cfg(target_arch = "wasm32")] +type Boxed = Pin>>; + /// Trait for streaming XRPC procedures (bidirectional streaming). /// /// Defines frame encoding/decoding for procedures that send/receive streams of data. @@ -145,10 +153,9 @@ where

::Frame<'static>: Serialize, { let stream = s - .map(|f| P::encode_frame(f).map(|b| XrpcStreamFrame::new_typed::>(b))) - .boxed(); + .map(|f| P::encode_frame(f).map(|b| XrpcStreamFrame::new_typed::>(b))); - XrpcProcedureSend(stream) + XrpcProcedureSend(Box::pin(stream)) } /// Sending stream for streaming XRPC procedure uplink. @@ -172,10 +179,9 @@ impl XrpcResponseStream { pub fn from_bytestream(StreamingResponse { parts, body }: StreamingResponse) -> Self { Self { parts, - body: body + body: Box::pin(body .into_inner() - .map_ok(|b| XrpcStreamFrame::new(b)) - .boxed(), + .map_ok(|b| XrpcStreamFrame::new(b))), } } @@ -183,10 +189,9 @@ impl XrpcResponseStream { pub fn from_parts(parts: http::response::Parts, body: ByteStream) -> Self { Self { parts, - body: body + body: Box::pin(body .into_inner() - .map_ok(|b| XrpcStreamFrame::new(b)) - .boxed(), + .map_ok(|b| XrpcStreamFrame::new(b))), } } @@ -194,13 +199,13 @@ impl XrpcResponseStream { pub fn into_parts(self) -> (http::response::Parts, ByteStream) { ( self.parts, - ByteStream::new(self.body.map_ok(|f| f.buffer).boxed()), + ByteStream::new(Box::pin(self.body.map_ok(|f| f.buffer))), ) } /// Consume and return just the body stream pub fn into_bytestream(self) -> ByteStream { - ByteStream::new(self.body.map_ok(|f| f.buffer).boxed()) + ByteStream::new(Box::pin(self.body.map_ok(|f| f.buffer))) } } @@ -209,10 +214,9 @@ impl XrpcResponseStream { pub fn from_stream(StreamingResponse { parts, body }: StreamingResponse) -> Self { Self { parts, - body: body + body: Box::pin(body .into_inner() - .map_ok(|b| XrpcStreamFrame::new_typed::>(b)) - .boxed(), + .map_ok(|b| XrpcStreamFrame::new_typed::>(b))), } } @@ -220,10 +224,9 @@ impl XrpcResponseStream { pub fn from_typed_parts(parts: http::response::Parts, body: ByteStream) -> Self { Self { parts, - body: body + body: Box::pin(body .into_inner() - .map_ok(|b| XrpcStreamFrame::new_typed::>(b)) - .boxed(), + .map_ok(|b| XrpcStreamFrame::new_typed::>(b))), } } } @@ -231,7 +234,7 @@ impl XrpcResponseStream { impl XrpcResponseStream { /// Consume the typed stream and return just the raw byte stream pub fn into_bytestream(self) -> ByteStream { - ByteStream::new(self.body.map_ok(|f| f.buffer).boxed()) + ByteStream::new(Box::pin(self.body.map_ok(|f| f.buffer))) } } diff --git a/crates/jacquard-identity/src/lib.rs b/crates/jacquard-identity/src/lib.rs index c2f59621..161c1ca0 100644 --- a/crates/jacquard-identity/src/lib.rs +++ b/crates/jacquard-identity/src/lib.rs @@ -79,7 +79,6 @@ use jacquard_api::com_atproto::identity::resolve_did; use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle; #[cfg(feature = "streaming")] use jacquard_common::ByteStream; -use jacquard_common::error::TransportError; use jacquard_common::http_client::HttpClient; use jacquard_common::types::did::Did; use jacquard_common::types::did_doc::DidDocument; @@ -169,17 +168,17 @@ impl JacquardResolver { /// /// - `did:web:example.com` → `https://example.com/.well-known/did.json` /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json` - fn did_web_url(&self, did: &Did<'_>) -> Result { + fn did_web_url(&self, did: &Did<'_>) -> resolver::Result { // did:web:example.com[:path:segments] let s = did.as_str(); let rest = s .strip_prefix("did:web:") - .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; + .ok_or_else(|| IdentityError::unsupported_did_method(s))?; let mut parts = rest.split(':'); let host = parts .next() - .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; - let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?; + .ok_or_else(|| IdentityError::unsupported_did_method(s))?; + let mut url = Url::parse(&format!("https://{host}/"))?; let path: Vec<&str> = parts.collect(); if path.is_empty() { url.set_path(".well-known/did.json"); @@ -187,7 +186,7 @@ impl JacquardResolver { // Append path segments and did.json let mut segments = url .path_segments_mut() - .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?; + .map_err(|_| IdentityError::url(ParseError::SetHostOnCannotBeABaseUrl))?; for seg in path { // Minimally percent-decode each segment per spec guidance let decoded = percent_decode_str(seg).decode_utf8_lossy(); @@ -205,36 +204,26 @@ impl JacquardResolver { self.did_web_url(&did).unwrap().to_string() } - async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { - let resp = self - .http - .get(url) - .send() - .await - .map_err(TransportError::from)?; + async fn get_json_bytes(&self, url: Url) -> resolver::Result<(Bytes, StatusCode)> { + let resp = self.http.get(url).send().await?; let status = resp.status(); - let buf = resp.bytes().await.map_err(TransportError::from)?; + let buf = resp.bytes().await?; Ok((buf, status)) } - async fn get_text(&self, url: Url) -> Result { - let resp = self - .http - .get(url) - .send() - .await - .map_err(TransportError::from)?; + async fn get_text(&self, url: Url) -> resolver::Result { + let resp = self.http.get(url).send().await?; if resp.status() == StatusCode::OK { - Ok(resp.text().await.map_err(TransportError::from)?) + Ok(resp.text().await?) } else { - Err(IdentityError::Http( - resp.error_for_status().unwrap_err().into(), + Err(IdentityError::transport( + resp.error_for_status().unwrap_err(), )) } } #[cfg(feature = "dns")] - async fn dns_txt(&self, name: &str) -> Result, IdentityError> { + async fn dns_txt(&self, name: &str) -> resolver::Result> { let Some(dns) = &self.dns else { return Ok(vec![]); }; @@ -249,12 +238,12 @@ impl JacquardResolver { Ok(out) } - fn parse_atproto_did_body(body: &str) -> Result, IdentityError> { + fn parse_atproto_did_body(body: &str) -> resolver::Result> { let line = body .lines() .find(|l| !l.trim().is_empty()) - .ok_or(IdentityError::InvalidWellKnown)?; - let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?; + .ok_or_else(|| IdentityError::invalid_well_known())?; + let did = Did::new(line.trim()).map_err(|_| IdentityError::invalid_well_known())?; Ok(did.into_static()) } } @@ -264,10 +253,10 @@ impl JacquardResolver { pub async fn resolve_handle_via_pds( &self, handle: &Handle<'_>, - ) -> Result, IdentityError> { + ) -> resolver::Result> { let pds = match &self.opts.pds_fallback { Some(u) => u.clone(), - None => return Err(IdentityError::InvalidWellKnown), + None => return Err(IdentityError::invalid_well_known()), }; let req = ResolveHandle::new() .handle(handle.clone().into_static()) @@ -277,23 +266,23 @@ impl JacquardResolver { .xrpc(pds) .send(&req) .await - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; + .map_err(|e| IdentityError::xrpc(e.to_string()))?; let out = resp .parse() - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; + .map_err(|e| IdentityError::xrpc(e.to_string()))?; Did::new_owned(out.did.as_str()) .map(|d| d.into_static()) - .map_err(|_| IdentityError::InvalidWellKnown) + .map_err(|_| IdentityError::invalid_well_known()) } /// Fetch DID document via PDS resolveDid (returns owned DidDocument) pub async fn fetch_did_doc_via_pds_owned( &self, did: &Did<'_>, - ) -> Result, IdentityError> { + ) -> resolver::Result> { let pds = match &self.opts.pds_fallback { Some(u) => u.clone(), - None => return Err(IdentityError::InvalidWellKnown), + None => return Err(IdentityError::invalid_well_known()), }; let req = resolve_did::ResolveDid::new().did(did.clone()).build(); let resp = self @@ -301,10 +290,10 @@ impl JacquardResolver { .xrpc(pds) .send(&req) .await - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; + .map_err(|e| IdentityError::xrpc(e.to_string()))?; let out = resp .parse() - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; + .map_err(|e| IdentityError::xrpc(e.to_string()))?; let doc_json = serde_json::to_value(&out.did_doc)?; let s = serde_json::to_string(&doc_json)?; let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; @@ -316,12 +305,12 @@ impl JacquardResolver { pub async fn fetch_mini_doc_via_slingshot( &self, did: &Did<'_>, - ) -> Result { + ) -> resolver::Result { let base = match &self.opts.plc_source { PlcSource::Slingshot { base } => base.clone(), _ => { - return Err(IdentityError::UnsupportedDidMethod( - "mini-doc requires Slingshot source".into(), + return Err(IdentityError::unsupported_did_method( + "mini-doc requires Slingshot source", )); } }; @@ -348,7 +337,7 @@ impl IdentityResolver for JacquardResolver { &self.opts } #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(handle = %handle)))] - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result, IdentityError> { + async fn resolve_handle(&self, handle: &Handle<'_>) -> resolver::Result> { let host = handle.as_str(); for step in &self.opts.handle_order { match step { @@ -433,11 +422,11 @@ impl IdentityResolver for JacquardResolver { } } } - Err(IdentityError::InvalidWellKnown) + Err(IdentityError::invalid_well_known()) } #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(did = %did)))] - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result { + async fn resolve_did_doc(&self, did: &Did<'_>) -> resolver::Result { let s = did.as_str(); for step in &self.opts.did_order { match step { @@ -491,7 +480,7 @@ impl IdentityResolver for JacquardResolver { _ => {} } } - Err(IdentityError::UnsupportedDidMethod(s.to_string())) + Err(IdentityError::unsupported_did_method(s)) } } @@ -517,6 +506,7 @@ impl jacquard_common::http_client::HttpClientExt for JacquardResolver { } /// Send HTTP request with streaming body and receive streaming response + #[cfg(not(target_arch = "wasm32"))] fn send_http_bidirectional( &self, parts: http::request::Parts, @@ -529,6 +519,19 @@ impl jacquard_common::http_client::HttpClientExt for JacquardResolver { { self.http.send_http_bidirectional(parts, body) } + + /// Send HTTP request with streaming body and receive streaming response (WASM) + #[cfg(target_arch = "wasm32")] + fn send_http_bidirectional( + &self, + parts: http::request::Parts, + body: S, + ) -> impl Future, Self::Error>> + where + S: n0_future::Stream> + 'static, + { + self.http.send_http_bidirectional(parts, body) + } } /// Warnings produced during identity checks that are not fatal @@ -547,16 +550,16 @@ impl JacquardResolver { pub async fn resolve_handle_and_doc( &self, handle: &Handle<'_>, - ) -> Result<(Did<'static>, DidDocResponse, Vec), IdentityError> { + ) -> resolver::Result<(Did<'static>, DidDocResponse, Vec)> { let did = self.resolve_handle(handle).await?; let resp = self.resolve_did_doc(&did).await?; let resp_for_parse = resp.clone(); let doc_borrowed = resp_for_parse.parse()?; if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() { - return Err(IdentityError::DocIdMismatch { - expected: did.clone().into_static(), - doc: doc_borrowed.clone().into_static(), - }); + return Err(IdentityError::doc_id_mismatch( + did.clone().into_static(), + doc_borrowed.clone().into_static(), + )); } let mut warnings = Vec::new(); // Check handle alias presence (soft warning) @@ -575,7 +578,7 @@ impl JacquardResolver { } /// Build Slingshot mini-doc URL for an identifier (handle or DID) - fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result { + fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> resolver::Result { let mut url = base.clone(); url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); url.set_query(Some(&format!( @@ -589,12 +592,12 @@ impl JacquardResolver { pub async fn fetch_mini_doc_via_slingshot_identifier( &self, identifier: &AtIdentifier<'_>, - ) -> Result { + ) -> resolver::Result { let base = match &self.opts.plc_source { PlcSource::Slingshot { base } => base.clone(), _ => { - return Err(IdentityError::UnsupportedDidMethod( - "mini-doc requires Slingshot source".into(), + return Err(IdentityError::unsupported_did_method( + "mini-doc requires Slingshot source", )); } }; @@ -616,11 +619,11 @@ pub struct MiniDocResponse { impl MiniDocResponse { /// Parse borrowed MiniDoc - pub fn parse<'b>(&'b self) -> Result, IdentityError> { + pub fn parse<'b>(&'b self) -> resolver::Result> { if self.status.is_success() { serde_json::from_slice::>(&self.buffer).map_err(IdentityError::from) } else { - Err(IdentityError::HttpStatus(self.status)) + Err(IdentityError::http_status(self.status)) } } } @@ -726,7 +729,12 @@ mod tests { status: StatusCode::BAD_REQUEST, }; match resp.parse() { - Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST), + Err(e) => match e.kind() { + resolver::IdentityErrorKind::HttpStatus(s) => { + assert_eq!(*s, StatusCode::BAD_REQUEST) + } + _ => panic!("unexpected error kind: {:?}", e), + }, other => panic!("unexpected: {:?}", other), } } diff --git a/crates/jacquard-identity/src/resolver.rs b/crates/jacquard-identity/src/resolver.rs index 87daf8cf..d63a7565 100644 --- a/crates/jacquard-identity/src/resolver.rs +++ b/crates/jacquard-identity/src/resolver.rs @@ -12,93 +12,20 @@ use bon::Builder; use bytes::Bytes; use http::StatusCode; -use jacquard_common::error::TransportError; +use jacquard_common::error::BoxError; use jacquard_common::types::did::Did; use jacquard_common::types::did_doc::{DidDocument, Service}; use jacquard_common::types::ident::AtIdentifier; use jacquard_common::types::string::{AtprotoStr, Handle}; use jacquard_common::types::uri::Uri; use jacquard_common::types::value::{AtDataError, Data}; -use jacquard_common::{CowStr, IntoStatic}; -use miette::Diagnostic; +use jacquard_common::{CowStr, IntoStatic, smol_str}; +use smol_str::SmolStr; use std::collections::BTreeMap; use std::marker::Sync; use std::str::FromStr; -use thiserror::Error; use url::Url; -/// Errors that can occur during identity resolution. -/// -/// Note: when validating a fetched DID document against a requested DID, a -/// `DocIdMismatch` error is returned that includes the owned document so callers -/// can inspect it and decide how to proceed. -#[derive(Debug, Error, Diagnostic)] -#[allow(missing_docs)] -pub enum IdentityError { - #[error("unsupported DID method: {0}")] - #[diagnostic( - code(jacquard_identity::unsupported_did_method), - help("supported DID methods: did:web, did:plc") - )] - UnsupportedDidMethod(String), - #[error("invalid well-known atproto-did content")] - #[diagnostic( - code(jacquard_identity::invalid_well_known), - help("expected first non-empty line to be a DID") - )] - InvalidWellKnown, - #[error("missing PDS endpoint in DID document")] - #[diagnostic(code(jacquard_identity::missing_pds_endpoint))] - MissingPdsEndpoint, - #[error("HTTP error: {0}")] - #[diagnostic( - code(jacquard_identity::http), - help("check network connectivity and TLS configuration") - )] - Http(#[from] TransportError), - #[error("HTTP status {0}")] - #[diagnostic( - code(jacquard_identity::http_status), - help("verify well-known paths or PDS XRPC endpoints") - )] - HttpStatus(StatusCode), - #[error("XRPC error: {0}")] - #[diagnostic( - code(jacquard_identity::xrpc), - help("enable PDS fallback or public resolver if needed") - )] - Xrpc(String), - #[error("URL parse error: {0}")] - #[diagnostic(code(jacquard_identity::url))] - Url(#[from] url::ParseError), - #[error("DNS error: {0}")] - #[cfg(all(feature = "dns", not(target_family = "wasm")))] - #[diagnostic(code(jacquard_identity::dns))] - Dns(#[from] hickory_resolver::error::ResolveError), - #[error("serialize/deserialize error: {0}")] - #[diagnostic(code(jacquard_identity::serde))] - Serde(#[from] serde_json::Error), - #[error("invalid DID document: {0}")] - #[diagnostic( - code(jacquard_identity::invalid_doc), - help("validate keys and services; ensure AtprotoPersonalDataServer service exists") - )] - InvalidDoc(String), - #[error(transparent)] - #[diagnostic(code(jacquard_identity::data))] - Data(#[from] AtDataError), - /// DID document id did not match requested DID; includes the fetched document - #[error("DID doc id mismatch")] - #[diagnostic( - code(jacquard_identity::doc_id_mismatch), - help("document id differs from requested DID; do not trust this document") - )] - DocIdMismatch { - expected: Did<'static>, - doc: DidDocument<'static>, - }, -} - /// Source to fetch PLC (did:plc) documents from. /// /// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). @@ -155,7 +82,7 @@ pub struct DidDocResponse { impl DidDocResponse { /// Parse as borrowed DidDocument<'_> - pub fn parse<'b>(&'b self) -> Result, IdentityError> { + pub fn parse<'b>(&'b self) -> Result> { if self.status.is_success() { if let Ok(doc) = serde_json::from_slice::>(&self.buffer) { Ok(doc) @@ -175,31 +102,31 @@ impl DidDocResponse { extra_data: BTreeMap::new(), }) } else { - Err(IdentityError::MissingPdsEndpoint) + Err(IdentityError::missing_pds_endpoint()) } } else { - Err(IdentityError::HttpStatus(self.status)) + Err(IdentityError::http_status(self.status)) } } /// Parse and validate that the DID in the document matches the requested DID if present. /// /// On mismatch, returns an error that contains the owned document for inspection. - pub fn parse_validated<'b>(&'b self) -> Result, IdentityError> { + pub fn parse_validated<'b>(&'b self) -> Result> { let doc = self.parse()?; if let Some(expected) = &self.requested { if doc.id.as_str() != expected.as_str() { - return Err(IdentityError::DocIdMismatch { - expected: expected.clone(), - doc: doc.clone().into_static(), - }); + return Err(IdentityError::doc_id_mismatch( + expected.clone(), + doc.clone().into_static(), + )); } } Ok(doc) } /// Parse as owned DidDocument<'static> - pub fn into_owned(self) -> Result, IdentityError> { + pub fn into_owned(self) -> Result> { if self.status.is_success() { if let Ok(doc) = serde_json::from_slice::>(&self.buffer) { Ok(doc.into_static()) @@ -220,10 +147,10 @@ impl DidDocResponse { } .into_static()) } else { - Err(IdentityError::MissingPdsEndpoint) + Err(IdentityError::missing_pds_endpoint()) } } else { - Err(IdentityError::HttpStatus(self.status)) + Err(IdentityError::http_status(self.status)) } } } @@ -334,42 +261,30 @@ pub trait IdentityResolver { /// Resolve handle #[cfg(not(target_arch = "wasm32"))] - fn resolve_handle( - &self, - handle: &Handle<'_>, - ) -> impl Future, IdentityError>> + fn resolve_handle(&self, handle: &Handle<'_>) -> impl Future>> where Self: Sync; /// Resolve handle #[cfg(target_arch = "wasm32")] - fn resolve_handle( - &self, - handle: &Handle<'_>, - ) -> impl Future, IdentityError>>; + fn resolve_handle(&self, handle: &Handle<'_>) -> impl Future>>; /// Resolve DID document #[cfg(not(target_arch = "wasm32"))] - fn resolve_did_doc( - &self, - did: &Did<'_>, - ) -> impl Future> + fn resolve_did_doc(&self, did: &Did<'_>) -> impl Future> where Self: Sync; /// Resolve DID document #[cfg(target_arch = "wasm32")] - fn resolve_did_doc( - &self, - did: &Did<'_>, - ) -> impl Future>; + fn resolve_did_doc(&self, did: &Did<'_>) -> impl Future>; /// Resolve DID doc from an identifier #[cfg(not(target_arch = "wasm32"))] fn resolve_ident( &self, actor: &AtIdentifier<'_>, - ) -> impl Future> + ) -> impl Future> where Self: Sync, { @@ -389,7 +304,7 @@ pub trait IdentityResolver { fn resolve_ident( &self, actor: &AtIdentifier<'_>, - ) -> impl Future> { + ) -> impl Future> { async move { match actor { AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, @@ -406,7 +321,7 @@ pub trait IdentityResolver { fn resolve_ident_owned( &self, actor: &AtIdentifier<'_>, - ) -> impl Future, IdentityError>> + ) -> impl Future>> where Self: Sync, { @@ -426,7 +341,7 @@ pub trait IdentityResolver { fn resolve_ident_owned( &self, actor: &AtIdentifier<'_>, - ) -> impl Future, IdentityError>> { + ) -> impl Future>> { async move { match actor { AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, @@ -443,7 +358,7 @@ pub trait IdentityResolver { fn resolve_did_doc_owned( &self, did: &Did<'_>, - ) -> impl Future, IdentityError>> + ) -> impl Future>> where Self: Sync, { @@ -455,13 +370,13 @@ pub trait IdentityResolver { fn resolve_did_doc_owned( &self, did: &Did<'_>, - ) -> impl Future, IdentityError>> { + ) -> impl Future>> { async { self.resolve_did_doc(did).await?.into_owned() } } /// Return the PDS url for a DID #[cfg(not(target_arch = "wasm32"))] - fn pds_for_did(&self, did: &Did<'_>) -> impl Future> + fn pds_for_did(&self, did: &Did<'_>) -> impl Future> where Self: Sync, { @@ -471,32 +386,34 @@ pub trait IdentityResolver { // Default-on doc id equality check if self.options().validate_doc_id { if doc.id.as_str() != did.as_str() { - return Err(IdentityError::DocIdMismatch { - expected: did.clone().into_static(), - doc: doc.clone().into_static(), - }); + return Err(IdentityError::doc_id_mismatch( + did.clone().into_static(), + doc.clone().into_static(), + )); } } - doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) + doc.pds_endpoint() + .ok_or_else(|| IdentityError::missing_pds_endpoint()) } } /// Return the PDS url for a DID #[cfg(target_arch = "wasm32")] - fn pds_for_did(&self, did: &Did<'_>) -> impl Future> { + fn pds_for_did(&self, did: &Did<'_>) -> impl Future> { async { let resp = self.resolve_did_doc(did).await?; let doc = resp.parse()?; // Default-on doc id equality check if self.options().validate_doc_id { if doc.id.as_str() != did.as_str() { - return Err(IdentityError::DocIdMismatch { - expected: did.clone().into_static(), - doc: doc.clone().into_static(), - }); + return Err(IdentityError::doc_id_mismatch( + did.clone().into_static(), + doc.clone().into_static(), + )); } } - doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) + doc.pds_endpoint() + .ok_or_else(|| IdentityError::missing_pds_endpoint()) } } @@ -505,7 +422,7 @@ pub trait IdentityResolver { fn pds_for_handle( &self, handle: &Handle<'_>, - ) -> impl Future, Url), IdentityError>> + ) -> impl Future, Url)>> where Self: Sync, { @@ -521,7 +438,7 @@ pub trait IdentityResolver { fn pds_for_handle( &self, handle: &Handle<'_>, - ) -> impl Future, Url), IdentityError>> { + ) -> impl Future, Url)>> { async { let did = self.resolve_handle(handle).await?; let pds = self.pds_for_did(&did).await?; @@ -537,12 +454,12 @@ impl IdentityResolver for std::sync::Arc { } /// Resolve handle - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result, IdentityError> { + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result> { self.as_ref().resolve_handle(handle).await } /// Resolve DID document - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result { + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result { self.as_ref().resolve_did_doc(did).await } } @@ -554,16 +471,278 @@ impl IdentityResolver for std::sync::Arc { } /// Resolve handle - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result, IdentityError> { + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result> { self.as_ref().resolve_handle(handle).await } /// Resolve DID document - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result { + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result { self.as_ref().resolve_did_doc(did).await } } +/// Error type for identity resolution operations +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{kind}")] +pub struct IdentityError { + #[diagnostic_source] + kind: IdentityErrorKind, + #[source] + source: Option, + #[help] + help: Option, + context: Option, +} + +/// Error categories for identity resolution +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum IdentityErrorKind { + /// Unsupported DID method + #[error("unsupported DID method: {0}")] + #[diagnostic( + code(jacquard::identity::unsupported_method), + help("supported DID methods: did:web, did:plc") + )] + UnsupportedDidMethod(SmolStr), + + /// Invalid well-known atproto-did content + #[error("invalid well-known atproto-did content")] + #[diagnostic( + code(jacquard::identity::invalid_well_known), + help("expected first non-empty line to be a DID") + )] + InvalidWellKnown, + + /// Missing PDS endpoint in DID document + #[error("missing PDS endpoint in DID document")] + #[diagnostic( + code(jacquard::identity::missing_pds), + help("ensure DID document contains AtprotoPersonalDataServer service") + )] + MissingPdsEndpoint, + + /// Transport-level error + #[error("transport error")] + #[diagnostic( + code(jacquard::identity::transport), + help("check network connectivity and TLS configuration") + )] + Transport, + + /// HTTP status error + #[error("HTTP {0}")] + #[diagnostic( + code(jacquard::identity::http_status), + help("verify well-known paths or PDS XRPC endpoints") + )] + HttpStatus(StatusCode), + + /// XRPC error + #[error("XRPC error: {0}")] + #[diagnostic( + code(jacquard::identity::xrpc), + help("enable PDS fallback or public resolver if needed") + )] + Xrpc(SmolStr), + + /// URL parse error + #[error("URL parse error")] + #[diagnostic(code(jacquard::identity::url))] + Url, + + /// DNS resolution error + #[cfg(all(feature = "dns", not(target_family = "wasm")))] + #[error("DNS resolution error")] + #[diagnostic( + code(jacquard::identity::dns), + help("check DNS configuration and connectivity") + )] + Dns, + + /// Serialization/deserialization error + #[error("serialization error")] + #[diagnostic(code(jacquard::identity::serialization))] + Serialization, + + /// Invalid DID document + #[error("invalid DID document: {0}")] + #[diagnostic( + code(jacquard::identity::invalid_doc), + help("validate keys and services in DID document") + )] + InvalidDoc(SmolStr), + + /// DID document id mismatch - includes the fetched document for inspection + #[error("DID document id mismatch")] + #[diagnostic( + code(jacquard::identity::doc_mismatch), + help("document id differs from requested DID; do not trust this document") + )] + DocIdMismatch { + expected: Did<'static>, + doc: DidDocument<'static>, + }, +} + +impl IdentityError { + /// Create a new error with the given kind and optional source + pub fn new(kind: IdentityErrorKind, source: Option) -> Self { + Self { + kind, + source, + help: None, + context: None, + } + } + + /// Get the error kind + pub fn kind(&self) -> &IdentityErrorKind { + &self.kind + } + + /// Get the source error if present + pub fn source_err(&self) -> Option<&BoxError> { + self.source.as_ref() + } + + /// Get the context string if present + pub fn context(&self) -> Option<&str> { + self.context.as_ref().map(|s| s.as_str()) + } + + /// Add help text to this error + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Add context to this error + pub fn with_context(mut self, context: impl Into) -> Self { + self.context = Some(context.into()); + self + } + + // Constructors for each kind + + /// Create an unsupported DID method error + pub fn unsupported_did_method(method: impl Into) -> Self { + Self::new(IdentityErrorKind::UnsupportedDidMethod(method.into()), None) + } + + /// Create an invalid well-known error + pub fn invalid_well_known() -> Self { + Self::new(IdentityErrorKind::InvalidWellKnown, None) + } + + /// Create a missing PDS endpoint error + pub fn missing_pds_endpoint() -> Self { + Self::new(IdentityErrorKind::MissingPdsEndpoint, None) + } + + /// Create a transport error + pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(IdentityErrorKind::Transport, Some(Box::new(source))) + } + + /// Create an HTTP status error + pub fn http_status(status: StatusCode) -> Self { + Self::new(IdentityErrorKind::HttpStatus(status), None) + } + + /// Create an XRPC error + pub fn xrpc(msg: impl Into) -> Self { + Self::new(IdentityErrorKind::Xrpc(msg.into()), None) + } + + /// Create a URL parse error + pub fn url(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(IdentityErrorKind::Url, Some(Box::new(source))) + } + + /// Create a DNS error + #[cfg(all(feature = "dns", not(target_family = "wasm")))] + pub fn dns(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(IdentityErrorKind::Dns, Some(Box::new(source))) + } + + /// Create a serialization error + pub fn serialization(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(IdentityErrorKind::Serialization, Some(Box::new(source))) + } + + /// Create an invalid doc error + pub fn invalid_doc(msg: impl Into) -> Self { + Self::new(IdentityErrorKind::InvalidDoc(msg.into()), None) + } + + /// Create a doc id mismatch error + pub fn doc_id_mismatch(expected: Did<'static>, doc: DidDocument<'static>) -> Self { + Self::new(IdentityErrorKind::DocIdMismatch { expected, doc }, None) + } +} + +/// Result type for identity operations +pub type Result = std::result::Result; + +// ============================================================================ +// Conversions from external errors +// ============================================================================ + +// #[allow(deprecated)] +// impl From for IdentityError { +// fn from(e: jacquard_common::error::TransportError) -> Self { +// Self::transport(e).with_context("transport-level error during identity resolution") +// } +// } + +impl From for IdentityError { + fn from(e: url::ParseError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(IdentityErrorKind::Url, Some(Box::new(e))).with_context(msg) + } +} + +// Identity resolution errors -> ClientError +impl From for jacquard_common::error::ClientError { + fn from(e: IdentityError) -> Self { + Self::identity_resolution(e) + } +} + +#[cfg(all(feature = "dns", not(target_family = "wasm")))] +impl From for IdentityError { + fn from(e: hickory_resolver::error::ResolveError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(IdentityErrorKind::Dns, Some(Box::new(e))) + .with_context(msg) + .with_help("check DNS configuration and network connectivity") + } +} + +impl From for IdentityError { + fn from(e: serde_json::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(IdentityErrorKind::Serialization, Some(Box::new(e))) + .with_context(msg) + .with_help("ensure response is valid JSON") + } +} + +impl From for IdentityError { + fn from(e: AtDataError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(IdentityErrorKind::Serialization, Some(Box::new(e))) + .with_context(msg) + .with_help("AT Protocol data validation failed") + } +} + +impl From for IdentityError { + fn from(e: reqwest::Error) -> Self { + Self::transport(e).with_context("HTTP request failed during identity resolution") + } +} + #[cfg(test)] mod tests { use super::*; @@ -590,10 +769,13 @@ mod tests { requested: Some(requested), }; match resp.parse_validated() { - Err(IdentityError::DocIdMismatch { expected, doc }) => { - assert_eq!(expected.as_str(), "did:plc:alice"); - assert_eq!(doc.id.as_str(), "did:plc:bob"); - } + Err(e) => match e.kind() { + IdentityErrorKind::DocIdMismatch { expected, doc } => { + assert_eq!(expected.as_str(), "did:plc:alice"); + assert_eq!(doc.id.as_str(), "did:plc:bob"); + } + _ => panic!("unexpected error kind: {:?}", e), + }, other => panic!("unexpected result: {:?}", other), } } diff --git a/crates/jacquard-oauth/src/client.rs b/crates/jacquard-oauth/src/client.rs index 29dcc814..2c0b8367 100644 --- a/crates/jacquard-oauth/src/client.rs +++ b/crates/jacquard-oauth/src/client.rs @@ -11,7 +11,7 @@ use crate::{ }; use jacquard_common::{ AuthorizationToken, CowStr, IntoStatic, - error::{AuthError, ClientError, TransportError, XrpcResult}, + error::{AuthError, ClientError, XrpcResult}, http_client::HttpClient, types::{did::Did, string::Handle}, xrpc::{ @@ -493,14 +493,14 @@ where .dpop_call(&mut dpop) .send(build_http_request(&base_uri, &request, &opts)?) .await - .map_err(|e| TransportError::Other(Box::new(e)))?; + .map_err(|e| ClientError::transport(e))?; let resp = process_response(http_response); drop(guard); if is_invalid_token_response(&resp) { opts.auth = Some( self.refresh() .await - .map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?, + .map_err(|e| ClientError::transport(e))?, ); let guard = self.data.read().await; let mut dpop = guard.dpop_data.clone(); @@ -509,7 +509,7 @@ where .dpop_call(&mut dpop) .send(build_http_request(&base_uri, &request, &opts)?) .await - .map_err(|e| TransportError::Other(Box::new(e)))?; + .map_err(|e| ClientError::transport(e))?; process_response(http_response) } else { resp @@ -538,6 +538,7 @@ where self.client.send_http_streaming(request).await } + #[cfg(not(target_arch = "wasm32"))] async fn send_http_bidirectional( &self, parts: http::request::Parts, @@ -551,6 +552,20 @@ where { self.client.send_http_bidirectional(parts, body).await } + + #[cfg(target_arch = "wasm32")] + async fn send_http_bidirectional( + &self, + parts: http::request::Parts, + body: Str, + ) -> core::result::Result, Self::Error> + where + Str: n0_future::Stream< + Item = core::result::Result, + > + 'static, + { + self.client.send_http_bidirectional(parts, body).await + } } #[cfg(feature = "streaming")] @@ -626,7 +641,7 @@ where <::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp, { use jacquard_common::StreamError; - use n0_future::{StreamExt, TryStreamExt}; + use n0_future::TryStreamExt; let base_uri = self.base_uri().await; let mut opts = self.options.read().await.clone(); @@ -677,7 +692,7 @@ where .into_parts(); let body_stream = - jacquard_common::stream::ByteStream::new(stream.0.map_ok(|f| f.buffer).boxed()); + jacquard_common::stream::ByteStream::new(Box::pin(stream.0.map_ok(|f| f.buffer))); let guard = self.data.read().await; let mut dpop = guard.dpop_data.clone(); @@ -707,16 +722,20 @@ where } fn is_invalid_token_response(response: &XrpcResult>) -> bool { + use jacquard_common::error::ClientErrorKind; + match response { - Err(ClientError::Auth(AuthError::InvalidToken)) => true, - Err(ClientError::Auth(AuthError::Other(value))) => value - .to_str() - .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")), + Err(e) => match e.kind() { + ClientErrorKind::Auth(AuthError::InvalidToken) => true, + ClientErrorKind::Auth(AuthError::Other(value)) => value + .to_str() + .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")), + _ => false, + }, Ok(resp) => match resp.parse() { Err(XrpcError::Auth(AuthError::InvalidToken)) => true, _ => false, }, - _ => false, } } diff --git a/crates/jacquard-oauth/src/request.rs b/crates/jacquard-oauth/src/request.rs index a7d17e5b..b01f8011 100644 --- a/crates/jacquard-oauth/src/request.rs +++ b/crates/jacquard-oauth/src/request.rs @@ -14,7 +14,6 @@ use jacquard_identity::resolver::IdentityError; use serde::Serialize; use serde_json::Value; use smol_str::ToSmolStr; -use thiserror::Error; use crate::{ FALLBACK_ALG, @@ -40,17 +39,43 @@ use crate::{ const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; -#[derive(Error, Debug, miette::Diagnostic)] -pub enum RequestError { +use smol_str::SmolStr; + +pub type BoxError = Box; + +/// OAuth request error for token operations and auth flows +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{kind}")] +pub struct RequestError { + #[diagnostic_source] + kind: RequestErrorKind, + #[source] + source: Option, + #[help] + help: Option, + context: Option, + url: Option, + details: Option, + location: Option, +} + +/// Error categories for OAuth request operations +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum RequestErrorKind { + /// No endpoint available #[error("no {0} endpoint available")] #[diagnostic( code(jacquard_oauth::request::no_endpoint), help("server does not advertise this endpoint") )] - NoEndpoint(CowStr<'static>), + NoEndpoint(SmolStr), + + /// Token response verification failed #[error("token response verification failed")] #[diagnostic(code(jacquard_oauth::request::token_verification))] TokenVerification, + + /// Unsupported authentication method #[error("unsupported authentication method")] #[diagnostic( code(jacquard_oauth::request::unsupported_auth_method), @@ -59,54 +84,316 @@ pub enum RequestError { ) )] UnsupportedAuthMethod, + + /// No refresh token available #[error("no refresh token available")] #[diagnostic(code(jacquard_oauth::request::no_refresh_token))] NoRefreshToken, - #[error("failed to parse DID: {0}")] + + /// Invalid DID + #[error("failed to parse DID")] #[diagnostic(code(jacquard_oauth::request::invalid_did))] - InvalidDid(#[from] AtStrError), - #[error(transparent)] + InvalidDid, + + /// DPoP client error + #[error("dpop error")] #[diagnostic(code(jacquard_oauth::request::dpop))] - DpopClient(#[from] crate::dpop::Error), - #[error(transparent)] + Dpop, + + /// Session storage error + #[error("storage error")] #[diagnostic(code(jacquard_oauth::request::storage))] - Storage(#[from] SessionStoreError), + Storage, - #[error(transparent)] + /// Resolver error + #[error("resolver error")] #[diagnostic(code(jacquard_oauth::request::resolver))] - ResolverError(#[from] crate::resolver::ResolverError), - // #[error(transparent)] - // OAuthSession(#[from] crate::oauth_session::Error), - #[error(transparent)] + Resolver, + + /// HTTP build error + #[error("http build error")] #[diagnostic(code(jacquard_oauth::request::http_build))] - Http(#[from] http::Error), + HttpBuild, + + /// HTTP status error #[error("http status: {0}")] #[diagnostic( code(jacquard_oauth::request::http_status), help("see server response for details") )] HttpStatus(StatusCode), - #[error("http status: {0}, body: {1:?}")] + + /// HTTP status with error body + #[error("http status: {status}, body: {body:?}")] #[diagnostic( code(jacquard_oauth::request::http_status_body), help("server returned error JSON; inspect fields like `error`, `error_description`") )] - HttpStatusWithBody(StatusCode, Value), - #[error(transparent)] + HttpStatusWithBody { status: StatusCode, body: Value }, + + /// Identity resolution error + #[error("identity error")] #[diagnostic(code(jacquard_oauth::request::identity))] - Identity(#[from] IdentityError), - #[error(transparent)] + Identity, + + /// Keyset error + #[error("keyset error")] #[diagnostic(code(jacquard_oauth::request::keyset))] - Keyset(#[from] crate::keyset::Error), - #[error(transparent)] + Keyset, + + /// Form serialization error + #[error("form serialization error")] #[diagnostic(code(jacquard_oauth::request::serde_form))] - SerdeHtmlForm(#[from] serde_html_form::ser::Error), - #[error(transparent)] + SerdeHtmlForm, + + /// JSON error + #[error("json error")] #[diagnostic(code(jacquard_oauth::request::serde_json))] - SerdeJson(#[from] serde_json::Error), - #[error(transparent)] + SerdeJson, + + /// Atproto metadata error + #[error("atproto error")] #[diagnostic(code(jacquard_oauth::request::atproto))] - Atproto(#[from] crate::atproto::Error), + Atproto, +} + +impl RequestError { + /// Create a new error with the given kind and optional source + pub fn new(kind: RequestErrorKind, source: Option) -> Self { + Self { + kind, + source, + help: None, + context: None, + url: None, + details: None, + location: None, + } + } + + /// Get the error kind + pub fn kind(&self) -> &RequestErrorKind { + &self.kind + } + + /// Get the source error if present + pub fn source_err(&self) -> Option<&BoxError> { + self.source.as_ref() + } + + /// Get the context string if present + pub fn context(&self) -> Option<&str> { + self.context.as_ref().map(|s| s.as_str()) + } + + /// Get the URL if present + pub fn url(&self) -> Option<&str> { + self.url.as_ref().map(|s| s.as_str()) + } + + /// Get the details if present + pub fn details(&self) -> Option<&str> { + self.details.as_ref().map(|s| s.as_str()) + } + + /// Get the location if present + pub fn location(&self) -> Option<&str> { + self.location.as_ref().map(|s| s.as_str()) + } + + /// Add help text to this error + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Add context to this error + pub fn with_context(mut self, context: impl Into) -> Self { + self.context = Some(context.into()); + self + } + + /// Add URL to this error + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Add details to this error + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } + + /// Add location to this error + pub fn with_location(mut self, location: impl Into) -> Self { + self.location = Some(location.into()); + self + } + + // Constructors for each kind + + /// Create a no endpoint error + pub fn no_endpoint(endpoint: impl Into) -> Self { + Self::new(RequestErrorKind::NoEndpoint(endpoint.into()), None) + } + + /// Create a token verification error + pub fn token_verification() -> Self { + Self::new(RequestErrorKind::TokenVerification, None) + } + + /// Create an unsupported authentication method error + pub fn unsupported_auth_method() -> Self { + Self::new(RequestErrorKind::UnsupportedAuthMethod, None) + } + + /// Create a no refresh token error + pub fn no_refresh_token() -> Self { + Self::new(RequestErrorKind::NoRefreshToken, None) + } + + /// Create an invalid DID error + pub fn invalid_did(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::InvalidDid, Some(Box::new(source))) + } + + /// Create a DPoP error + pub fn dpop(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::Dpop, Some(Box::new(source))) + } + + /// Create a storage error + pub fn storage(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::Storage, Some(Box::new(source))) + } + + /// Create a resolver error + pub fn resolver(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::Resolver, Some(Box::new(source))) + } + + /// Create an HTTP build error + pub fn http_build(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::HttpBuild, Some(Box::new(source))) + } + + /// Create an HTTP status error + pub fn http_status(status: StatusCode) -> Self { + Self::new(RequestErrorKind::HttpStatus(status), None) + } + + /// Create an HTTP status with body error + pub fn http_status_with_body(status: StatusCode, body: Value) -> Self { + Self::new(RequestErrorKind::HttpStatusWithBody { status, body }, None) + } + + /// Create an identity error + pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::Identity, Some(Box::new(source))) + } + + /// Create a keyset error + pub fn keyset(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::Keyset, Some(Box::new(source))) + } + + /// Create an atproto metadata error + pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(RequestErrorKind::Atproto, Some(Box::new(source))) + } +} + +// From impls for common error types + +impl From for RequestError { + fn from(e: AtStrError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::InvalidDid, Some(Box::new(e))) + .with_context(msg) + .with_help("ensure DID is correctly formatted (e.g., did:plc:abc123)") + } +} + +impl From for RequestError { + fn from(e: crate::dpop::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::Dpop, Some(Box::new(e))) + .with_context(msg) + .with_help("check DPoP key configuration and nonce handling") + } +} + +impl From for RequestError { + fn from(e: SessionStoreError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::Storage, Some(Box::new(e))) + .with_context(msg) + .with_help("verify session store is accessible and writable") + } +} + +impl From for RequestError { + fn from(e: crate::resolver::ResolverError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::Resolver, Some(Box::new(e))) + .with_context(msg) + .with_help("check identity resolution and OAuth metadata endpoints") + } +} + +impl From for RequestError { + fn from(e: http::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::HttpBuild, Some(Box::new(e))) + .with_context(msg) + .with_help("verify request URIs and headers are valid") + } +} + +impl From for RequestError { + fn from(e: IdentityError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::Identity, Some(Box::new(e))) + .with_context(msg) + .with_help("check handle/DID is valid and identity resolver is configured") + } +} + +impl From for RequestError { + fn from(e: crate::keyset::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::Keyset, Some(Box::new(e))) + .with_context(msg) + .with_help("verify keyset configuration and signing algorithm support") + } +} + +impl From for RequestError { + fn from(e: serde_html_form::ser::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::SerdeHtmlForm, Some(Box::new(e))) + .with_context(msg) + .with_help("check OAuth request parameters are serializable") + } +} + +impl From for RequestError { + fn from(e: serde_json::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::SerdeJson, Some(Box::new(e))) + .with_context(msg) + .with_help("verify OAuth response body is valid JSON") + } +} + +impl From for RequestError { + fn from(e: crate::atproto::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(RequestErrorKind::Atproto, Some(Box::new(e))) + .with_context(msg) + .with_help("ensure client metadata matches atproto requirements") + } } pub type Result = core::result::Result; @@ -191,7 +478,7 @@ pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>( let (code_challenge, verifier) = generate_pkce(); let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else { - return Err(RequestError::TokenVerification); + return Err(RequestError::token_verification()); }; let mut dpop_data = DpopReqData { dpop_key, @@ -247,9 +534,7 @@ pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>( .require_pushed_authorization_requests == Some(true) { - Err(RequestError::NoEndpoint(CowStr::new_static( - "pushed_authorization_request", - ))) + Err(RequestError::no_endpoint("pushed_authorization_request")) } else { todo!("use of PAR is mandatory") } @@ -265,7 +550,7 @@ where T: OAuthResolver + DpopExt + Send + Sync + 'static, { let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else { - return Err(RequestError::NoRefreshToken); + return Err(RequestError::no_refresh_token()); }; // /!\ IMPORTANT /!\ @@ -343,7 +628,7 @@ where ) .await?; let Some(sub) = token_response.sub else { - return Err(RequestError::TokenVerification); + return Err(RequestError::token_verification()); }; let sub = Did::new_owned(sub)?; let iss = metadata.server_metadata.issuer.clone(); @@ -408,7 +693,7 @@ where D: DpopDataSource, { let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else { - return Err(RequestError::NoEndpoint(request.name())); + return Err(RequestError::no_endpoint(request.name())); }; let client_assertions = build_auth( metadata.keyset.as_ref(), @@ -429,11 +714,7 @@ where .method(Method::POST) .header("Content-Type", "application/x-www-form-urlencoded") .body(body.into_bytes())?; - let res = client - .dpop_server_call(data_source) - .send(req) - .await - .map_err(RequestError::DpopClient)?; + let res = client.dpop_server_call(data_source).send(req).await?; if res.status() == request.expected_status() { let body = res.body(); if body.is_empty() { @@ -444,12 +725,12 @@ where Ok(output) } } else if res.status().is_client_error() { - Err(RequestError::HttpStatusWithBody( + Err(RequestError::http_status_with_body( res.status(), serde_json::from_slice(res.body())?, )) } else { - Err(RequestError::HttpStatus(res.status())) + Err(RequestError::http_status(res.status())) } } @@ -560,7 +841,7 @@ fn build_auth<'a>( } } - Err(RequestError::UnsupportedAuthMethod) + Err(RequestError::unsupported_auth_method()) } #[cfg(test)] @@ -642,6 +923,7 @@ mod tests { server.issuer = CowStr::from("https://issuer"); server.authorization_endpoint = CowStr::from("https://issuer/authorize"); server.token_endpoint = CowStr::from("https://issuer/token"); + server.token_endpoint_auth_methods_supported = Some(vec![CowStr::from("none")]); OAuthMetadata { server_metadata: server, client_metadata: OAuthClientMetadata { @@ -669,12 +951,9 @@ mod tests { let err = super::par(&MockClient::default(), None, None, &meta) .await .unwrap_err(); - match err { - RequestError::NoEndpoint(name) => { - assert_eq!(name.as_ref(), "pushed_authorization_request"); - } - other => panic!("unexpected: {other:?}"), - } + assert!( + matches!(err.kind(), RequestErrorKind::NoEndpoint(name) if name == "pushed_authorization_request") + ); } #[tokio::test] @@ -706,7 +985,7 @@ mod tests { }, }; let err = super::refresh(&client, session, &meta).await.unwrap_err(); - matches!(err, RequestError::NoRefreshToken); + assert!(matches!(err.kind(), RequestErrorKind::NoRefreshToken)); } #[tokio::test] @@ -734,6 +1013,6 @@ mod tests { let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta) .await .unwrap_err(); - matches!(err, RequestError::TokenVerification); + assert!(matches!(err.kind(), RequestErrorKind::TokenVerification)); } } diff --git a/crates/jacquard-oauth/src/resolver.rs b/crates/jacquard-oauth/src/resolver.rs index 13ae13f9..ac6ead56 100644 --- a/crates/jacquard-oauth/src/resolver.rs +++ b/crates/jacquard-oauth/src/resolver.rs @@ -4,11 +4,12 @@ use std::future::Future; use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; use http::{Request, StatusCode}; use jacquard_common::CowStr; +use jacquard_common::IntoStatic; use jacquard_common::types::did_doc::DidDocument; use jacquard_common::types::ident::AtIdentifier; -use jacquard_common::{IntoStatic, error::TransportError}; use jacquard_common::{http_client::HttpClient, types::did::Did}; use jacquard_identity::resolver::{IdentityError, IdentityResolver}; +use smol_str::SmolStr; use url::Url; /// Compare two issuer strings strictly but without spuriously failing on trivial differences. @@ -51,88 +52,323 @@ pub(crate) fn issuer_equivalent(a: &str, b: &str) -> bool { } } -#[derive(thiserror::Error, Debug, miette::Diagnostic)] -pub enum ResolverError { +pub type BoxError = Box; + +/// OAuth resolver error for identity and metadata resolution +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{kind}")] +pub struct ResolverError { + #[diagnostic_source] + kind: ResolverErrorKind, + #[source] + source: Option, + #[help] + help: Option, + context: Option, + url: Option, + details: Option, + location: Option, +} + +/// Error categories for OAuth resolver operations +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum ResolverErrorKind { + /// Resource not found #[error("resource not found")] #[diagnostic( code(jacquard_oauth::resolver::not_found), help("check the base URL or identifier") )] NotFound, + + /// Invalid AT identifier #[error("invalid at identifier: {0}")] #[diagnostic( code(jacquard_oauth::resolver::at_identifier), help("ensure a valid handle or DID was provided") )] - AtIdentifier(String), + AtIdentifier(SmolStr), + + /// Invalid DID #[error("invalid did: {0}")] #[diagnostic( code(jacquard_oauth::resolver::did), help("ensure DID is correctly formed (did:plc or did:web)") )] - Did(String), + Did(SmolStr), + + /// Invalid DID document #[error("invalid did document: {0}")] #[diagnostic( code(jacquard_oauth::resolver::did_document), help("verify the DID document structure and service entries") )] - DidDocument(String), + DidDocument(SmolStr), + + /// Protected resource metadata is invalid #[error("protected resource metadata is invalid: {0}")] #[diagnostic( code(jacquard_oauth::resolver::protected_resource_metadata), help("PDS must advertise an authorization server in its protected resource metadata") )] - ProtectedResourceMetadata(String), + ProtectedResourceMetadata(SmolStr), + + /// Authorization server metadata is invalid #[error("authorization server metadata is invalid: {0}")] #[diagnostic( code(jacquard_oauth::resolver::authorization_server_metadata), help("issuer must match and include the PDS resource") )] - AuthorizationServerMetadata(String), - #[error("error resolving identity: {0}")] + AuthorizationServerMetadata(SmolStr), + + /// Identity resolution error + #[error("error resolving identity")] #[diagnostic(code(jacquard_oauth::resolver::identity))] - IdentityResolverError(#[from] IdentityError), + Identity, + + /// Unsupported DID method #[error("unsupported did method: {0:?}")] #[diagnostic( code(jacquard_oauth::resolver::unsupported_did_method), help("supported DID methods: did:web, did:plc") )] UnsupportedDidMethod(Did<'static>), - #[error(transparent)] + + /// HTTP transport error + #[error("transport error")] #[diagnostic(code(jacquard_oauth::resolver::transport))] - Transport(#[from] TransportError), - #[error("http status: {0:?}")] + Transport, + + /// HTTP status error + #[error("http status: {0}")] #[diagnostic( code(jacquard_oauth::resolver::http_status), help("check well-known paths and server configuration") )] HttpStatus(StatusCode), - #[error(transparent)] + + /// JSON serialization error + #[error("json error")] #[diagnostic(code(jacquard_oauth::resolver::serde_json))] - SerdeJson(#[from] serde_json::Error), - #[error(transparent)] + SerdeJson, + + /// Form serialization error + #[error("form serialization error")] #[diagnostic(code(jacquard_oauth::resolver::serde_form))] - SerdeHtmlForm(#[from] serde_html_form::ser::Error), - #[error(transparent)] + SerdeHtmlForm, + + /// URL parsing error + #[error("url parsing error")] #[diagnostic(code(jacquard_oauth::resolver::url))] - Uri(#[from] url::ParseError), + Uri, } +impl ResolverError { + /// Create a new error with the given kind and optional source + pub fn new(kind: ResolverErrorKind, source: Option) -> Self { + Self { + kind, + source, + help: None, + context: None, + url: None, + details: None, + location: None, + } + } + + /// Get the error kind + pub fn kind(&self) -> &ResolverErrorKind { + &self.kind + } + + /// Get the source error if present + pub fn source_err(&self) -> Option<&BoxError> { + self.source.as_ref() + } + + /// Get the context string if present + pub fn context(&self) -> Option<&str> { + self.context.as_ref().map(|s| s.as_str()) + } + + /// Get the URL if present + pub fn url(&self) -> Option<&str> { + self.url.as_ref().map(|s| s.as_str()) + } + + /// Get the details if present + pub fn details(&self) -> Option<&str> { + self.details.as_ref().map(|s| s.as_str()) + } + + /// Get the location if present + pub fn location(&self) -> Option<&str> { + self.location.as_ref().map(|s| s.as_str()) + } + + /// Add help text to this error + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Add context to this error + pub fn with_context(mut self, context: impl Into) -> Self { + self.context = Some(context.into()); + self + } + + /// Add URL to this error + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Add details to this error + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } + + /// Add location to this error + pub fn with_location(mut self, location: impl Into) -> Self { + self.location = Some(location.into()); + self + } + + // Constructors for each kind + + /// Create a not found error + pub fn not_found() -> Self { + Self::new(ResolverErrorKind::NotFound, None) + } + + /// Create an invalid AT identifier error + pub fn at_identifier(msg: impl Into) -> Self { + Self::new(ResolverErrorKind::AtIdentifier(msg.into()), None) + } + + /// Create an invalid DID error + pub fn did(msg: impl Into) -> Self { + Self::new(ResolverErrorKind::Did(msg.into()), None) + } + + /// Create an invalid DID document error + pub fn did_document(msg: impl Into) -> Self { + Self::new(ResolverErrorKind::DidDocument(msg.into()), None) + } + + /// Create a protected resource metadata error + pub fn protected_resource_metadata(msg: impl Into) -> Self { + Self::new( + ResolverErrorKind::ProtectedResourceMetadata(msg.into()), + None, + ) + } + + /// Create an authorization server metadata error + pub fn authorization_server_metadata(msg: impl Into) -> Self { + Self::new( + ResolverErrorKind::AuthorizationServerMetadata(msg.into()), + None, + ) + } + + /// Create an identity resolution error + pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(ResolverErrorKind::Identity, Some(Box::new(source))) + } + + /// Create an unsupported DID method error + pub fn unsupported_did_method(did: Did<'static>) -> Self { + Self::new(ResolverErrorKind::UnsupportedDidMethod(did), None) + } + + /// Create a transport error + pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::new(ResolverErrorKind::Transport, Some(Box::new(source))) + } + + /// Create an HTTP status error + pub fn http_status(status: StatusCode) -> Self { + Self::new(ResolverErrorKind::HttpStatus(status), None) + } +} + +/// Result type for resolver operations +pub type Result = std::result::Result; + +// From impls for common error types + +impl From for ResolverError { + fn from(e: IdentityError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ResolverErrorKind::Identity, Some(Box::new(e))) + .with_context(msg) + .with_help("verify handle/DID is valid and resolver configuration") + } +} + +impl From for ResolverError { + fn from(e: jacquard_common::error::ClientError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ResolverErrorKind::Transport, Some(Box::new(e))) + .with_context(msg) + .with_help("check network connectivity and well-known endpoint availability") + } +} + +impl From for ResolverError { + fn from(e: serde_json::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ResolverErrorKind::SerdeJson, Some(Box::new(e))) + .with_context(msg) + .with_help("verify OAuth metadata response format is valid JSON") + } +} + +impl From for ResolverError { + fn from(e: serde_html_form::ser::Error) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ResolverErrorKind::SerdeHtmlForm, Some(Box::new(e))) + .with_context(msg) + .with_help("check form parameters are serializable") + } +} + +impl From for ResolverError { + fn from(e: url::ParseError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ResolverErrorKind::Uri, Some(Box::new(e))) + .with_context(msg) + .with_help("ensure URLs are well-formed (e.g., https://example.com)") + } +} + +// // Deprecated - for compatibility with old TransportError usage +// #[allow(deprecated)] +// impl From for ResolverError { +// fn from(e: jacquard_common::error::TransportError) -> Self { +// Self::transport(e) +// } +// } + #[cfg(not(target_arch = "wasm32"))] async fn verify_issuer_impl( resolver: &T, server_metadata: &OAuthAuthorizationServerMetadata<'_>, sub: &Did<'_>, -) -> Result { +) -> Result { let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { - return Err(ResolverError::AuthorizationServerMetadata( - "issuer mismatch".to_string(), + return Err(ResolverError::authorization_server_metadata( + "issuer mismatch", )); } Ok(identity .pds_endpoint() - .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) + .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) } #[cfg(target_arch = "wasm32")] @@ -140,34 +376,31 @@ async fn verify_issuer_impl( resolver: &T, server_metadata: &OAuthAuthorizationServerMetadata<'_>, sub: &Did<'_>, -) -> Result { +) -> Result { let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { - return Err(ResolverError::AuthorizationServerMetadata( - "issuer mismatch".to_string(), + return Err(ResolverError::authorization_server_metadata( + "issuer mismatch", )); } Ok(identity .pds_endpoint() - .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) + .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) } #[cfg(not(target_arch = "wasm32"))] async fn resolve_oauth_impl( resolver: &T, input: &str, -) -> Result< - ( - OAuthAuthorizationServerMetadata<'static>, - Option>, - ), - ResolverError, -> { +) -> Result<( + OAuthAuthorizationServerMetadata<'static>, + Option>, +)> { // Allow using an entryway, or PDS url, directly as login input (e.g. // when the user forgot their handle, or when the handle does not // resolve to a DID) Ok(if input.starts_with("https://") { - let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; + let url = Url::parse(input).map_err(|_| ResolverError::not_found())?; (resolver.resolve_from_service(&url).await?, None) } else { let (metadata, identity) = resolver.resolve_from_identity(input).await?; @@ -179,18 +412,15 @@ async fn resolve_oauth_impl( async fn resolve_oauth_impl( resolver: &T, input: &str, -) -> Result< - ( - OAuthAuthorizationServerMetadata<'static>, - Option>, - ), - ResolverError, -> { +) -> Result<( + OAuthAuthorizationServerMetadata<'static>, + Option>, +)> { // Allow using an entryway, or PDS url, directly as login input (e.g. // when the user forgot their handle, or when the handle does not // resolve to a DID) Ok(if input.starts_with("https://") { - let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; + let url = Url::parse(input).map_err(|_| ResolverError::not_found())?; (resolver.resolve_from_service(&url).await?, None) } else { let (metadata, identity) = resolver.resolve_from_identity(input).await?; @@ -202,7 +432,7 @@ async fn resolve_oauth_impl( async fn resolve_from_service_impl( resolver: &T, input: &Url, -) -> Result, ResolverError> { +) -> Result> { // Assume first that input is a PDS URL (as required by ATPROTO) if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { return Ok(metadata); @@ -215,7 +445,7 @@ async fn resolve_from_service_impl( async fn resolve_from_service_impl( resolver: &T, input: &Url, -) -> Result, ResolverError> { +) -> Result> { // Assume first that input is a PDS URL (as required by ATPROTO) if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { return Ok(metadata); @@ -228,21 +458,18 @@ async fn resolve_from_service_impl( async fn resolve_from_identity_impl( resolver: &T, input: &str, -) -> Result< - ( - OAuthAuthorizationServerMetadata<'static>, - DidDocument<'static>, - ), - ResolverError, -> { - let actor = - AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; +) -> Result<( + OAuthAuthorizationServerMetadata<'static>, + DidDocument<'static>, +)> { + let actor = AtIdentifier::new(input) + .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; let identity = resolver.resolve_ident_owned(&actor).await?; if let Some(pds) = &identity.pds_endpoint() { let metadata = resolver.get_resource_server_metadata(pds).await?; Ok((metadata, identity)) } else { - Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) + Err(ResolverError::did_document("Did doc lacking pds")) } } @@ -250,21 +477,18 @@ async fn resolve_from_identity_impl( async fn resolve_from_identity_impl( resolver: &T, input: &str, -) -> Result< - ( - OAuthAuthorizationServerMetadata<'static>, - DidDocument<'static>, - ), - ResolverError, -> { - let actor = - AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; +) -> Result<( + OAuthAuthorizationServerMetadata<'static>, + DidDocument<'static>, +)> { + let actor = AtIdentifier::new(input) + .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; let identity = resolver.resolve_ident_owned(&actor).await?; if let Some(pds) = &identity.pds_endpoint() { let metadata = resolver.get_resource_server_metadata(pds).await?; Ok((metadata, identity)) } else { - Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) + Err(ResolverError::did_document("Did doc lacking pds")) } } @@ -272,7 +496,7 @@ async fn resolve_from_identity_impl( async fn get_authorization_server_metadata_impl( client: &T, issuer: &Url, -) -> Result, ResolverError> { +) -> Result> { let mut md = resolve_authorization_server(client, issuer).await?; // Normalize issuer string to the input URL representation to avoid slash quirks md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); @@ -283,7 +507,7 @@ async fn get_authorization_server_metadata_impl( async fn get_authorization_server_metadata_impl( client: &T, issuer: &Url, -) -> Result, ResolverError> { +) -> Result> { let mut md = resolve_authorization_server(client, issuer).await?; // Normalize issuer string to the input URL representation to avoid slash quirks md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); @@ -294,7 +518,7 @@ async fn get_authorization_server_metadata_impl( async fn get_resource_server_metadata_impl( resolver: &T, pds: &Url, -) -> Result, ResolverError> { +) -> Result> { let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; // ATPROTO requires one, and only one, authorization server entry // > That document MUST contain a single item in the authorization_servers array. @@ -302,16 +526,18 @@ async fn get_resource_server_metadata_impl( let issuer = match &rs_metadata.authorization_servers { Some(servers) if !servers.is_empty() => { if servers.len() > 1 { - return Err(ResolverError::ProtectedResourceMetadata(format!( - "unable to determine authorization server for PDS: {pds}" - ))); + return Err(ResolverError::protected_resource_metadata( + smol_str::format_smolstr!( + "unable to determine authorization server for PDS: {pds}" + ), + )); } &servers[0] } _ => { - return Err(ResolverError::ProtectedResourceMetadata(format!( - "no authorization server found for PDS: {pds}" - ))); + return Err(ResolverError::protected_resource_metadata( + smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), + )); } }; let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; @@ -322,10 +548,13 @@ async fn get_resource_server_metadata_impl( .strip_suffix('/') .unwrap_or(rs_metadata.resource.as_str()); if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { - return Err(ResolverError::AuthorizationServerMetadata(format!( - "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", - rs_metadata.resource, protected_resources - ))); + return Err(ResolverError::authorization_server_metadata( + smol_str::format_smolstr!( + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", + rs_metadata.resource, + protected_resources + ), + )); } } @@ -347,7 +576,7 @@ async fn get_resource_server_metadata_impl( async fn get_resource_server_metadata_impl( resolver: &T, pds: &Url, -) -> Result, ResolverError> { +) -> Result> { let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; // ATPROTO requires one, and only one, authorization server entry // > That document MUST contain a single item in the authorization_servers array. @@ -355,16 +584,18 @@ async fn get_resource_server_metadata_impl( let issuer = match &rs_metadata.authorization_servers { Some(servers) if !servers.is_empty() => { if servers.len() > 1 { - return Err(ResolverError::ProtectedResourceMetadata(format!( - "unable to determine authorization server for PDS: {pds}" - ))); + return Err(ResolverError::protected_resource_metadata( + smol_str::format_smolstr!( + "unable to determine authorization server for PDS: {pds}" + ), + )); } &servers[0] } _ => { - return Err(ResolverError::ProtectedResourceMetadata(format!( - "no authorization server found for PDS: {pds}" - ))); + return Err(ResolverError::protected_resource_metadata( + smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), + )); } }; let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; @@ -375,10 +606,13 @@ async fn get_resource_server_metadata_impl( .strip_suffix('/') .unwrap_or(rs_metadata.resource.as_str()); if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { - return Err(ResolverError::AuthorizationServerMetadata(format!( - "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", - rs_metadata.resource, protected_resources - ))); + return Err(ResolverError::authorization_server_metadata( + smol_str::format_smolstr!( + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", + rs_metadata.resource, + protected_resources + ), + )); } } @@ -403,7 +637,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { &self, server_metadata: &OAuthAuthorizationServerMetadata<'_>, sub: &Did<'_>, - ) -> impl Future> + Send + ) -> impl Future> + Send where Self: Sync, { @@ -415,7 +649,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { &self, server_metadata: &OAuthAuthorizationServerMetadata<'_>, sub: &Did<'_>, - ) -> impl Future> { + ) -> impl Future> { verify_issuer_impl(self, server_metadata, sub) } @@ -424,13 +658,10 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { &self, input: &str, ) -> impl Future< - Output = Result< - ( - OAuthAuthorizationServerMetadata<'static>, - Option>, - ), - ResolverError, - >, + Output = Result<( + OAuthAuthorizationServerMetadata<'static>, + Option>, + )>, > + Send where Self: Sync, @@ -443,13 +674,10 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { &self, input: &str, ) -> impl Future< - Output = Result< - ( - OAuthAuthorizationServerMetadata<'static>, - Option>, - ), - ResolverError, - >, + Output = Result<( + OAuthAuthorizationServerMetadata<'static>, + Option>, + )>, > { resolve_oauth_impl(self, input) } @@ -458,7 +686,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { fn resolve_from_service( &self, input: &Url, - ) -> impl Future, ResolverError>> + Send + ) -> impl Future>> + Send where Self: Sync, { @@ -469,8 +697,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { fn resolve_from_service( &self, input: &Url, - ) -> impl Future, ResolverError>> - { + ) -> impl Future>> { resolve_from_service_impl(self, input) } @@ -479,13 +706,10 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { &self, input: &str, ) -> impl Future< - Output = Result< - ( - OAuthAuthorizationServerMetadata<'static>, - DidDocument<'static>, - ), - ResolverError, - >, + Output = Result<( + OAuthAuthorizationServerMetadata<'static>, + DidDocument<'static>, + )>, > + Send where Self: Sync, @@ -498,13 +722,10 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { &self, input: &str, ) -> impl Future< - Output = Result< - ( - OAuthAuthorizationServerMetadata<'static>, - DidDocument<'static>, - ), - ResolverError, - >, + Output = Result<( + OAuthAuthorizationServerMetadata<'static>, + DidDocument<'static>, + )>, > { resolve_from_identity_impl(self, input) } @@ -513,7 +734,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { fn get_authorization_server_metadata( &self, issuer: &Url, - ) -> impl Future, ResolverError>> + Send + ) -> impl Future>> + Send where Self: Sync, { @@ -524,8 +745,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { fn get_authorization_server_metadata( &self, issuer: &Url, - ) -> impl Future, ResolverError>> - { + ) -> impl Future>> { get_authorization_server_metadata_impl(self, issuer) } @@ -533,7 +753,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { fn get_resource_server_metadata( &self, pds: &Url, - ) -> impl Future, ResolverError>> + Send + ) -> impl Future>> + Send where Self: Sync, { @@ -544,8 +764,7 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { fn get_resource_server_metadata( &self, pds: &Url, - ) -> impl Future, ResolverError>> - { + ) -> impl Future>> { get_resource_server_metadata_impl(self, pds) } } @@ -553,70 +772,66 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { pub async fn resolve_authorization_server( client: &T, server: &Url, -) -> Result, ResolverError> { +) -> Result> { let url = server .join("/.well-known/oauth-authorization-server") - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; + .map_err(|e| ResolverError::transport(e))?; let req = Request::builder() .uri(url.to_string()) .body(Vec::new()) - .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; + .map_err(|e| ResolverError::transport(e))?; let res = client .send_http(req) .await - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; + .map_err(|e| ResolverError::transport(e))?; if res.status() == StatusCode::OK { - let mut metadata = serde_json::from_slice::(res.body()) - .map_err(ResolverError::SerdeJson)?; + let mut metadata = serde_json::from_slice::(res.body())?; // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 // Accept semantically equivalent issuer (normalize to the requested URL form) if issuer_equivalent(&metadata.issuer, server.as_str()) { metadata.issuer = server.as_str().into(); Ok(metadata.into_static()) } else { - Err(ResolverError::AuthorizationServerMetadata(format!( - "invalid issuer: {}", - metadata.issuer - ))) + Err(ResolverError::authorization_server_metadata( + smol_str::format_smolstr!("invalid issuer: {}", metadata.issuer), + )) } } else { - Err(ResolverError::HttpStatus(res.status())) + Err(ResolverError::http_status(res.status())) } } pub async fn resolve_protected_resource_info( client: &T, server: &Url, -) -> Result, ResolverError> { +) -> Result> { let url = server .join("/.well-known/oauth-protected-resource") - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; + .map_err(|e| ResolverError::transport(e))?; let req = Request::builder() .uri(url.to_string()) .body(Vec::new()) - .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; + .map_err(|e| ResolverError::transport(e))?; let res = client .send_http(req) .await - .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; + .map_err(|e| ResolverError::transport(e))?; if res.status() == StatusCode::OK { - let mut metadata = serde_json::from_slice::(res.body()) - .map_err(ResolverError::SerdeJson)?; + let mut metadata = serde_json::from_slice::(res.body())?; // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 // Accept semantically equivalent resource URL (normalize to the requested URL form) if issuer_equivalent(&metadata.resource, server.as_str()) { metadata.resource = server.as_str().into(); Ok(metadata.into_static()) } else { - Err(ResolverError::AuthorizationServerMetadata(format!( - "invalid resource: {}", - metadata.resource - ))) + Err(ResolverError::authorization_server_metadata( + smol_str::format_smolstr!("invalid resource: {}", metadata.resource), + )) } } else { - Err(ResolverError::HttpStatus(res.status())) + Err(ResolverError::http_status(res.status())) } } @@ -662,7 +877,10 @@ mod tests { let err = super::resolve_authorization_server(&client, &issuer) .await .unwrap_err(); - matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND)); + assert!(matches!( + err.kind(), + ResolverErrorKind::HttpStatus(StatusCode::NOT_FOUND) + )); } #[tokio::test] @@ -678,7 +896,7 @@ mod tests { let err = super::resolve_authorization_server(&client, &issuer) .await .unwrap_err(); - matches!(err, ResolverError::SerdeJson(_)); + assert!(matches!(err.kind(), ResolverErrorKind::SerdeJson)); } #[test] diff --git a/crates/jacquard-oauth/src/session.rs b/crates/jacquard-oauth/src/session.rs index 27e30f22..69e0f34b 100644 --- a/crates/jacquard-oauth/src/session.rs +++ b/crates/jacquard-oauth/src/session.rs @@ -263,7 +263,7 @@ impl<'s> ClientSession<'s> { server_metadata: client .get_authorization_server_metadata(&self.session_data.authserver_url) .await - .map_err(|e| Error::ServerAgent(crate::request::RequestError::ResolverError(e)))?, + .map_err(|e| Error::ServerAgent(crate::request::RequestError::resolver(e)))?, client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset) .unwrap() .into_static(), diff --git a/crates/jacquard/src/client.rs b/crates/jacquard/src/client.rs index cfd25888..dde5a183 100644 --- a/crates/jacquard/src/client.rs +++ b/crates/jacquard/src/client.rs @@ -18,14 +18,27 @@ /// App-password session implementation with auto-refresh pub mod credential_session; +/// Agent error type +pub mod error; /// Token storage and on-disk persistence formats pub mod token; /// Trait for fetch-modify-put patterns on array-based endpoints pub mod vec_update; +use crate::client::credential_session::{CredentialSession, SessionKey}; +use crate::client::vec_update::VecUpdate; use core::future::Future; -use jacquard_common::error::TransportError; -pub use jacquard_common::error::{ClientError, XrpcResult}; +pub use error::*; +#[cfg(feature = "api")] +use jacquard_api::com_atproto::{ + repo::{ + create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, + get_record::GetRecordResponse, put_record::PutRecordOutput, + }, + server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput}, +}; +use jacquard_common::error::XrpcResult; +pub use jacquard_common::error::{ClientError, XrpcResult as ClientResult}; use jacquard_common::http_client::HttpClient; pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError}; use jacquard_common::types::blob::{Blob, MimeType}; @@ -49,99 +62,149 @@ use jacquard_oauth::authstore::ClientAuthStore; use jacquard_oauth::client::OAuthSession; use jacquard_oauth::dpop::DpopExt; use jacquard_oauth::resolver::OAuthResolver; - use serde::Serialize; +#[cfg(feature = "api")] +use std::marker::Send; +use std::option::Option; pub use token::FileAuthStore; -use crate::client::credential_session::{CredentialSession, SessionKey}; -use crate::client::vec_update::VecUpdate; +/// Identifies the active authentication mode for an agent/session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentKind { + /// App password (Bearer) session + AppPassword, + /// OAuth (DPoP) session + OAuth, +} -use jacquard_common::error::{AuthError, DecodeError}; -use jacquard_common::types::nsid::Nsid; -use jacquard_common::xrpc::GenericXrpcError; - -/// Error type for Agent convenience methods -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -pub enum AgentError { - /// Transport/network layer failure - #[error(transparent)] - #[diagnostic(transparent)] - Client(#[from] ClientError), - - /// No session available for operations requiring authentication - #[error("No session available - cannot determine repo")] - NoSession, - - /// Authentication error from XRPC layer - #[error("Authentication error: {0}")] - #[diagnostic(transparent)] - Auth( - #[from] - #[diagnostic_source] - AuthError, - ), - - /// Generic XRPC error (InvalidRequest, etc.) - #[error("XRPC error: {0}")] - Generic(GenericXrpcError), - - /// Response deserialization failed - #[error("Failed to decode response: {0}")] - #[diagnostic(transparent)] - Decode( - #[from] - #[diagnostic_source] - DecodeError, - ), - - /// Record operation failed with typed error from endpoint - /// Context: which repo/collection/rkey we were operating on - #[error("Record operation failed on {collection}/{rkey:?} in repo {repo}: {error}")] - RecordOperation { - /// The repository DID - repo: Did<'static>, - /// The collection NSID - collection: Nsid<'static>, - /// The record key - rkey: RecordKey>, - /// The underlying error - error: Box, - }, +/// Common interface for stateful sessions used by the Agent wrapper. +/// +/// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP). +#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] +pub trait AgentSession: XrpcClient + HttpClient + Send + Sync { + /// Identify the kind of session. + fn session_kind(&self) -> AgentKind; + /// Return current DID and an optional session id (always Some for OAuth). + fn session_info(&self) + -> impl Future, Option>)>>; + /// Current base endpoint. + fn endpoint(&self) -> impl Future; + /// Override per-session call options. + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future; + /// Refresh the session and return a fresh AuthorizationToken. + fn refresh(&self) -> impl Future>>; +} - /// Multi-step operation failed at sub-step (e.g., get failed in update_record) - #[error("Operation failed at step '{step}': {error}")] - SubOperation { - /// Description of which step failed - step: &'static str, - /// The underlying error - error: Box, - }, +/// Alias for an agent over a credential (app‑password) session. +pub type CredentialAgent = Agent>; +/// Alias for an agent over an OAuth (DPoP) session. +pub type OAuthAgent = Agent>; + +/// BasicClient: in-memory store + public resolver over a credential session. +pub type BasicClient = Agent< + CredentialSession< + MemorySessionStore, + jacquard_identity::PublicResolver, + >, +>; + +impl BasicClient { + /// Create an unauthenticated BasicClient for public API access. + /// + /// Uses an in-memory session store and public resolver. Suitable for + /// read-only operations on public data without authentication. + /// + /// # Example + /// + /// ```no_run + /// # use jacquard::client::BasicClient; + /// # use jacquard::types::string::AtUri; + /// # use jacquard_api::app_bsky::feed::post::Post; + /// use crate::jacquard::client::AgentSessionExt; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = BasicClient::unauthenticated(); + /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); + /// let response = client.get_record::>(&uri).await?; + /// # Ok(()) + /// # } + /// ``` + pub fn unauthenticated() -> Self { + use std::sync::Arc; + let http = reqwest::Client::new(); + let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); + let store = MemorySessionStore::default(); + let session = CredentialSession::new(Arc::new(store), Arc::new(resolver)); + Agent::new(session) + } } -impl IntoStatic for AgentError { - type Output = AgentError; - - fn into_static(self) -> Self::Output { - match self { - AgentError::RecordOperation { - repo, - collection, - rkey, - error, - } => AgentError::RecordOperation { - repo: repo.into_static(), - collection: collection.into_static(), - rkey: rkey.into_static(), - error, - }, - AgentError::SubOperation { step, error } => AgentError::SubOperation { step, error }, - // Error types are already 'static - AgentError::Client(e) => AgentError::Client(e), - AgentError::NoSession => AgentError::NoSession, - AgentError::Auth(e) => AgentError::Auth(e), - AgentError::Generic(e) => AgentError::Generic(e), - AgentError::Decode(e) => AgentError::Decode(e), - } +impl Default for BasicClient { + fn default() -> Self { + Self::unauthenticated() + } +} + +/// MemoryCredentialSession: credential session with in memory store and identity resolver +pub type MemoryCredentialSession = CredentialSession< + MemorySessionStore, + jacquard_identity::PublicResolver, +>; + +impl MemoryCredentialSession { + /// Create an unauthenticated MemoryCredentialSession. + /// + /// Uses an in memory store and a public resolver. + /// Equivalent to a BasicClient that isn't wrapped in Agent + pub fn unauthenticated() -> Self { + use std::sync::Arc; + let http = reqwest::Client::new(); + let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); + let store = MemorySessionStore::default(); + CredentialSession::new(Arc::new(store), Arc::new(resolver)) + } + + /// Create a MemoryCredentialSession and authenticate with the provided details + /// + /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL. + /// - `session_id`: optional session label; defaults to "session". + /// - Persists and activates the session, and updates the base endpoint to the user's PDS. + /// + /// # Example + /// ```no_run + /// # use jacquard::client::BasicClient; + /// # use jacquard::types::string::AtUri; + /// # use jacquard::api::app_bsky::feed::post::Post; + /// # use jacquard::types::string::Datetime; + /// # use jacquard::CowStr; + /// use jacquard::client::MemoryCredentialSession; + /// use jacquard::client::{Agent, AgentSessionExt}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let (identifier, password, post_text): (CowStr<'_>, CowStr<'_>, CowStr<'_>) = todo!(); + /// let (session, _) = MemoryCredentialSession::authenticated(identifier, password, None).await?; + /// let agent = Agent::from(session); + /// let post = Post::builder().text(post_text).created_at(Datetime::now()).build(); + /// let output = agent.create_record(post, None).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn authenticated( + identifier: CowStr<'_>, + password: CowStr<'_>, + session_id: Option>, + ) -> ClientResult<(Self, AtpSession)> { + let session = MemoryCredentialSession::unauthenticated(); + let auth = session + .login(identifier, password, session_id, None, None) + .await?; + Ok((session, auth)) + } +} + +impl Default for MemoryCredentialSession { + fn default() -> Self { + MemoryCredentialSession::unauthenticated() } } @@ -184,109 +247,6 @@ impl From> for AtpSession { } } -/// Identifies the active authentication mode for an agent/session. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AgentKind { - /// App password (Bearer) session - AppPassword, - /// OAuth (DPoP) session - OAuth, -} - -/// Common interface for stateful sessions used by the Agent wrapper. -/// -/// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP). -#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] -pub trait AgentSession: XrpcClient + HttpClient + Send + Sync { - /// Identify the kind of session. - fn session_kind(&self) -> AgentKind; - /// Return current DID and an optional session id (always Some for OAuth). - fn session_info(&self) - -> impl Future, Option>)>>; - /// Current base endpoint. - fn endpoint(&self) -> impl Future; - /// Override per-session call options. - fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future; - /// Refresh the session and return a fresh AuthorizationToken. - fn refresh(&self) -> impl Future, ClientError>>; -} - -impl AgentSession for CredentialSession -where - S: SessionStore + Send + Sync + 'static, - T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static, - W: Send + Sync, -{ - fn session_kind(&self) -> AgentKind { - AgentKind::AppPassword - } - fn session_info( - &self, - ) -> impl Future< - Output = std::option::Option<( - jacquard_common::types::did::Did<'static>, - std::option::Option>, - )>, - > { - async move { - CredentialSession::::session_info(self) - .await - .map(|(did, sid)| (did, Some(sid))) - } - } - fn endpoint(&self) -> impl Future { - async move { CredentialSession::::endpoint(self).await } - } - fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future { - async move { CredentialSession::::set_options(self, opts).await } - } - fn refresh(&self) -> impl Future, ClientError>> { - async move { - Ok(CredentialSession::::refresh(self) - .await? - .into_static()) - } - } -} - -impl AgentSession for OAuthSession -where - S: ClientAuthStore + Send + Sync + 'static, - T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, - W: Send + Sync, -{ - fn session_kind(&self) -> AgentKind { - AgentKind::OAuth - } - fn session_info( - &self, - ) -> impl Future< - Output = std::option::Option<( - jacquard_common::types::did::Did<'static>, - std::option::Option>, - )>, - > { - async { - let (did, sid) = OAuthSession::::session_info(self).await; - Some((did.into_static(), Some(sid.into_static()))) - } - } - fn endpoint(&self) -> impl Future { - async { self.endpoint().await } - } - fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future { - async { self.set_options(opts).await } - } - fn refresh(&self) -> impl Future, ClientError>> { - async { - self.refresh() - .await - .map(|t| t.into_static()) - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e)))) - } - } -} - /// Thin wrapper over a stateful session providing a uniform `XrpcClient`. pub struct Agent { inner: A, @@ -319,29 +279,28 @@ impl Agent { } /// Refresh the session and return a fresh token. - pub async fn refresh(&self) -> Result, ClientError> { + pub async fn refresh(&self) -> ClientResult> { self.inner.refresh().await } } -#[cfg(feature = "api")] -use jacquard_api::com_atproto::{ - repo::{ - create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, - get_record::GetRecordResponse, put_record::PutRecordOutput, - }, - server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput}, -}; - -/// doc +/// Output type for a collection record retrieval operation pub type CollectionOutput<'a, R> = <::Record as XrpcResp>::Output<'a>; -/// doc +/// Error type for a collection record retrieval operation pub type CollectionErr<'a, R> = <::Record as XrpcResp>::Err<'a>; -/// doc +/// Response type for the get request of a vec update operation pub type VecGetResponse = <::GetRequest as XrpcRequest>::Response; -/// doc +/// Response type for the put request of a vec update operation pub type VecPutResponse = <::PutRequest as XrpcRequest>::Response; +type CollectionError<'a, R> = <::Record as XrpcResp>::Err<'a>; + +type VecUpdateGetError<'a, U> = + <<::GetRequest as XrpcRequest>::Response as XrpcResp>::Err<'a>; + +type VecUpdatePutError<'a, U> = + <<::PutRequest as XrpcRequest>::Response as XrpcResp>::Err<'a>; + /// Extension trait providing convenience methods for common repository operations. /// /// This trait is automatically implemented for any type that implements both @@ -423,7 +382,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { &self, record: R, rkey: Option>>, - ) -> impl Future, AgentError>> + ) -> impl Future>> where R: Collection + serde::Serialize, { @@ -435,12 +394,13 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { use jacquard_common::types::ident::AtIdentifier; use jacquard_common::types::value::to_data; - let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; + let (did, _) = self + .session_info() + .await + .ok_or_else(AgentError::no_session)?; - let data = to_data(&record).map_err(|e| AgentError::SubOperation { - step: "serialize record", - error: Box::new(e), - })?; + let data = + to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; let request = CreateRecord::new() .repo(AtIdentifier::Did(did)) @@ -451,13 +411,9 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let response = self.send(request).await?; response.into_output().map_err(|e| match e { - XrpcError::Auth(auth) => AgentError::Auth(auth), - XrpcError::Generic(g) => AgentError::Generic(g), - XrpcError::Decode(e) => AgentError::Decode(e), - XrpcError::Xrpc(typed) => AgentError::SubOperation { - step: "create record", - error: Box::new(typed), - }, + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed), }) } } @@ -491,7 +447,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { fn get_record( &self, uri: &AtUri<'_>, - ) -> impl Future, ClientError>> + ) -> impl Future>> where R: Collection, { @@ -503,19 +459,18 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { // Validate that URI's collection matches the expected type if let Some(uri_collection) = uri.collection() { if uri_collection.as_str() != R::nsid().as_str() { - return Err(ClientError::Transport(TransportError::Other( - format!( + return Err(ClientError::invalid_request(format!( "Collection mismatch: URI contains '{}' but type parameter expects '{}'", uri_collection, R::nsid() - ) - .into(), - ))); + )) + .with_help("ensure the URI collection matches the record type")); } } let rkey = uri.rkey().ok_or_else(|| { - ClientError::Transport(TransportError::Other("AtUri missing rkey".into())) + ClientError::invalid_request("AtUri missing rkey") + .with_help("ensure the URI includes a record key after the collection") })?; // Resolve authority (DID or handle) to get DID and PDS @@ -523,16 +478,14 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let (repo_did, pds_url) = match uri.authority() { AtIdentifier::Did(did) => { let pds = self.pds_for_did(did).await.map_err(|e| { - ClientError::Transport(TransportError::Other( - format!("Failed to resolve PDS for {}: {}", did, e).into(), - )) + ClientError::from(e) + .with_context("DID document resolution failed during record retrieval") })?; (did.clone(), pds) } AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { - ClientError::Transport(TransportError::Other( - format!("Failed to resolve handle {}: {}", handle, e).into(), - )) + ClientError::from(e) + .with_context("handle resolution failed during record retrieval") })?, }; @@ -545,13 +498,13 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { .build(); let response: Response = { - let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) - .map_err(|e| ClientError::Transport(TransportError::from(e)))?; + let http_request = + xrpc::build_http_request(&pds_url, &request, &self.opts().await)?; let http_response = self .send_http(http_request) .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; + .map_err(|e| ClientError::transport(e))?; xrpc::process_response(http_response) }?; @@ -566,20 +519,24 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { fn fetch_record( &self, uri: &RecordUri<'_, R>, - ) -> impl Future, ClientError>> + ) -> impl Future>> where R: Collection, for<'a> CollectionOutput<'a, R>: IntoStatic>, - for<'a> CollectionErr<'a, R>: IntoStatic>, + for<'a> CollectionErr<'a, R>: IntoStatic> + Send + Sync, { let uri = uri.as_uri(); async move { let response = self.get_record::(uri).await?; let response: Response = response.transmute(); - let output = response - .into_output() - .map_err(|e| ClientError::Transport(TransportError::Other(e.to_string().into())))?; - // TODO: fix this to use a better error lol + let output = response.into_output().map_err(|e| match e { + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => { + AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) + .with_details(typed.to_string()) + } + })?; Ok(output) } } @@ -614,10 +571,13 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { &self, uri: &AtUri<'_>, f: impl FnOnce(&mut R), - ) -> impl Future, AgentError>> + ) -> impl Future>> where R: Collection + Serialize, R: for<'a> From>, + for<'a> as IntoStatic>::Output: + IntoStatic + std::error::Error + Send + Sync, + for<'a> CollectionError<'a, R>: Send + Sync + std::error::Error + IntoStatic, { async move { #[cfg(feature = "tracing")] @@ -629,13 +589,12 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { // Parse to get R<'_> borrowing from response buffer let record = response.parse().map_err(|e| match e { - XrpcError::Auth(auth) => AgentError::Auth(auth), - XrpcError::Generic(g) => AgentError::Generic(g), - XrpcError::Decode(e) => AgentError::Decode(e), - XrpcError::Xrpc(typed) => AgentError::SubOperation { - step: "get record", - error: format!("{:?}", typed).into(), - }, + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => { + AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) + .with_details(typed.to_string()) + } })?; // Convert to owned @@ -647,9 +606,11 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { // Put it back let rkey = uri .rkey() - .ok_or(AgentError::SubOperation { - step: "extract rkey", - error: "AtUri missing rkey".into(), + .ok_or_else(|| { + AgentError::sub_operation( + "extract rkey", + std::io::Error::new(std::io::ErrorKind::InvalidInput, "AtUri missing rkey"), + ) })? .clone() .into_static(); @@ -664,7 +625,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { fn delete_record( &self, rkey: RecordKey>, - ) -> impl Future, AgentError>> + ) -> impl Future>> where R: Collection, { @@ -675,7 +636,10 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { use jacquard_api::com_atproto::repo::delete_record::DeleteRecord; use jacquard_common::types::ident::AtIdentifier; - let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; + let (did, _) = self + .session_info() + .await + .ok_or_else(AgentError::no_session)?; let request = DeleteRecord::new() .repo(AtIdentifier::Did(did)) @@ -685,13 +649,9 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let response = self.send(request).await?; response.into_output().map_err(|e| match e { - XrpcError::Auth(auth) => AgentError::Auth(auth), - XrpcError::Generic(g) => AgentError::Generic(g), - XrpcError::Decode(e) => AgentError::Decode(e), - XrpcError::Xrpc(typed) => AgentError::SubOperation { - step: "delete record", - error: Box::new(typed), - }, + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed), }) } } @@ -704,7 +664,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { &self, rkey: RecordKey>, record: R, - ) -> impl Future, AgentError>> + ) -> impl Future>> where R: Collection + serde::Serialize, { @@ -716,12 +676,13 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { use jacquard_common::types::ident::AtIdentifier; use jacquard_common::types::value::to_data; - let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; + let (did, _) = self + .session_info() + .await + .ok_or_else(AgentError::no_session)?; - let data = to_data(&record).map_err(|e| AgentError::SubOperation { - step: "serialize record", - error: Box::new(e), - })?; + let data = + to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; let request = PutRecord::new() .repo(AtIdentifier::Did(did)) @@ -732,13 +693,9 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let response = self.send(request).await?; response.into_output().map_err(|e| match e { - XrpcError::Auth(auth) => AgentError::Auth(auth), - XrpcError::Generic(g) => AgentError::Generic(g), - XrpcError::Decode(e) => AgentError::Decode(e), - XrpcError::Xrpc(typed) => AgentError::SubOperation { - step: "put record", - error: Box::new(typed), - }, + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed), }) } } @@ -767,7 +724,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { &self, data: impl Into, mime_type: MimeType<'_>, - ) -> impl Future, AgentError>> { + ) -> impl Future>> { async move { #[cfg(feature = "tracing")] let _span = tracing::debug_span!("upload_blob", mime_type = %mime_type).entered(); @@ -783,24 +740,16 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { opts.extra_headers.push(( CONTENT_TYPE, - http::HeaderValue::from_str(mime_type.as_str()).map_err(|e| { - AgentError::SubOperation { - step: "set Content-Type header", - error: Box::new(e), - } - })?, + http::HeaderValue::from_str(mime_type.as_str()) + .map_err(|e| AgentError::sub_operation("set Content-Type header", e))?, )); let response = self.send_with_opts(request, opts).await?; let debug: serde_json::Value = serde_json::from_slice(response.buffer()).unwrap(); println!("json: {}", serde_json::to_string_pretty(&debug).unwrap()); let output = response.into_output().map_err(|e| match e { - XrpcError::Auth(auth) => AgentError::Auth(auth), - XrpcError::Generic(g) => AgentError::Generic(g), - XrpcError::Decode(e) => AgentError::Decode(e), - XrpcError::Xrpc(typed) => AgentError::SubOperation { - step: "upload blob", - error: Box::new(typed), - }, + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed), })?; Ok(output.blob.blob().clone().into_static()) } @@ -822,26 +771,30 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { fn update_vec( &self, modify: impl FnOnce(&mut Vec<::Item>), - ) -> impl Future>, AgentError>> + ) -> impl Future>>> where U: VecUpdate, ::PutRequest: Send + Sync, ::GetRequest: Send + Sync, VecGetResponse: Send + Sync, VecPutResponse: Send + Sync, + for<'a> VecUpdateGetError<'a, U>: Send + Sync + std::error::Error + IntoStatic, + for<'a> VecUpdatePutError<'a, U>: Send + Sync + std::error::Error + IntoStatic, + for<'a> as IntoStatic>::Output: + Send + Sync + std::error::Error + IntoStatic + 'static, + for<'a> as IntoStatic>::Output: + Send + Sync + std::error::Error + IntoStatic + 'static, { async { // Fetch current data let get_request = U::build_get(); let response = self.send(get_request).await?; let output = response.parse().map_err(|e| match e { - XrpcError::Auth(auth) => AgentError::Auth(auth), - XrpcError::Generic(g) => AgentError::Generic(g), - XrpcError::Decode(e) => AgentError::Decode(e), - XrpcError::Xrpc(_) => AgentError::SubOperation { - step: "get vec", - error: format!("{:?}", e).into(), - }, + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => { + AgentError::sub_operation("update vec", typed.into_static()) + } })?; // Extract vec (converts to owned via IntoStatic) @@ -872,13 +825,19 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { fn update_vec_item( &self, item: ::Item, - ) -> impl Future>, AgentError>> + ) -> impl Future>>> where U: VecUpdate, ::PutRequest: Send + Sync, ::GetRequest: Send + Sync, VecGetResponse: Send + Sync, VecPutResponse: Send + Sync, + for<'a> VecUpdateGetError<'a, U>: Send + Sync + std::error::Error + IntoStatic, + for<'a> VecUpdatePutError<'a, U>: Send + Sync + std::error::Error + IntoStatic, + for<'a> as IntoStatic>::Output: + Send + Sync + std::error::Error + IntoStatic + 'static, + for<'a> as IntoStatic>::Output: + Send + Sync + std::error::Error + IntoStatic + 'static, { async { self.update_vec::(|vec| { @@ -896,6 +855,72 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { #[cfg(feature = "api")] impl AgentSessionExt for T {} +impl AgentSession for CredentialSession +where + S: SessionStore + Send + Sync + 'static, + T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static, + W: Send + Sync, +{ + fn session_kind(&self) -> AgentKind { + AgentKind::AppPassword + } + fn session_info( + &self, + ) -> impl Future, Option>)>> { + async move { + CredentialSession::::session_info(self) + .await + .map(|(did, sid)| (did, Some(sid))) + } + } + fn endpoint(&self) -> impl Future { + async move { CredentialSession::::endpoint(self).await } + } + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future { + async move { CredentialSession::::set_options(self, opts).await } + } + fn refresh(&self) -> impl Future>> { + async move { + Ok(CredentialSession::::refresh(self) + .await? + .into_static()) + } + } +} + +impl AgentSession for OAuthSession +where + S: ClientAuthStore + Send + Sync + 'static, + T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, + W: Send + Sync, +{ + fn session_kind(&self) -> AgentKind { + AgentKind::OAuth + } + fn session_info( + &self, + ) -> impl Future, Option>)>> { + async { + let (did, sid) = OAuthSession::::session_info(self).await; + Some((did.into_static(), Some(sid.into_static()))) + } + } + fn endpoint(&self) -> impl Future { + async { self.endpoint().await } + } + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future { + async { self.set_options(opts).await } + } + fn refresh(&self) -> impl Future>> { + async { + self.refresh() + .await + .map(|t| t.into_static()) + .map_err(|e| ClientError::transport(e).with_context("OAuth token refresh failed")) + } + } +} + impl HttpClient for Agent { type Error = ::Error; @@ -1103,14 +1128,14 @@ impl IdentityResolver for Agent { fn resolve_handle( &self, handle: &Handle<'_>, - ) -> impl Future, IdentityError>> { + ) -> impl Future, IdentityError>> { async { self.inner.resolve_handle(handle).await } } fn resolve_did_doc( &self, did: &Did<'_>, - ) -> impl Future> { + ) -> impl Future> { async { self.inner.resolve_did_doc(did).await } } } @@ -1134,7 +1159,7 @@ impl AgentSession for Agent { async { self.set_options(opts).await } } - fn refresh(&self) -> impl Future, ClientError>> { + fn refresh(&self) -> impl Future>> { async { self.refresh().await } } } @@ -1144,116 +1169,3 @@ impl From for Agent { Self::new(inner) } } - -/// Alias for an agent over a credential (app‑password) session. -pub type CredentialAgent = Agent>; -/// Alias for an agent over an OAuth (DPoP) session. -pub type OAuthAgent = Agent>; - -/// BasicClient: in-memory store + public resolver over a credential session. -pub type BasicClient = Agent< - CredentialSession< - MemorySessionStore, - jacquard_identity::PublicResolver, - >, ->; - -impl BasicClient { - /// Create an unauthenticated BasicClient for public API access. - /// - /// Uses an in-memory session store and public resolver. Suitable for - /// read-only operations on public data without authentication. - /// - /// # Example - /// - /// ```no_run - /// # use jacquard::client::BasicClient; - /// # use jacquard::types::string::AtUri; - /// # use jacquard_api::app_bsky::feed::post::Post; - /// use crate::jacquard::client::AgentSessionExt; - /// # #[tokio::main] - /// # async fn main() -> Result<(), Box> { - /// let client = BasicClient::unauthenticated(); - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); - /// let response = client.get_record::>(&uri).await?; - /// # Ok(()) - /// # } - /// ``` - pub fn unauthenticated() -> Self { - use std::sync::Arc; - let http = reqwest::Client::new(); - let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); - let store = MemorySessionStore::default(); - let session = CredentialSession::new(Arc::new(store), Arc::new(resolver)); - Agent::new(session) - } -} - -impl Default for BasicClient { - fn default() -> Self { - Self::unauthenticated() - } -} - -/// MemoryCredentialSession: credential session with in memory store and identity resolver -pub type MemoryCredentialSession = CredentialSession< - MemorySessionStore, - jacquard_identity::PublicResolver, ->; - -impl MemoryCredentialSession { - /// Create an unauthenticated MemoryCredentialSession. - /// - /// Uses an in memory store and a public resolver. - /// Equivalent to a BasicClient that isn't wrapped in Agent - pub fn unauthenticated() -> Self { - use std::sync::Arc; - let http = reqwest::Client::new(); - let resolver = jacquard_identity::PublicResolver::new(http, Default::default()); - let store = MemorySessionStore::default(); - CredentialSession::new(Arc::new(store), Arc::new(resolver)) - } - - /// Create a MemoryCredentialSession and authenticate with the provided details - /// - /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL. - /// - `session_id`: optional session label; defaults to "session". - /// - Persists and activates the session, and updates the base endpoint to the user's PDS. - /// - /// # Example - /// ```no_run - /// # use jacquard::client::BasicClient; - /// # use jacquard::types::string::AtUri; - /// # use jacquard::api::app_bsky::feed::post::Post; - /// # use jacquard::types::string::Datetime; - /// # use jacquard::CowStr; - /// use jacquard::client::MemoryCredentialSession; - /// use jacquard::client::{Agent, AgentSessionExt}; - /// # #[tokio::main] - /// # async fn main() -> Result<(), Box> { - /// # let (identifier, password, post_text): (CowStr<'_>, CowStr<'_>, CowStr<'_>) = todo!(); - /// let (session, _) = MemoryCredentialSession::authenticated(identifier, password, None).await?; - /// let agent = Agent::from(session); - /// let post = Post::builder().text(post_text).created_at(Datetime::now()).build(); - /// let output = agent.create_record(post, None).await?; - /// # Ok(()) - /// # } - /// ``` - pub async fn authenticated( - identifier: CowStr<'_>, - password: CowStr<'_>, - session_id: Option>, - ) -> Result<(Self, AtpSession), ClientError> { - let session = MemoryCredentialSession::unauthenticated(); - let auth = session - .login(identifier, password, session_id, None, None) - .await?; - Ok((session, auth)) - } -} - -impl Default for MemoryCredentialSession { - fn default() -> Self { - MemoryCredentialSession::unauthenticated() - } -} diff --git a/crates/jacquard/src/client/credential_session.rs b/crates/jacquard/src/client/credential_session.rs index ff9f998e..62310d57 100644 --- a/crates/jacquard/src/client/credential_session.rs +++ b/crates/jacquard/src/client/credential_session.rs @@ -5,7 +5,7 @@ use jacquard_api::com_atproto::server::{ }; use jacquard_common::{ AuthorizationToken, CowStr, IntoStatic, - error::{AuthError, ClientError, TransportError, XrpcResult}, + error::{AuthError, ClientError, XrpcResult}, http_client::HttpClient, session::SessionStore, types::{did::Did, string::Handle}, @@ -144,13 +144,13 @@ where T: HttpClient, { /// Refresh the active session by calling `com.atproto.server.refreshSession`. - pub async fn refresh(&self) -> Result, ClientError> { + pub async fn refresh(&self) -> std::result::Result, ClientError> { let key = self .key .read() .await .clone() - .ok_or(ClientError::Auth(AuthError::NotAuthenticated))?; + .ok_or_else(|| ClientError::auth(AuthError::NotAuthenticated))?; let session = self.store.get(&key).await; let endpoint = self.endpoint().await; let mut opts = self.options.read().await.clone(); @@ -163,14 +163,17 @@ where .await?; let refresh = response .parse() - .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?; + .map_err(|_| ClientError::auth(AuthError::RefreshFailed) + .with_help("ensure refresh token is valid and not expired") + .with_url("com.atproto.server.refreshSession"))?; let new_session: AtpSession = refresh.into(); let token = AuthorizationToken::Bearer(new_session.access_jwt.clone()); self.store .set(key, new_session) .await - .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?; + .map_err(|e| ClientError::from(e) + .with_context("failed to persist refreshed session to store"))?; Ok(token) } @@ -193,7 +196,7 @@ where session_id: Option>, allow_takendown: Option, auth_factor_token: Option>, - ) -> Result + ) -> std::result::Result where S: Any + 'static, { @@ -205,56 +208,44 @@ where let pds = if identifier.as_ref().starts_with("http://") || identifier.as_ref().starts_with("https://") { - Url::parse(identifier.as_ref()).map_err(|e| { - ClientError::Transport(TransportError::InvalidRequest(e.to_string())) - })? + Url::parse(identifier.as_ref()) + .map_err(|e: url::ParseError| ClientError::from(e) + .with_help("identifier should be a valid https:// URL, handle, or DID"))? } else if identifier.as_ref().starts_with("did:") { - let did = Did::new(identifier.as_ref()).map_err(|e| { - ClientError::Transport(TransportError::InvalidRequest(format!( - "invalid did: {:?}", - e - ))) - })?; + let did = Did::new(identifier.as_ref()) + .map_err(|e| ClientError::invalid_request(format!("invalid did: {:?}", e)) + .with_help("DID format should be did:method:identifier (e.g., did:plc:abc123)"))?; let resp = self .client .resolve_did_doc(&did) .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; - resp.into_owned() - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? + .map_err(|e| ClientError::from(e) + .with_context("DID document resolution failed during login"))?; + resp.into_owned()? .pds_endpoint() - .ok_or_else(|| { - ClientError::Transport(TransportError::InvalidRequest( - "missing PDS endpoint".into(), - )) - })? + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") + .with_help("DID document must include a PDS service endpoint"))? } else { // treat as handle - let handle = - jacquard_common::types::string::Handle::new(identifier.as_ref()).map_err(|e| { - ClientError::Transport(TransportError::InvalidRequest(format!( - "invalid handle: {:?}", - e - ))) - })?; + let handle = jacquard_common::types::string::Handle::new(identifier.as_ref()) + .map_err(|e| ClientError::invalid_request(format!("invalid handle: {:?}", e)) + .with_help("handle format should be domain.tld (e.g., alice.bsky.social)"))?; let did = self .client .resolve_handle(&handle) .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; + .map_err(|e| ClientError::from(e) + .with_context("handle resolution failed during login"))?; let resp = self .client .resolve_did_doc(&did) .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; - resp.into_owned() - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? + .map_err(|e| ClientError::from(e) + .with_context("DID document resolution failed during login"))?; + resp.into_owned()? .pds_endpoint() - .ok_or_else(|| { - ClientError::Transport(TransportError::InvalidRequest( - "missing PDS endpoint".into(), - )) - })? + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") + .with_help("DID document must include a PDS service endpoint"))? }; // Build and send createSession @@ -275,7 +266,9 @@ where .await?; let out = resp .parse() - .map_err(|_| ClientError::Auth(AuthError::NotAuthenticated))?; + .map_err(|_| ClientError::auth(AuthError::NotAuthenticated) + .with_help("check identifier and password are correct") + .with_url("com.atproto.server.createSession"))?; let session = AtpSession::from(out); let sid = session_id.unwrap_or_else(|| CowStr::new_static("session")); @@ -283,7 +276,8 @@ where self.store .set(key.clone(), session.clone()) .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; + .map_err(|e| ClientError::from(e) + .with_context("failed to persist session to store"))?; // If using FileAuthStore, persist PDS for faster resume if let Some(file_store) = (&*self.store as &dyn Any).downcast_ref::() @@ -298,7 +292,11 @@ where } /// Restore a previously persisted app-password session and set base endpoint. - pub async fn restore(&self, did: Did<'_>, session_id: CowStr<'_>) -> Result<(), ClientError> + pub async fn restore( + &self, + did: Did<'_>, + session_id: CowStr<'_>, + ) -> std::result::Result<(), ClientError> where S: Any + 'static, { @@ -309,7 +307,7 @@ where let key = (did.clone().into_static(), session_id.clone().into_static()); let Some(sess) = self.store.get(&key).await else { - return Err(ClientError::Auth(AuthError::NotAuthenticated)); + return Err(ClientError::auth(AuthError::NotAuthenticated)); }; // Try to read cached PDS; otherwise resolve via DID let pds = if let Some(file_store) = @@ -323,16 +321,11 @@ where let resp = self .client .resolve_did_doc(&did) - .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; - resp.into_owned() - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? + .await?; + resp.into_owned()? .pds_endpoint() - .ok_or_else(|| { - ClientError::Transport(TransportError::InvalidRequest( - "missing PDS endpoint".into(), - )) - })? + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") + .with_help("DID document must include a PDS service endpoint"))? }); // Activate @@ -341,8 +334,7 @@ where // ensure store has the session (no-op if it existed) self.store .set((sess.did.clone(), session_id.into_static()), sess) - .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; + .await?; if let Some(file_store) = (&*self.store as &dyn Any).downcast_ref::() { @@ -356,13 +348,13 @@ where &self, did: Did<'_>, session_id: CowStr<'_>, - ) -> Result<(), ClientError> + ) -> std::result::Result<(), ClientError> where S: Any + 'static, { let key = (did.clone().into_static(), session_id.into_static()); if self.store.get(&key).await.is_none() { - return Err(ClientError::Auth(AuthError::NotAuthenticated)); + return Err(ClientError::auth(AuthError::NotAuthenticated)); } // Endpoint from store if cached, else resolve let pds = if let Some(file_store) = @@ -376,16 +368,11 @@ where let resp = self .client .resolve_did_doc(&did) - .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; - resp.into_owned() - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))? + .await?; + resp.into_owned()? .pds_endpoint() - .ok_or_else(|| { - ClientError::Transport(TransportError::InvalidRequest( - "missing PDS endpoint".into(), - )) - })? + .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint") + .with_help("DID document must include a PDS service endpoint"))? }); *self.key.write().await = Some(key.clone()); *self.endpoint.write().await = Some(pds); @@ -398,14 +385,13 @@ where } /// Clear and delete the current session from the store. - pub async fn logout(&self) -> Result<(), ClientError> { + pub async fn logout(&self) -> std::result::Result<(), ClientError> { let Some(key) = self.key.read().await.clone() else { return Ok(()); }; self.store .del(&key) - .await - .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; + .await?; *self.key.write().await = None; Ok(()) } @@ -484,7 +470,14 @@ where #[inline] fn is_expired(response: &XrpcResult>) -> bool { match response { - Err(ClientError::Auth(AuthError::TokenExpired)) => true, + Err(e) + if matches!( + e.kind(), + jacquard_common::error::ClientErrorKind::Auth(AuthError::TokenExpired) + ) => + { + true + } Ok(resp) => match resp.parse() { Err(XrpcError::Auth(AuthError::TokenExpired)) => true, _ => false, @@ -503,22 +496,39 @@ where async fn send_http_streaming( &self, request: http::Request>, - ) -> core::result::Result, Self::Error> { + ) -> core::result::Result, Self::Error> + { self.client.send_http_streaming(request).await } + #[cfg(not(target_arch = "wasm32"))] async fn send_http_bidirectional( &self, parts: http::request::Parts, body: Str, ) -> core::result::Result, Self::Error> where - Str: n0_future::Stream> - + Send + Str: n0_future::Stream< + Item = core::result::Result, + > + Send + 'static, { self.client.send_http_bidirectional(parts, body).await } + + #[cfg(target_arch = "wasm32")] + async fn send_http_bidirectional( + &self, + parts: http::request::Parts, + body: Str, + ) -> core::result::Result, Self::Error> + where + Str: n0_future::Stream< + Item = core::result::Result, + > + 'static, + { + self.client.send_http_bidirectional(parts, body).await + } } #[cfg(feature = "streaming")] @@ -589,7 +599,7 @@ where <::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp, { use jacquard_common::StreamError; - use n0_future::{StreamExt, TryStreamExt}; + use n0_future::TryStreamExt; let base_uri = self.base_uri().await; let mut opts = self.options.read().await.clone(); @@ -640,7 +650,7 @@ where .into_parts(); let body_stream = - jacquard_common::stream::ByteStream::new(stream.0.map_ok(|f| f.buffer).boxed()); + jacquard_common::stream::ByteStream::new(Box::pin(stream.0.map_ok(|f| f.buffer))); // Clone the stream for potential retry let (body1, body2) = body_stream.tee(); @@ -672,7 +682,9 @@ where http::HeaderValue::from_str(&format!("DPoP {}", t.as_ref())) } } - .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?; + .map_err(|e| { + StreamError::protocol(format!("Invalid authorization token: {}", e)) + })?; builder = builder.header(http::header::AUTHORIZATION, hv); } if let Some(proxy) = &opts.atproto_proxy { @@ -704,13 +716,17 @@ where .await .map_err(StreamError::transport)?; let (resp_parts, resp_body) = response.into_parts(); - Ok(jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( - resp_parts, resp_body, - )) + Ok( + jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( + resp_parts, resp_body, + ), + ) } else { - Ok(jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( - resp_parts, resp_body, - )) + Ok( + jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( + resp_parts, resp_body, + ), + ) } } } diff --git a/crates/jacquard/src/client/error.rs b/crates/jacquard/src/client/error.rs new file mode 100644 index 00000000..676c4463 --- /dev/null +++ b/crates/jacquard/src/client/error.rs @@ -0,0 +1,279 @@ +use jacquard_common::error::{AuthError, ClientError}; +use jacquard_common::types::did::Did; +use jacquard_common::types::nsid::Nsid; +use jacquard_common::types::string::{RecordKey, Rkey}; +use jacquard_common::xrpc::XrpcError; +use jacquard_common::{Data, IntoStatic}; +use smol_str::SmolStr; + +/// Boxed error type for wrapping arbitrary errors +pub type BoxError = Box; + +/// Error type for Agent convenience methods +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{kind}")] +pub struct AgentError { + #[diagnostic_source] + kind: AgentErrorKind, + #[source] + source: Option, + #[help] + help: Option, + context: Option, + url: Option, + details: Option, + location: Option, + xrpc: Option>, +} + +/// Error categories for Agent operations +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum AgentErrorKind { + /// Transport/network layer failure + #[error("client error")] + #[diagnostic(code(jacquard::agent::client))] + Client, + + /// No session available for operations requiring authentication + #[error("no session available")] + #[diagnostic( + code(jacquard::agent::no_session), + help("ensure agent is authenticated before performing operations") + )] + NoSession, + + /// Authentication error from XRPC layer + #[error("auth error: {0}")] + #[diagnostic(code(jacquard::agent::auth))] + Auth(AuthError), + + /// Record operation failed with typed error from endpoint + #[error("record operation failed on {collection}/{rkey:?} in repo {repo}")] + #[diagnostic(code(jacquard::agent::record_operation))] + RecordOperation { + /// The repository DID + repo: Did<'static>, + /// The collection NSID + collection: Nsid<'static>, + /// The record key + rkey: RecordKey>, + }, + + /// Multi-step operation failed at sub-step (e.g., get failed in update_record) + #[error("operation failed at step '{step}'")] + #[diagnostic(code(jacquard::agent::sub_operation))] + SubOperation { + /// Description of which step failed + step: &'static str, + }, + /// XRPC error + #[error("xrpc error")] + #[diagnostic(code(jacquard::agent::xrpc))] + XrpcError, +} + +impl AgentError { + /// Create a new error with the given kind and optional source + pub fn new(kind: AgentErrorKind, source: Option) -> Self { + Self { + kind, + source, + help: None, + context: None, + url: None, + details: None, + location: None, + xrpc: None, + } + } + + /// Get the error kind + pub fn kind(&self) -> &AgentErrorKind { + &self.kind + } + + /// Get the source error if present + pub fn source_err(&self) -> Option<&BoxError> { + self.source.as_ref() + } + + /// Get the context string if present + pub fn context(&self) -> Option<&str> { + self.context.as_ref().map(|s| s.as_str()) + } + + /// Get the URL if present + pub fn url(&self) -> Option<&str> { + self.url.as_ref().map(|s| s.as_str()) + } + + /// Get the details if present + pub fn details(&self) -> Option<&str> { + self.details.as_ref().map(|s| s.as_str()) + } + + /// Get the location if present + pub fn location(&self) -> Option<&str> { + self.location.as_ref().map(|s| s.as_str()) + } + + /// Add help text to this error + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Add context to this error + pub fn with_context(mut self, context: impl Into) -> Self { + self.context = Some(context.into()); + self + } + + /// Add URL to this error + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Add details to this error + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } + + /// Add location to this error + pub fn with_location(mut self, location: impl Into) -> Self { + self.location = Some(location.into()); + self + } + + /// Add XRPC error data to this error for observability + pub fn with_xrpc(mut self, xrpc: XrpcError) -> Self + where + E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize, + { + use jacquard_common::types::value::to_data; + // Attempt to serialize XrpcError to Data for observability + if let Ok(data) = to_data(&xrpc) { + self.xrpc = Some(data.into_static()); + } + self + } + + /// Create an XRPC error with attached error data for observability + pub fn xrpc(error: XrpcError) -> Self + where + E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize + Send + Sync, + ::Output: IntoStatic + std::error::Error + Send + Sync, + { + use jacquard_common::types::value::to_data; + // Attempt to serialize XrpcError to Data for observability + if let Ok(data) = to_data(&error) { + let mut error = Self::new( + AgentErrorKind::XrpcError, + Some(Box::new(error.into_static())), + ); + error.xrpc = Some(data.into_static()); + error + } else { + Self::new( + AgentErrorKind::XrpcError, + Some(Box::new(error.into_static())), + ) + } + } + + // Constructors + + /// Create a no session error + pub fn no_session() -> Self { + Self::new(AgentErrorKind::NoSession, None) + } + + /// Create a sub-operation error for multi-step operations + pub fn sub_operation( + step: &'static str, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::new( + AgentErrorKind::SubOperation { step }, + Some(Box::new(source)), + ) + } + + /// Create a record operation error + pub fn record_operation( + repo: Did<'static>, + collection: Nsid<'static>, + rkey: RecordKey>, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::new( + AgentErrorKind::RecordOperation { + repo, + collection, + rkey, + }, + Some(Box::new(source)), + ) + } + + /// Create an authentication error + pub fn auth(auth_error: AuthError) -> Self { + Self::new(AgentErrorKind::Auth(auth_error), None) + } +} + +impl From for AgentError { + fn from(e: ClientError) -> Self { + Self::new(AgentErrorKind::Client, Some(Box::new(e))) + } +} + +impl From for AgentError { + fn from(e: AuthError) -> Self { + Self::new(AgentErrorKind::Auth(e), None) + .with_help("check authentication credentials and session state") + } +} + +/// Result type for Agent operations +pub type Result = core::result::Result; + +impl IntoStatic for AgentError { + type Output = AgentError; + + fn into_static(self) -> Self::Output { + match self.kind { + AgentErrorKind::RecordOperation { + repo, + collection, + rkey, + } => Self { + kind: AgentErrorKind::RecordOperation { + repo: repo.into_static(), + collection: collection.into_static(), + rkey: rkey.into_static(), + }, + source: self.source, + help: self.help, + context: self.context, + url: self.url, + details: self.details, + location: self.location, + xrpc: self.xrpc, + }, + AgentErrorKind::Auth(auth) => Self { + kind: AgentErrorKind::Auth(auth.into_static()), + source: self.source, + help: self.help, + context: self.context, + url: self.url, + details: self.details, + location: self.location, + xrpc: self.xrpc, + }, + _ => self, + } + } +} diff --git a/crates/jacquard/src/moderation.rs b/crates/jacquard/src/moderation.rs index 2add17ef..21d4c09f 100644 --- a/crates/jacquard/src/moderation.rs +++ b/crates/jacquard/src/moderation.rs @@ -2,13 +2,13 @@ //! //! This is an attempt to semi-generalize the Bluesky moderation system. It avoids //! depending on their lexicons as much as reasonably possible. This works via a -//! trait, [`Labeled`], which represents things that have labels for moderation +//! trait, [`Labeled`][crate::moderation::Labeled], which represents things that have labels for moderation //! applied to them. This way the moderation application functions can operate //! primarily via the trait, and are thus generic over lexicon types, and are //! easy to use with your own types. //! //! For more complex types which might have labels applied to components, -//! there is the [`Moderateable`] trait. A mostly complete implementation for +//! there is the [`Moderateable`][crate::moderation::Moderateable] trait. A mostly complete implementation for //! `FeedViewPost` is available for reference. The trait method outputs a `Vec` //! of tuples, where the first element is a string tag and the second is the //! moderation decision for the tagged element. This lets application developers @@ -16,7 +16,7 @@ //! mostly match Bluesky behaviour (respecting "!hide", and such) by default. //! //! I've taken the time to go through the generated API bindings and implement -//! the [`Labeled`] trait for a number of types. It's a fairly easy trait to +//! the [`Labeled`][crate::moderation::Labeled] trait for a number of types. It's a fairly easy trait to //! implement, just not really automatable. //! //! diff --git a/crates/jacquard/src/moderation/fetch.rs b/crates/jacquard/src/moderation/fetch.rs index 446a635e..128ae18d 100644 --- a/crates/jacquard/src/moderation/fetch.rs +++ b/crates/jacquard/src/moderation/fetch.rs @@ -9,7 +9,7 @@ use jacquard_api::app_bsky::labeler::{ }; use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels}; use jacquard_common::cowstr::ToCowStr; -use jacquard_common::error::{ClientError, TransportError}; +use jacquard_common::error::ClientError; use jacquard_common::types::collection::Collection; use jacquard_common::types::string::Did; use jacquard_common::types::uri::RecordUri; @@ -30,14 +30,10 @@ pub async fn fetch_labeler_defs( let response = client.send(request).await?; let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e { - XrpcError::Auth(auth) => ClientError::Auth(auth), - XrpcError::Generic(g) => { - ClientError::Transport(TransportError::Other(g.to_string().into())) - } - XrpcError::Decode(e) => ClientError::Decode(e), - XrpcError::Xrpc(typed) => { - ClientError::Transport(TransportError::Other(format!("{:?}", typed).into())) - } + XrpcError::Auth(auth) => ClientError::auth(auth), + XrpcError::Generic(g) => ClientError::decode(g.to_string()), + XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)), + XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)), })?; let mut defs = LabelerDefs::new(); @@ -81,7 +77,7 @@ pub async fn fetch_labeler_defs( pub async fn fetch_labeler_defs_direct( client: &(impl AgentSessionExt + Sync), dids: Vec>, -) -> Result, ClientError> { +) -> Result, AgentError> { #[cfg(feature = "tracing")] let _span = tracing::debug_span!("fetch_labeler_defs_direct", count = dids.len()).entered(); @@ -90,7 +86,7 @@ pub async fn fetch_labeler_defs_direct( for did in dids { let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str()); let record_uri = Service::uri(uri).map_err(|e| { - ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into())) + AgentError::from(ClientError::invalid_request(format!("Invalid URI: {}", e))) })?; let output = client.fetch_record(&record_uri).await?; @@ -135,8 +131,9 @@ pub async fn fetch_labels( .await? .into_output() .map_err(|e| match e { - XrpcError::Generic(e) => AgentError::Generic(e), - _ => unimplemented!(), // We know the error at this point is always GenericXrpcError + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)), })?; Ok((labels.labels, labels.cursor)) } @@ -157,7 +154,7 @@ pub async fn fetch_labeled_record( where R: Collection + From>, for<'a> CollectionOutput<'a, R>: IntoStatic>, - for<'a> CollectionErr<'a, R>: IntoStatic>, + for<'a> CollectionErr<'a, R>: IntoStatic> + Send + Sync, { let record: R = client.fetch_record(record_uri).await?.into(); let (labels, _) = diff --git a/rustdoc-host.nix b/rustdoc-host.nix new file mode 100644 index 00000000..b55178b8 --- /dev/null +++ b/rustdoc-host.nix @@ -0,0 +1,145 @@ +{ config, pkgs, lib, ... }: + +{ + # Basic system config + networking.firewall.allowedTCPPorts = [ 80 443 ]; + + # Rust toolchain for building docs + environment.systemPackages = with pkgs; [ + rustup + git + cargo + ]; + + # Build script to generate docs + environment.etc."rustdoc-build.sh" = { + text = '' + #!/usr/bin/env bash + set -euo pipefail + + REPO_URL="''${1:-https://github.com/orual/jacquard.git}" + BRANCH="''${2:-main}" + BUILD_DIR="/var/www/rustdoc/build" + OUTPUT_DIR="/var/www/rustdoc/docs" + + echo "Building docs from $REPO_URL ($BRANCH)..." + + # Clean and clone + rm -rf "$BUILD_DIR" + git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$BUILD_DIR" + cd "$BUILD_DIR" + + # Build docs with all features for jacquard-api + export RUSTDOCFLAGS="--html-in-header /etc/rustdoc-analytics.html" + cargo doc \ + --no-deps \ + --workspace \ + --all-features \ + --document-private-items + + # Copy to serving directory + rm -rf "$OUTPUT_DIR" + cp -r target/doc "$OUTPUT_DIR" + + # Create index redirect + cat > "$OUTPUT_DIR/index.html" < + + + + Jacquard Documentation + + +

Redirecting to jacquard documentation...

+ + + EOF + + chown -R nginx:nginx "$OUTPUT_DIR" + echo "Build complete! Docs available at $OUTPUT_DIR" + ''; + mode = "0755"; + }; + + # Optional analytics snippet (empty by default) + environment.etc."rustdoc-analytics.html" = { + text = '' + + ''; + }; + + # Nginx to serve the docs + services.nginx = { + enable = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + + virtualHosts."docs.example.com" = { + # Set this to your actual domain + # serverName = "docs.jacquard.dev"; + + # For cloudflare tunnel, you don't need ACME here + # If you want direct HTTPS: + # enableACME = true; + # forceSSL = true; + + root = "/var/www/rustdoc/docs"; + + locations."/" = { + tryFiles = "$uri $uri/ =404"; + extraConfig = '' + # Cache static assets + location ~* \.(css|js|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # CORS headers for cross-origin font loading + location ~* \.(woff|woff2)$ { + add_header Access-Control-Allow-Origin "*"; + } + ''; + }; + }; + }; + + # Create serving directory + systemd.tmpfiles.rules = [ + "d /var/www/rustdoc 0755 nginx nginx -" + "d /var/www/rustdoc/build 0755 nginx nginx -" + "d /var/www/rustdoc/docs 0755 nginx nginx -" + ]; + + # Optional: systemd service for periodic rebuilds + systemd.services.rustdoc-build = { + description = "Build Jacquard documentation"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.bash}/bin/bash /etc/rustdoc-build.sh"; + User = "nginx"; + }; + }; + + # Optional: timer to rebuild daily + systemd.timers.rustdoc-build = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "daily"; + Persistent = true; + }; + }; + + # Optional: webhook receiver for rebuild-on-push + # Uncomment if you want webhook triggers + # services.webhook = { + # enable = true; + # hooks = { + # rebuild-docs = { + # execute-command = "/etc/rustdoc-build.sh"; + # command-working-directory = "/tmp"; + # }; + # }; + # }; +} -- 2.51.2