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`.
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>>>
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}