A better Rust ATProto crate
1//! Stateless XRPC utilities and request/response mapping 2//! 3//! Mapping overview: 4//! - Success (2xx): parse body into the endpoint's typed output. 5//! - 400: try typed error; on failure, fall back to a generic XRPC error (with 6//! `nsid`, `method`, and `http_status`) and map common auth errors. 7//! - 401: if `WWW-Authenticate` is present, return 8//! `ClientError::Auth(AuthError::Other(header))` so higher layers (OAuth/DPoP) 9//! can inspect `error="invalid_token"` or `error="use_dpop_nonce"` and refresh/retry. 10//! If the header is absent, parse the body and map auth errors to 11//! `AuthError::TokenExpired`/`InvalidToken`. 12//! 13use bytes::Bytes; 14use http::{ 15 HeaderName, HeaderValue, Request, StatusCode, 16 header::{AUTHORIZATION, CONTENT_TYPE}, 17}; 18use serde::{Deserialize, Serialize}; 19use smol_str::SmolStr; 20use std::fmt::{self, Debug}; 21use std::{error::Error, marker::PhantomData}; 22use url::Url; 23 24use crate::error::TransportError; 25use crate::http_client::HttpClient; 26use crate::types::value::Data; 27use crate::{AuthorizationToken, error::AuthError}; 28use crate::{CowStr, error::XrpcResult}; 29use crate::{IntoStatic, error::DecodeError}; 30 31/// Error type for encoding XRPC requests 32#[derive(Debug, thiserror::Error, miette::Diagnostic)] 33pub enum EncodeError { 34 /// Failed to serialize query parameters 35 #[error("Failed to serialize query: {0}")] 36 Query( 37 #[from] 38 #[source] 39 serde_html_form::ser::Error, 40 ), 41 /// Failed to serialize JSON body 42 #[error("Failed to serialize JSON: {0}")] 43 Json( 44 #[from] 45 #[source] 46 serde_json::Error, 47 ), 48 /// Other encoding error 49 #[error("Encoding error: {0}")] 50 Other(String), 51} 52 53/// XRPC method type 54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 55pub enum XrpcMethod { 56 /// Query (HTTP GET) 57 Query, 58 /// Procedure (HTTP POST) 59 Procedure(&'static str), 60} 61 62impl XrpcMethod { 63 /// Get the HTTP method string 64 pub const fn as_str(&self) -> &'static str { 65 match self { 66 Self::Query => "GET", 67 Self::Procedure(_) => "POST", 68 } 69 } 70 71 /// Get the body encoding type for this method (procedures only) 72 pub const fn body_encoding(&self) -> Option<&'static str> { 73 match self { 74 Self::Query => None, 75 Self::Procedure(enc) => Some(enc), 76 } 77 } 78} 79 80/// Trait for XRPC request types (queries and procedures) 81/// 82/// This trait provides metadata about XRPC endpoints including the NSID, 83/// HTTP method, encoding, and associated output type. 84/// 85/// The trait is implemented on the request parameters/input type itself. 86pub trait XrpcRequest<'de>: Serialize + Deserialize<'de> { 87 /// The NSID for this XRPC method 88 const NSID: &'static str; 89 90 /// XRPC method (query/GET or procedure/POST) 91 const METHOD: XrpcMethod; 92 93 /// Response type returned from the XRPC call (marker struct) 94 type Response: XrpcResp; 95 96 /// Encode the request body for procedures. 97 /// 98 /// Default implementation serializes to JSON. Override for non-JSON encodings. 99 fn encode_body(&self) -> Result<Vec<u8>, EncodeError> { 100 Ok(serde_json::to_vec(self)?) 101 } 102 103 /// Decode the request body for procedures. 104 /// 105 /// Default implementation deserializes from JSON. Override for non-JSON encodings. 106 fn decode_body(body: &'de [u8]) -> Result<Box<Self>, DecodeError> { 107 let body: Self = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?; 108 109 Ok(Box::new(body)) 110 } 111} 112 113/// Trait for XRPC Response types 114/// 115/// It mirrors the NSID and carries the encoding types as well as Output (success) and Err types 116pub trait XrpcResp { 117 /// The NSID for this XRPC method 118 const NSID: &'static str; 119 120 /// Output encoding (MIME type) 121 const ENCODING: &'static str; 122 123 /// Response output type 124 type Output<'de>: Deserialize<'de> + IntoStatic; 125 126 /// Error type for this request 127 type Err<'de>: Error + Deserialize<'de> + IntoStatic; 128} 129 130/// Error type for XRPC endpoints that don't define any errors 131#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 132pub struct GenericError<'a>(#[serde(borrow)] Data<'a>); 133 134impl<'de> fmt::Display for GenericError<'de> { 135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 136 self.0.fmt(f) 137 } 138} 139 140impl Error for GenericError<'_> {} 141 142impl IntoStatic for GenericError<'_> { 143 type Output = GenericError<'static>; 144 fn into_static(self) -> Self::Output { 145 GenericError(self.0.into_static()) 146 } 147} 148 149/// Per-request options for XRPC calls. 150#[derive(Debug, Default, Clone)] 151pub struct CallOptions<'a> { 152 /// Optional Authorization to apply (`Bearer` or `DPoP`). 153 pub auth: Option<AuthorizationToken<'a>>, 154 /// `atproto-proxy` header value. 155 pub atproto_proxy: Option<CowStr<'a>>, 156 /// `atproto-accept-labelers` header values. 157 pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>, 158 /// Extra headers to attach to this request. 159 pub extra_headers: Vec<(HeaderName, HeaderValue)>, 160} 161 162impl IntoStatic for CallOptions<'_> { 163 type Output = CallOptions<'static>; 164 165 fn into_static(self) -> Self::Output { 166 CallOptions { 167 auth: self.auth.map(|auth| auth.into_static()), 168 atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()), 169 atproto_accept_labelers: self 170 .atproto_accept_labelers 171 .map(|labelers| labelers.into_static()), 172 extra_headers: self.extra_headers, 173 } 174 } 175} 176 177/// Extension for stateless XRPC calls on any `HttpClient`. 178/// 179/// Example 180/// ```ignore 181/// use jacquard::client::XrpcExt; 182/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 183/// use jacquard::types::ident::AtIdentifier; 184/// use miette::IntoDiagnostic; 185/// 186/// #[tokio::main] 187/// async fn main() -> miette::Result<()> { 188/// let http = reqwest::Client::new(); 189/// let base = url::Url::parse("https://public.api.bsky.app")?; 190/// let resp = http 191/// .xrpc(base) 192/// .send( 193/// GetAuthorFeed::new() 194/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 195/// .limit(5) 196/// .build(), 197/// ) 198/// .await?; 199/// let out = resp.into_output()?; 200/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 201/// Ok(()) 202/// } 203/// ``` 204pub trait XrpcExt: HttpClient { 205 /// Start building an XRPC call for the given base URL. 206 fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self> 207 where 208 Self: Sized, 209 { 210 XrpcCall { 211 client: self, 212 base, 213 opts: CallOptions::default(), 214 } 215 } 216} 217 218impl<T: HttpClient> XrpcExt for T {} 219 220/// Stateful XRPC call trait 221pub trait XrpcClient: HttpClient { 222 /// Get the base URI for the client. 223 fn base_uri(&self) -> Url; 224 225 /// Get the call options for the client. 226 fn opts(&self) -> impl Future<Output = CallOptions<'_>> { 227 async { CallOptions::default() } 228 } 229 /// Send an XRPC request and parse the response 230 fn send<'s, R>( 231 &self, 232 request: R, 233 ) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest<'s>>::Response>>> 234 where 235 R: XrpcRequest<'s>; 236} 237 238/// Stateless XRPC call builder. 239/// 240/// Example (per-request overrides) 241/// ```ignore 242/// use jacquard::client::{XrpcExt, AuthorizationToken}; 243/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 244/// use jacquard::types::ident::AtIdentifier; 245/// use jacquard::CowStr; 246/// use miette::IntoDiagnostic; 247/// 248/// #[tokio::main] 249/// async fn main() -> miette::Result<()> { 250/// let http = reqwest::Client::new(); 251/// let base = url::Url::parse("https://public.api.bsky.app")?; 252/// let resp = http 253/// .xrpc(base) 254/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) 255/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) 256/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) 257/// .send( 258/// GetAuthorFeed::new() 259/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 260/// .limit(5) 261/// .build(), 262/// ) 263/// .await?; 264/// let out = resp.into_output()?; 265/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 266/// Ok(()) 267/// } 268/// ``` 269pub struct XrpcCall<'a, C: HttpClient> { 270 pub(crate) client: &'a C, 271 pub(crate) base: Url, 272 pub(crate) opts: CallOptions<'a>, 273} 274 275impl<'a, C: HttpClient> XrpcCall<'a, C> { 276 /// Apply Authorization to this call. 277 pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self { 278 self.opts.auth = Some(token); 279 self 280 } 281 /// Set `atproto-proxy` header for this call. 282 pub fn proxy(mut self, proxy: CowStr<'a>) -> Self { 283 self.opts.atproto_proxy = Some(proxy); 284 self 285 } 286 /// Set `atproto-accept-labelers` header(s) for this call. 287 pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self { 288 self.opts.atproto_accept_labelers = Some(labelers); 289 self 290 } 291 /// Add an extra header. 292 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self { 293 self.opts.extra_headers.push((name, value)); 294 self 295 } 296 /// Replace the builder's options entirely. 297 pub fn with_options(mut self, opts: CallOptions<'a>) -> Self { 298 self.opts = opts; 299 self 300 } 301 302 /// Send the given typed XRPC request and return a response wrapper. 303 /// 304 /// Note on 401 handling: 305 /// - When the server returns 401 with a `WWW-Authenticate` header, this surfaces as 306 /// `ClientError::Auth(AuthError::Other(header))` so higher layers (e.g., OAuth/DPoP) can 307 /// inspect the header for `error="invalid_token"` or `error="use_dpop_nonce"` and react 308 /// (refresh/retry). If the header is absent, the 401 body flows through to `Response` and 309 /// can be parsed/mapped to `AuthError` as appropriate. 310 pub async fn send<'s, R>( 311 self, 312 request: &R, 313 ) -> XrpcResult<Response<<R as XrpcRequest<'s>>::Response>> 314 where 315 R: XrpcRequest<'s>, 316 { 317 let http_request = build_http_request(&self.base, request, &self.opts) 318 .map_err(crate::error::TransportError::from)?; 319 320 let http_response = self 321 .client 322 .send_http(http_request) 323 .await 324 .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; 325 326 let status = http_response.status(); 327 // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers 328 // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. 329 if status.as_u16() == 401 { 330 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 331 return Err(crate::error::ClientError::Auth( 332 crate::error::AuthError::Other(hv.clone()), 333 )); 334 } 335 } 336 let buffer = Bytes::from(http_response.into_body()); 337 338 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 339 return Err(crate::error::HttpError { 340 status, 341 body: Some(buffer), 342 } 343 .into()); 344 } 345 346 Ok(Response::new(buffer, status)) 347 } 348} 349 350/// Process the HTTP response from the server into a proper xrpc response statelessly. 351/// 352/// Exposed to make things more easily pluggable 353#[inline] 354pub fn process_response<Resp>(http_response: http::Response<Vec<u8>>) -> XrpcResult<Response<Resp>> 355where 356 Resp: XrpcResp, 357{ 358 let status = http_response.status(); 359 // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers 360 // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. 361 if status.as_u16() == 401 { 362 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 363 return Err(crate::error::ClientError::Auth( 364 crate::error::AuthError::Other(hv.clone()), 365 )); 366 } 367 } 368 let buffer = Bytes::from(http_response.into_body()); 369 370 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 371 return Err(crate::error::HttpError { 372 status, 373 body: Some(buffer), 374 } 375 .into()); 376 } 377 378 Ok(Response::new(buffer, status)) 379} 380 381/// HTTP headers commonly used in XRPC requests 382pub enum Header { 383 /// Content-Type header 384 ContentType, 385 /// Authorization header 386 Authorization, 387 /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. 388 /// 389 /// See: <https://atproto.com/specs/xrpc#service-proxying> 390 AtprotoProxy, 391 /// `atproto-accept-labelers` header used by clients to request labels from specific labelers to be included and applied in the response. See [label](https://atproto.com/specs/label) specification for details. 392 AtprotoAcceptLabelers, 393} 394 395impl From<Header> for HeaderName { 396 fn from(value: Header) -> Self { 397 match value { 398 Header::ContentType => CONTENT_TYPE, 399 Header::Authorization => AUTHORIZATION, 400 Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), 401 Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), 402 } 403 } 404} 405 406/// Build an HTTP request for an XRPC call given base URL and options 407pub fn build_http_request<'s, R>( 408 base: &Url, 409 req: &R, 410 opts: &CallOptions<'_>, 411) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> 412where 413 R: XrpcRequest<'s>, 414{ 415 let mut url = base.clone(); 416 let mut path = url.path().trim_end_matches('/').to_owned(); 417 path.push_str("/xrpc/"); 418 path.push_str(<R as XrpcRequest<'s>>::NSID); 419 url.set_path(&path); 420 421 if let XrpcMethod::Query = <R as XrpcRequest<'s>>::METHOD { 422 let qs = serde_html_form::to_string(&req) 423 .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?; 424 if !qs.is_empty() { 425 url.set_query(Some(&qs)); 426 } else { 427 url.set_query(None); 428 } 429 } 430 431 let method = match <R as XrpcRequest<'_>>::METHOD { 432 XrpcMethod::Query => http::Method::GET, 433 XrpcMethod::Procedure(_) => http::Method::POST, 434 }; 435 436 let mut builder = Request::builder().method(method).uri(url.as_str()); 437 438 if let XrpcMethod::Procedure(encoding) = <R as XrpcRequest<'_>>::METHOD { 439 builder = builder.header(Header::ContentType, encoding); 440 } 441 let output_encoding = <R::Response as XrpcResp>::ENCODING; 442 builder = builder.header(http::header::ACCEPT, output_encoding); 443 444 if let Some(token) = &opts.auth { 445 let hv = match token { 446 AuthorizationToken::Bearer(t) => { 447 HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) 448 } 449 AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), 450 } 451 .map_err(|e| { 452 TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) 453 })?; 454 builder = builder.header(Header::Authorization, hv); 455 } 456 457 if let Some(proxy) = &opts.atproto_proxy { 458 builder = builder.header(Header::AtprotoProxy, proxy.as_ref()); 459 } 460 if let Some(labelers) = &opts.atproto_accept_labelers { 461 if !labelers.is_empty() { 462 let joined = labelers 463 .iter() 464 .map(|s| s.as_ref()) 465 .collect::<Vec<_>>() 466 .join(", "); 467 builder = builder.header(Header::AtprotoAcceptLabelers, joined); 468 } 469 } 470 for (name, value) in &opts.extra_headers { 471 builder = builder.header(name, value); 472 } 473 474 let body = if let XrpcMethod::Procedure(_) = R::METHOD { 475 req.encode_body() 476 .map_err(|e| TransportError::InvalidRequest(e.to_string()))? 477 } else { 478 vec![] 479 }; 480 481 builder 482 .body(body) 483 .map_err(|e| TransportError::InvalidRequest(e.to_string())) 484} 485 486pub trait XrpcEndpoint { 487 const PATH: &'static str; 488 const METHOD: XrpcMethod; 489 type Request<'de>: XrpcRequest<'de> + IntoStatic; 490 type Response: XrpcResp; 491} 492 493/// XRPC response wrapper that owns the response buffer 494/// 495/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. 496/// Generic over the response marker type (e.g., `GetAuthorFeedResponse`), not the request. 497pub struct Response<Resp> 498where 499 Resp: XrpcResp, // HRTB: Resp works with any lifetime 500{ 501 _marker: PhantomData<fn() -> Resp>, 502 buffer: Bytes, 503 status: StatusCode, 504} 505 506impl<Resp> Response<Resp> 507where 508 Resp: XrpcResp, 509{ 510 /// Create a new response from a buffer and status code 511 pub fn new(buffer: Bytes, status: StatusCode) -> Self { 512 Self { 513 buffer, 514 status, 515 _marker: PhantomData, 516 } 517 } 518 519 /// Get the HTTP status code 520 pub fn status(&self) -> StatusCode { 521 self.status 522 } 523 524 /// Get the raw buffer 525 pub fn buffer(&self) -> &Bytes { 526 &self.buffer 527 } 528 529 /// Parse the response, borrowing from the internal buffer 530 pub fn parse<'s>( 531 &'s self, 532 ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 533 // 200: parse as output 534 if self.status.is_success() { 535 match serde_json::from_slice::<_>(&self.buffer) { 536 Ok(output) => Ok(output), 537 Err(e) => Err(XrpcError::Decode(e)), 538 } 539 // 400: try typed XRPC error, fallback to generic error 540 } else if self.status.as_u16() == 400 { 541 match serde_json::from_slice::<_>(&self.buffer) { 542 Ok(error) => Err(XrpcError::Xrpc(error)), 543 Err(_) => { 544 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 545 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 546 Ok(mut generic) => { 547 generic.nsid = Resp::NSID; 548 generic.method = ""; // method info only available on request 549 generic.http_status = self.status; 550 // Map auth-related errors to AuthError 551 match generic.error.as_str() { 552 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 553 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 554 _ => Err(XrpcError::Generic(generic)), 555 } 556 } 557 Err(e) => Err(XrpcError::Decode(e)), 558 } 559 } 560 } 561 // 401: always auth error 562 } else { 563 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 564 Ok(mut generic) => { 565 generic.nsid = Resp::NSID; 566 generic.method = ""; // method info only available on request 567 generic.http_status = self.status; 568 match generic.error.as_str() { 569 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 570 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 571 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 572 } 573 } 574 Err(e) => Err(XrpcError::Decode(e)), 575 } 576 } 577 } 578} 579 580impl<Resp> Response<Resp> 581where 582 Resp: XrpcResp, 583{ 584 /// Parse the response into an owned output 585 pub fn into_output( 586 self, 587 ) -> Result<<Resp as XrpcResp>::Output<'static>, XrpcError<<Resp as XrpcResp>::Err<'static>>> 588 where 589 for<'a> <Resp as XrpcResp>::Output<'a>: 590 IntoStatic<Output = <Resp as XrpcResp>::Output<'static>>, 591 for<'a> <Resp as XrpcResp>::Err<'a>: IntoStatic<Output = <Resp as XrpcResp>::Err<'static>>, 592 { 593 // Use a helper to make lifetime inference work 594 fn parse_output<'b, R: XrpcResp>( 595 buffer: &'b [u8], 596 ) -> Result<R::Output<'b>, serde_json::Error> { 597 serde_json::from_slice(buffer) 598 } 599 600 fn parse_error<'b, R: XrpcResp>(buffer: &'b [u8]) -> Result<R::Err<'b>, serde_json::Error> { 601 serde_json::from_slice(buffer) 602 } 603 604 // 200: parse as output 605 if self.status.is_success() { 606 match parse_output::<Resp>(&self.buffer) { 607 Ok(output) => { 608 return Ok(output.into_static()); 609 } 610 Err(e) => Err(XrpcError::Decode(e)), 611 } 612 // 400: try typed XRPC error, fallback to generic error 613 } else if self.status.as_u16() == 400 { 614 let error = match parse_error::<Resp>(&self.buffer) { 615 Ok(error) => XrpcError::Xrpc(error), 616 Err(_) => { 617 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 618 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 619 Ok(mut generic) => { 620 generic.nsid = Resp::NSID; 621 generic.method = ""; // method info only available on request 622 generic.http_status = self.status; 623 // Map auth-related errors to AuthError 624 match generic.error.as_ref() { 625 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired), 626 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken), 627 _ => XrpcError::Generic(generic), 628 } 629 } 630 Err(e) => XrpcError::Decode(e), 631 } 632 } 633 }; 634 Err(error.into_static()) 635 // 401: always auth error 636 } else { 637 let error: XrpcError<<Resp as XrpcResp>::Err<'_>> = 638 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 639 Ok(mut generic) => { 640 let status = self.status; 641 generic.nsid = Resp::NSID; 642 generic.method = ""; // method info only available on request 643 generic.http_status = status; 644 match generic.error.as_ref() { 645 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired), 646 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken), 647 _ => XrpcError::Auth(AuthError::NotAuthenticated), 648 } 649 } 650 Err(e) => XrpcError::Decode(e), 651 }; 652 653 Err(error.into_static()) 654 } 655 } 656} 657 658/// Generic XRPC error format for untyped errors like InvalidRequest 659/// 660/// Used when the error doesn't match the endpoint's specific error enum 661#[derive(Debug, Clone, Deserialize, Serialize)] 662pub struct GenericXrpcError { 663 /// Error code (e.g., "InvalidRequest") 664 pub error: SmolStr, 665 /// Optional error message with details 666 pub message: Option<SmolStr>, 667 /// XRPC method NSID that produced this error (context only; not serialized) 668 #[serde(skip)] 669 pub nsid: &'static str, 670 /// HTTP method used (GET/POST) (context only; not serialized) 671 #[serde(skip)] 672 pub method: &'static str, 673 /// HTTP status code (context only; not serialized) 674 #[serde(skip)] 675 pub http_status: StatusCode, 676} 677 678impl std::fmt::Display for GenericXrpcError { 679 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 680 if let Some(msg) = &self.message { 681 write!( 682 f, 683 "{}: {} (nsid={}, method={}, status={})", 684 self.error, msg, self.nsid, self.method, self.http_status 685 ) 686 } else { 687 write!( 688 f, 689 "{} (nsid={}, method={}, status={})", 690 self.error, self.nsid, self.method, self.http_status 691 ) 692 } 693 } 694} 695 696impl IntoStatic for GenericXrpcError { 697 type Output = Self; 698 699 fn into_static(self) -> Self::Output { 700 self 701 } 702} 703 704impl std::error::Error for GenericXrpcError {} 705 706/// XRPC-specific errors returned from endpoints 707/// 708/// Represents errors returned in the response body 709/// Type parameter `E` is the endpoint's specific error enum type. 710#[derive(Debug, thiserror::Error, miette::Diagnostic)] 711pub enum XrpcError<E: std::error::Error + IntoStatic> { 712 /// Typed XRPC error from the endpoint's specific error enum 713 #[error("XRPC error: {0}")] 714 #[diagnostic(code(jacquard_common::xrpc::typed))] 715 Xrpc(E), 716 717 /// Authentication error (ExpiredToken, InvalidToken, etc.) 718 #[error("Authentication error: {0}")] 719 #[diagnostic(code(jacquard_common::xrpc::auth))] 720 Auth(#[from] AuthError), 721 722 /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) 723 #[error("XRPC error: {0}")] 724 #[diagnostic(code(jacquard_common::xrpc::generic))] 725 Generic(GenericXrpcError), 726 727 /// Failed to decode the response body 728 #[error("Failed to decode response: {0}")] 729 #[diagnostic(code(jacquard_common::xrpc::decode))] 730 Decode(#[from] serde_json::Error), 731} 732 733impl<E> IntoStatic for XrpcError<E> 734where 735 E: std::error::Error + IntoStatic, 736 E::Output: std::error::Error + IntoStatic, 737 <E as IntoStatic>::Output: std::error::Error + IntoStatic, 738{ 739 type Output = XrpcError<E::Output>; 740 fn into_static(self) -> Self::Output { 741 match self { 742 XrpcError::Xrpc(e) => XrpcError::Xrpc(e.into_static()), 743 XrpcError::Auth(e) => XrpcError::Auth(e.into_static()), 744 XrpcError::Generic(e) => XrpcError::Generic(e), 745 XrpcError::Decode(e) => XrpcError::Decode(e), 746 } 747 } 748} 749 750#[cfg(test)] 751mod tests { 752 use super::*; 753 use serde::{Deserialize, Serialize}; 754 755 #[derive(Serialize, Deserialize)] 756 #[allow(dead_code)] 757 struct DummyReq; 758 759 #[derive(Deserialize, Debug, thiserror::Error)] 760 #[error("{0}")] 761 struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>); 762 763 impl IntoStatic for DummyErr<'_> { 764 type Output = DummyErr<'static>; 765 fn into_static(self) -> Self::Output { 766 DummyErr(self.0.into_static()) 767 } 768 } 769 770 struct DummyResp; 771 772 impl XrpcResp for DummyResp { 773 const NSID: &'static str = "test.dummy"; 774 const ENCODING: &'static str = "application/json"; 775 type Output<'de> = (); 776 type Err<'de> = DummyErr<'de>; 777 } 778 779 impl<'de> XrpcRequest<'de> for DummyReq { 780 const NSID: &'static str = "test.dummy"; 781 const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json"); 782 type Response = DummyResp; 783 } 784 785 #[test] 786 fn generic_error_carries_context() { 787 let body = serde_json::json!({"error":"InvalidRequest","message":"missing"}); 788 let buf = Bytes::from(serde_json::to_vec(&body).unwrap()); 789 let resp: Response<DummyResp> = Response::new(buf, StatusCode::BAD_REQUEST); 790 match resp.parse().unwrap_err() { 791 XrpcError::Generic(g) => { 792 assert_eq!(g.error.as_str(), "InvalidRequest"); 793 assert_eq!(g.message.as_deref(), Some("missing")); 794 assert_eq!(g.nsid, DummyResp::NSID); 795 assert_eq!(g.method, ""); // method info only on request 796 assert_eq!(g.http_status, StatusCode::BAD_REQUEST); 797 } 798 other => panic!("unexpected: {other:?}"), 799 } 800 } 801 802 #[test] 803 fn auth_error_mapping() { 804 for (code, expect) in [ 805 ("ExpiredToken", AuthError::TokenExpired), 806 ("InvalidToken", AuthError::InvalidToken), 807 ] { 808 let body = serde_json::json!({"error": code}); 809 let buf = Bytes::from(serde_json::to_vec(&body).unwrap()); 810 let resp: Response<DummyResp> = Response::new(buf, StatusCode::UNAUTHORIZED); 811 match resp.parse().unwrap_err() { 812 XrpcError::Auth(e) => match (e, expect) { 813 (AuthError::TokenExpired, AuthError::TokenExpired) => {} 814 (AuthError::InvalidToken, AuthError::InvalidToken) => {} 815 other => panic!("mismatch: {other:?}"), 816 }, 817 other => panic!("unexpected: {other:?}"), 818 } 819 } 820 } 821 822 #[test] 823 fn no_double_slash_in_path() { 824 #[derive(Serialize, Deserialize)] 825 struct Req; 826 #[derive(Deserialize, Debug, thiserror::Error)] 827 #[error("{0}")] 828 struct Err<'a>(#[serde(borrow)] CowStr<'a>); 829 impl IntoStatic for Err<'_> { 830 type Output = Err<'static>; 831 fn into_static(self) -> Self::Output { 832 Err(self.0.into_static()) 833 } 834 } 835 struct Resp; 836 impl XrpcResp for Resp { 837 const NSID: &'static str = "com.example.test"; 838 const ENCODING: &'static str = "application/json"; 839 type Output<'de> = (); 840 type Err<'de> = Err<'de>; 841 } 842 impl<'de> XrpcRequest<'de> for Req { 843 const NSID: &'static str = "com.example.test"; 844 const METHOD: XrpcMethod = XrpcMethod::Query; 845 type Response = Resp; 846 } 847 848 let opts = CallOptions::default(); 849 for base in [ 850 Url::parse("https://pds").unwrap(), 851 Url::parse("https://pds/").unwrap(), 852 Url::parse("https://pds/base/").unwrap(), 853 ] { 854 let req = build_http_request(&base, &Req, &opts).unwrap(); 855 let uri = req.uri().to_string(); 856 assert!(uri.contains("/xrpc/com.example.test")); 857 assert!(!uri.contains("//xrpc")); 858 } 859 } 860}