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