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