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