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 pub async fn send<R>(self, request: &R) -> XrpcResult<Response<<R as XrpcRequest>::Response>> 309 where 310 R: XrpcRequest + Send + Sync, 311 <R as XrpcRequest>::Response: Send + Sync, 312 { 313 let http_request = build_http_request(&self.base, request, &self.opts) 314 .map_err(crate::error::TransportError::from)?; 315 316 let http_response = self 317 .client 318 .send_http(http_request) 319 .await 320 .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; 321 322 process_response(http_response) 323 } 324} 325 326/// Process the HTTP response from the server into a proper xrpc response statelessly. 327/// 328/// Exposed to make things more easily pluggable 329#[inline] 330pub fn process_response<Resp>(http_response: http::Response<Vec<u8>>) -> XrpcResult<Response<Resp>> 331where 332 Resp: XrpcResp, 333{ 334 let status = http_response.status(); 335 // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers 336 // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. 337 if status.as_u16() == 401 { 338 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 339 return Err(crate::error::ClientError::Auth( 340 crate::error::AuthError::Other(hv.clone()), 341 )); 342 } 343 } 344 let buffer = Bytes::from(http_response.into_body()); 345 346 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 347 return Err(crate::error::HttpError { 348 status, 349 body: Some(buffer), 350 } 351 .into()); 352 } 353 354 Ok(Response::new(buffer, status)) 355} 356 357/// HTTP headers commonly used in XRPC requests 358pub enum Header { 359 /// Content-Type header 360 ContentType, 361 /// Authorization header 362 Authorization, 363 /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. 364 /// 365 /// See: <https://atproto.com/specs/xrpc#service-proxying> 366 AtprotoProxy, 367 /// `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. 368 AtprotoAcceptLabelers, 369} 370 371impl From<Header> for HeaderName { 372 fn from(value: Header) -> Self { 373 match value { 374 Header::ContentType => CONTENT_TYPE, 375 Header::Authorization => AUTHORIZATION, 376 Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), 377 Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), 378 } 379 } 380} 381 382/// Build an HTTP request for an XRPC call given base URL and options 383pub fn build_http_request<'s, R>( 384 base: &Url, 385 req: &R, 386 opts: &CallOptions<'_>, 387) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> 388where 389 R: XrpcRequest, 390{ 391 let mut url = base.clone(); 392 let mut path = url.path().trim_end_matches('/').to_owned(); 393 path.push_str("/xrpc/"); 394 path.push_str(<R as XrpcRequest>::NSID); 395 url.set_path(&path); 396 397 if let XrpcMethod::Query = <R as XrpcRequest>::METHOD { 398 let qs = serde_html_form::to_string(&req) 399 .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?; 400 if !qs.is_empty() { 401 url.set_query(Some(&qs)); 402 } else { 403 url.set_query(None); 404 } 405 } 406 407 let method = match <R as XrpcRequest>::METHOD { 408 XrpcMethod::Query => http::Method::GET, 409 XrpcMethod::Procedure(_) => http::Method::POST, 410 }; 411 412 let mut builder = Request::builder().method(method).uri(url.as_str()); 413 414 if let XrpcMethod::Procedure(encoding) = <R as XrpcRequest>::METHOD { 415 builder = builder.header(Header::ContentType, encoding); 416 } 417 let output_encoding = <R::Response as XrpcResp>::ENCODING; 418 builder = builder.header(http::header::ACCEPT, output_encoding); 419 420 if let Some(token) = &opts.auth { 421 let hv = match token { 422 AuthorizationToken::Bearer(t) => { 423 HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) 424 } 425 AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), 426 } 427 .map_err(|e| { 428 TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) 429 })?; 430 builder = builder.header(Header::Authorization, hv); 431 } 432 433 if let Some(proxy) = &opts.atproto_proxy { 434 builder = builder.header(Header::AtprotoProxy, proxy.as_ref()); 435 } 436 if let Some(labelers) = &opts.atproto_accept_labelers { 437 if !labelers.is_empty() { 438 let joined = labelers 439 .iter() 440 .map(|s| s.as_ref()) 441 .collect::<Vec<_>>() 442 .join(", "); 443 builder = builder.header(Header::AtprotoAcceptLabelers, joined); 444 } 445 } 446 for (name, value) in &opts.extra_headers { 447 builder = builder.header(name, value); 448 } 449 450 let body = if let XrpcMethod::Procedure(_) = R::METHOD { 451 req.encode_body() 452 .map_err(|e| TransportError::InvalidRequest(e.to_string()))? 453 } else { 454 vec![] 455 }; 456 457 builder 458 .body(body) 459 .map_err(|e| TransportError::InvalidRequest(e.to_string())) 460} 461 462/// XRPC response wrapper that owns the response buffer 463/// 464/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. 465/// Generic over the response marker type (e.g., `GetAuthorFeedResponse`), not the request. 466pub struct Response<Resp> 467where 468 Resp: XrpcResp, // HRTB: Resp works with any lifetime 469{ 470 _marker: PhantomData<fn() -> Resp>, 471 buffer: Bytes, 472 status: StatusCode, 473} 474 475impl<Resp> Response<Resp> 476where 477 Resp: XrpcResp, 478{ 479 /// Create a new response from a buffer and status code 480 pub fn new(buffer: Bytes, status: StatusCode) -> Self { 481 Self { 482 buffer, 483 status, 484 _marker: PhantomData, 485 } 486 } 487 488 /// Get the HTTP status code 489 pub fn status(&self) -> StatusCode { 490 self.status 491 } 492 493 /// Get the raw buffer 494 pub fn buffer(&self) -> &Bytes { 495 &self.buffer 496 } 497 498 /// Parse the response, borrowing from the internal buffer 499 pub fn parse<'s>( 500 &'s self, 501 ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 502 // 200: parse as output 503 if self.status.is_success() { 504 match serde_json::from_slice::<_>(&self.buffer) { 505 Ok(output) => Ok(output), 506 Err(e) => Err(XrpcError::Decode(e)), 507 } 508 // 400: try typed XRPC error, fallback to generic error 509 } else if self.status.as_u16() == 400 { 510 match serde_json::from_slice::<_>(&self.buffer) { 511 Ok(error) => Err(XrpcError::Xrpc(error)), 512 Err(_) => { 513 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 514 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 515 Ok(mut generic) => { 516 generic.nsid = Resp::NSID; 517 generic.method = ""; // method info only available on request 518 generic.http_status = self.status; 519 // Map auth-related errors to AuthError 520 match generic.error.as_str() { 521 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 522 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 523 _ => Err(XrpcError::Generic(generic)), 524 } 525 } 526 Err(e) => Err(XrpcError::Decode(e)), 527 } 528 } 529 } 530 // 401: always auth error 531 } else { 532 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 533 Ok(mut generic) => { 534 generic.nsid = Resp::NSID; 535 generic.method = ""; // method info only available on request 536 generic.http_status = self.status; 537 match generic.error.as_str() { 538 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 539 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 540 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 541 } 542 } 543 Err(e) => Err(XrpcError::Decode(e)), 544 } 545 } 546 } 547 548 /// Parse this as validated, loosely typed atproto data. 549 /// 550 /// NOTE: If the response is an error, it will still parse as the matching error type for the request. 551 pub fn parse_data<'s>(&'s self) -> Result<Data<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 552 // 200: parse as output 553 if self.status.is_success() { 554 match serde_json::from_slice::<_>(&self.buffer) { 555 Ok(output) => Ok(output), 556 Err(e) => Err(XrpcError::Decode(e)), 557 } 558 // 400: try typed XRPC error, fallback to generic error 559 } else if self.status.as_u16() == 400 { 560 match serde_json::from_slice::<_>(&self.buffer) { 561 Ok(error) => Err(XrpcError::Xrpc(error)), 562 Err(_) => { 563 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 564 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 565 Ok(mut generic) => { 566 generic.nsid = Resp::NSID; 567 generic.method = ""; // method info only available on request 568 generic.http_status = self.status; 569 // Map auth-related errors to AuthError 570 match generic.error.as_str() { 571 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 572 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 573 _ => Err(XrpcError::Generic(generic)), 574 } 575 } 576 Err(e) => Err(XrpcError::Decode(e)), 577 } 578 } 579 } 580 // 401: always auth error 581 } else { 582 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 583 Ok(mut generic) => { 584 generic.nsid = Resp::NSID; 585 generic.method = ""; // method info only available on request 586 generic.http_status = self.status; 587 match generic.error.as_str() { 588 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 589 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 590 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 591 } 592 } 593 Err(e) => Err(XrpcError::Decode(e)), 594 } 595 } 596 } 597 598 /// Parse this as raw atproto data with minimal validation. 599 /// 600 /// NOTE: If the response is an error, it will still parse as the matching error type for the request. 601 pub fn parse_raw<'s>(&'s self) -> Result<RawData<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 602 // 200: parse as output 603 if self.status.is_success() { 604 match serde_json::from_slice::<_>(&self.buffer) { 605 Ok(output) => Ok(output), 606 Err(e) => Err(XrpcError::Decode(e)), 607 } 608 // 400: try typed XRPC error, fallback to generic error 609 } else if self.status.as_u16() == 400 { 610 match serde_json::from_slice::<_>(&self.buffer) { 611 Ok(error) => Err(XrpcError::Xrpc(error)), 612 Err(_) => { 613 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 614 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 615 Ok(mut generic) => { 616 generic.nsid = Resp::NSID; 617 generic.method = ""; // method info only available on request 618 generic.http_status = self.status; 619 // Map auth-related errors to AuthError 620 match generic.error.as_str() { 621 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 622 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 623 _ => Err(XrpcError::Generic(generic)), 624 } 625 } 626 Err(e) => Err(XrpcError::Decode(e)), 627 } 628 } 629 } 630 // 401: always auth error 631 } else { 632 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 633 Ok(mut generic) => { 634 generic.nsid = Resp::NSID; 635 generic.method = ""; // method info only available on request 636 generic.http_status = self.status; 637 match generic.error.as_str() { 638 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 639 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 640 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 641 } 642 } 643 Err(e) => Err(XrpcError::Decode(e)), 644 } 645 } 646 } 647 648 /// Reinterpret this response as a different response type. 649 /// 650 /// This transmutes the response by keeping the same buffer and status code, 651 /// but changing the type-level marker. Useful for converting generic XRPC responses 652 /// into collection-specific typed responses. 653 /// 654 /// # Safety 655 /// 656 /// This is safe in the sense that no memory unsafety occurs, but logical correctness 657 /// depends on ensuring the buffer actually contains data that can deserialize to `NEW`. 658 /// Incorrect conversion will cause deserialization errors at runtime. 659 pub fn transmute<NEW: XrpcResp>(self) -> Response<NEW> { 660 Response { 661 buffer: self.buffer, 662 status: self.status, 663 _marker: PhantomData, 664 } 665 } 666} 667 668impl<Resp> Response<Resp> 669where 670 Resp: XrpcResp, 671{ 672 /// Parse the response into an owned output 673 pub fn into_output( 674 self, 675 ) -> Result<<Resp as XrpcResp>::Output<'static>, XrpcError<<Resp as XrpcResp>::Err<'static>>> 676 where 677 for<'a> <Resp as XrpcResp>::Output<'a>: 678 IntoStatic<Output = <Resp as XrpcResp>::Output<'static>>, 679 for<'a> <Resp as XrpcResp>::Err<'a>: IntoStatic<Output = <Resp as XrpcResp>::Err<'static>>, 680 { 681 // Use a helper to make lifetime inference work 682 fn parse_output<'b, R: XrpcResp>( 683 buffer: &'b [u8], 684 ) -> Result<R::Output<'b>, serde_json::Error> { 685 serde_json::from_slice(buffer) 686 } 687 688 fn parse_error<'b, R: XrpcResp>(buffer: &'b [u8]) -> Result<R::Err<'b>, serde_json::Error> { 689 serde_json::from_slice(buffer) 690 } 691 692 // 200: parse as output 693 if self.status.is_success() { 694 match parse_output::<Resp>(&self.buffer) { 695 Ok(output) => { 696 return Ok(output.into_static()); 697 } 698 Err(e) => Err(XrpcError::Decode(e)), 699 } 700 // 400: try typed XRPC error, fallback to generic error 701 } else if self.status.as_u16() == 400 { 702 let error = match parse_error::<Resp>(&self.buffer) { 703 Ok(error) => XrpcError::Xrpc(error), 704 Err(_) => { 705 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 706 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 707 Ok(mut generic) => { 708 generic.nsid = Resp::NSID; 709 generic.method = ""; // method info only available on request 710 generic.http_status = self.status; 711 // Map auth-related errors to AuthError 712 match generic.error.as_ref() { 713 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired), 714 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken), 715 _ => XrpcError::Generic(generic), 716 } 717 } 718 Err(e) => XrpcError::Decode(e), 719 } 720 } 721 }; 722 Err(error.into_static()) 723 // 401: always auth error 724 } else { 725 let error: XrpcError<<Resp as XrpcResp>::Err<'_>> = 726 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 727 Ok(mut generic) => { 728 let status = self.status; 729 generic.nsid = Resp::NSID; 730 generic.method = ""; // method info only available on request 731 generic.http_status = status; 732 match generic.error.as_ref() { 733 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired), 734 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken), 735 _ => XrpcError::Auth(AuthError::NotAuthenticated), 736 } 737 } 738 Err(e) => XrpcError::Decode(e), 739 }; 740 741 Err(error.into_static()) 742 } 743 } 744} 745 746/// Generic XRPC error format for untyped errors like InvalidRequest 747/// 748/// Used when the error doesn't match the endpoint's specific error enum 749#[derive(Debug, Clone, Deserialize, Serialize)] 750pub struct GenericXrpcError { 751 /// Error code (e.g., "InvalidRequest") 752 pub error: SmolStr, 753 /// Optional error message with details 754 pub message: Option<SmolStr>, 755 /// XRPC method NSID that produced this error (context only; not serialized) 756 #[serde(skip)] 757 pub nsid: &'static str, 758 /// HTTP method used (GET/POST) (context only; not serialized) 759 #[serde(skip)] 760 pub method: &'static str, 761 /// HTTP status code (context only; not serialized) 762 #[serde(skip)] 763 pub http_status: StatusCode, 764} 765 766impl std::fmt::Display for GenericXrpcError { 767 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 768 if let Some(msg) = &self.message { 769 write!( 770 f, 771 "{}: {} (nsid={}, method={}, status={})", 772 self.error, msg, self.nsid, self.method, self.http_status 773 ) 774 } else { 775 write!( 776 f, 777 "{} (nsid={}, method={}, status={})", 778 self.error, self.nsid, self.method, self.http_status 779 ) 780 } 781 } 782} 783 784impl IntoStatic for GenericXrpcError { 785 type Output = Self; 786 787 fn into_static(self) -> Self::Output { 788 self 789 } 790} 791 792impl std::error::Error for GenericXrpcError {} 793 794/// XRPC-specific errors returned from endpoints 795/// 796/// Represents errors returned in the response body 797/// Type parameter `E` is the endpoint's specific error enum type. 798#[derive(Debug, thiserror::Error, miette::Diagnostic)] 799pub enum XrpcError<E: std::error::Error + IntoStatic> { 800 /// Typed XRPC error from the endpoint's specific error enum 801 #[error("XRPC error: {0}")] 802 #[diagnostic(code(jacquard_common::xrpc::typed))] 803 Xrpc(E), 804 805 /// Authentication error (ExpiredToken, InvalidToken, etc.) 806 #[error("Authentication error: {0}")] 807 #[diagnostic(code(jacquard_common::xrpc::auth))] 808 Auth(#[from] AuthError), 809 810 /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) 811 #[error("XRPC error: {0}")] 812 #[diagnostic(code(jacquard_common::xrpc::generic))] 813 Generic(GenericXrpcError), 814 815 /// Failed to decode the response body 816 #[error("Failed to decode response: {0}")] 817 #[diagnostic(code(jacquard_common::xrpc::decode))] 818 Decode(#[from] serde_json::Error), 819} 820 821impl<E> IntoStatic for XrpcError<E> 822where 823 E: std::error::Error + IntoStatic, 824 E::Output: std::error::Error + IntoStatic, 825 <E as IntoStatic>::Output: std::error::Error + IntoStatic, 826{ 827 type Output = XrpcError<E::Output>; 828 fn into_static(self) -> Self::Output { 829 match self { 830 XrpcError::Xrpc(e) => XrpcError::Xrpc(e.into_static()), 831 XrpcError::Auth(e) => XrpcError::Auth(e.into_static()), 832 XrpcError::Generic(e) => XrpcError::Generic(e), 833 XrpcError::Decode(e) => XrpcError::Decode(e), 834 } 835 } 836} 837 838#[cfg(test)] 839mod tests { 840 use super::*; 841 use serde::{Deserialize, Serialize}; 842 843 #[derive(Serialize, Deserialize)] 844 #[allow(dead_code)] 845 struct DummyReq; 846 847 #[derive(Deserialize, Debug, thiserror::Error)] 848 #[error("{0}")] 849 struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>); 850 851 impl IntoStatic for DummyErr<'_> { 852 type Output = DummyErr<'static>; 853 fn into_static(self) -> Self::Output { 854 DummyErr(self.0.into_static()) 855 } 856 } 857 858 struct DummyResp; 859 860 impl XrpcResp for DummyResp { 861 const NSID: &'static str = "test.dummy"; 862 const ENCODING: &'static str = "application/json"; 863 type Output<'de> = (); 864 type Err<'de> = DummyErr<'de>; 865 } 866 867 impl XrpcRequest for DummyReq { 868 const NSID: &'static str = "test.dummy"; 869 const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json"); 870 type Response = DummyResp; 871 } 872 873 #[test] 874 fn generic_error_carries_context() { 875 let body = serde_json::json!({"error":"InvalidRequest","message":"missing"}); 876 let buf = Bytes::from(serde_json::to_vec(&body).unwrap()); 877 let resp: Response<DummyResp> = Response::new(buf, StatusCode::BAD_REQUEST); 878 match resp.parse().unwrap_err() { 879 XrpcError::Generic(g) => { 880 assert_eq!(g.error.as_str(), "InvalidRequest"); 881 assert_eq!(g.message.as_deref(), Some("missing")); 882 assert_eq!(g.nsid, DummyResp::NSID); 883 assert_eq!(g.method, ""); // method info only on request 884 assert_eq!(g.http_status, StatusCode::BAD_REQUEST); 885 } 886 other => panic!("unexpected: {other:?}"), 887 } 888 } 889 890 #[test] 891 fn auth_error_mapping() { 892 for (code, expect) in [ 893 ("ExpiredToken", AuthError::TokenExpired), 894 ("InvalidToken", AuthError::InvalidToken), 895 ] { 896 let body = serde_json::json!({"error": code}); 897 let buf = Bytes::from(serde_json::to_vec(&body).unwrap()); 898 let resp: Response<DummyResp> = Response::new(buf, StatusCode::UNAUTHORIZED); 899 match resp.parse().unwrap_err() { 900 XrpcError::Auth(e) => match (e, expect) { 901 (AuthError::TokenExpired, AuthError::TokenExpired) => {} 902 (AuthError::InvalidToken, AuthError::InvalidToken) => {} 903 other => panic!("mismatch: {other:?}"), 904 }, 905 other => panic!("unexpected: {other:?}"), 906 } 907 } 908 } 909 910 #[test] 911 fn no_double_slash_in_path() { 912 #[derive(Serialize, Deserialize)] 913 struct Req; 914 #[derive(Deserialize, Debug, thiserror::Error)] 915 #[error("{0}")] 916 struct Err<'a>(#[serde(borrow)] CowStr<'a>); 917 impl IntoStatic for Err<'_> { 918 type Output = Err<'static>; 919 fn into_static(self) -> Self::Output { 920 Err(self.0.into_static()) 921 } 922 } 923 struct Resp; 924 impl XrpcResp for Resp { 925 const NSID: &'static str = "com.example.test"; 926 const ENCODING: &'static str = "application/json"; 927 type Output<'de> = (); 928 type Err<'de> = Err<'de>; 929 } 930 impl XrpcRequest for Req { 931 const NSID: &'static str = "com.example.test"; 932 const METHOD: XrpcMethod = XrpcMethod::Query; 933 type Response = Resp; 934 } 935 936 let opts = CallOptions::default(); 937 for base in [ 938 Url::parse("https://pds").unwrap(), 939 Url::parse("https://pds/").unwrap(), 940 Url::parse("https://pds/base/").unwrap(), 941 ] { 942 let req = build_http_request(&base, &Req, &opts).unwrap(); 943 let uri = req.uri().to_string(); 944 assert!(uri.contains("/xrpc/com.example.test")); 945 assert!(!uri.contains("//xrpc")); 946 } 947 } 948}