jacquard_common/
xrpc.rs

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