jacquard_common/types/
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::IntoStatic;
25use crate::error::TransportError;
26use crate::http_client::HttpClient;
27use crate::types::value::Data;
28use crate::{AuthorizationToken, error::AuthError};
29use crate::{CowStr, error::XrpcResult};
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 types, and associated output types.
84///
85/// The trait is implemented on the request parameters/input type itself.
86pub trait XrpcRequest: Serialize {
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    /// Output encoding (MIME type)
94    const OUTPUT_ENCODING: &'static str;
95
96    /// Response output type
97    type Output<'de>: Deserialize<'de> + IntoStatic;
98
99    /// Error type for this request
100    type Err<'de>: Error + Deserialize<'de> + IntoStatic;
101
102    /// Encode the request body for procedures.
103    ///
104    /// Default implementation serializes to JSON. Override for non-JSON encodings.
105    fn encode_body(&self) -> Result<Vec<u8>, EncodeError> {
106        Ok(serde_json::to_vec(self)?)
107    }
108}
109
110/// Error type for XRPC endpoints that don't define any errors
111#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
112#[serde(bound(deserialize = "'de: 'a"))]
113pub struct GenericError<'a>(Data<'a>);
114
115impl fmt::Display for GenericError<'_> {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        self.0.fmt(f)
118    }
119}
120
121impl Error for GenericError<'_> {}
122
123impl IntoStatic for GenericError<'_> {
124    type Output = GenericError<'static>;
125    fn into_static(self) -> Self::Output {
126        GenericError(self.0.into_static())
127    }
128}
129
130/// Per-request options for XRPC calls.
131#[derive(Debug, Default, Clone)]
132pub struct CallOptions<'a> {
133    /// Optional Authorization to apply (`Bearer` or `DPoP`).
134    pub auth: Option<AuthorizationToken<'a>>,
135    /// `atproto-proxy` header value.
136    pub atproto_proxy: Option<CowStr<'a>>,
137    /// `atproto-accept-labelers` header values.
138    pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
139    /// Extra headers to attach to this request.
140    pub extra_headers: Vec<(HeaderName, HeaderValue)>,
141}
142
143impl IntoStatic for CallOptions<'_> {
144    type Output = CallOptions<'static>;
145
146    fn into_static(self) -> Self::Output {
147        CallOptions {
148            auth: self.auth.map(|auth| auth.into_static()),
149            atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()),
150            atproto_accept_labelers: self
151                .atproto_accept_labelers
152                .map(|labelers| labelers.into_static()),
153            extra_headers: self.extra_headers,
154        }
155    }
156}
157
158/// Extension for stateless XRPC calls on any `HttpClient`.
159///
160/// Example
161/// ```ignore
162/// use jacquard::client::XrpcExt;
163/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
164/// use jacquard::types::ident::AtIdentifier;
165/// use miette::IntoDiagnostic;
166///
167/// #[tokio::main]
168/// async fn main() -> miette::Result<()> {
169///     let http = reqwest::Client::new();
170///     let base = url::Url::parse("https://public.api.bsky.app")?;
171///     let resp = http
172///         .xrpc(base)
173///         .send(
174///             GetAuthorFeed::new()
175///                 .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
176///                 .limit(5)
177///                 .build(),
178///         )
179///         .await?;
180///     let out = resp.into_output()?;
181///     println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
182///     Ok(())
183/// }
184/// ```
185pub trait XrpcExt: HttpClient {
186    /// Start building an XRPC call for the given base URL.
187    fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self>
188    where
189        Self: Sized,
190    {
191        XrpcCall {
192            client: self,
193            base,
194            opts: CallOptions::default(),
195        }
196    }
197}
198
199impl<T: HttpClient> XrpcExt for T {}
200
201/// Stateless XRPC call builder.
202///
203/// Example (per-request overrides)
204/// ```ignore
205/// use jacquard::client::{XrpcExt, AuthorizationToken};
206/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
207/// use jacquard::types::ident::AtIdentifier;
208/// use jacquard::CowStr;
209/// use miette::IntoDiagnostic;
210///
211/// #[tokio::main]
212/// async fn main() -> miette::Result<()> {
213///     let http = reqwest::Client::new();
214///     let base = url::Url::parse("https://public.api.bsky.app")?;
215///     let resp = http
216///         .xrpc(base)
217///         .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
218///         .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
219///         .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
220///         .send(
221///             GetAuthorFeed::new()
222///                 .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
223///                 .limit(5)
224///                 .build(),
225///         )
226///         .await?;
227///     let out = resp.into_output()?;
228///     println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
229///     Ok(())
230/// }
231/// ```
232pub struct XrpcCall<'a, C: HttpClient> {
233    pub(crate) client: &'a C,
234    pub(crate) base: Url,
235    pub(crate) opts: CallOptions<'a>,
236}
237
238impl<'a, C: HttpClient> XrpcCall<'a, C> {
239    /// Apply Authorization to this call.
240    pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
241        self.opts.auth = Some(token);
242        self
243    }
244    /// Set `atproto-proxy` header for this call.
245    pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
246        self.opts.atproto_proxy = Some(proxy);
247        self
248    }
249    /// Set `atproto-accept-labelers` header(s) for this call.
250    pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
251        self.opts.atproto_accept_labelers = Some(labelers);
252        self
253    }
254    /// Add an extra header.
255    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
256        self.opts.extra_headers.push((name, value));
257        self
258    }
259    /// Replace the builder's options entirely.
260    pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
261        self.opts = opts;
262        self
263    }
264
265    /// Send the given typed XRPC request and return a response wrapper.
266    ///
267    /// Note on 401 handling:
268    /// - When the server returns 401 with a `WWW-Authenticate` header, this surfaces as
269    ///   `ClientError::Auth(AuthError::Other(header))` so higher layers (e.g., OAuth/DPoP) can
270    ///   inspect the header for `error="invalid_token"` or `error="use_dpop_nonce"` and react
271    ///   (refresh/retry). If the header is absent, the 401 body flows through to `Response` and
272    ///   can be parsed/mapped to `AuthError` as appropriate.
273    pub async fn send<R: XrpcRequest + Send>(self, request: &R) -> XrpcResult<Response<R>> {
274        let http_request = build_http_request(&self.base, request, &self.opts)
275            .map_err(crate::error::TransportError::from)?;
276
277        let http_response = self
278            .client
279            .send_http(http_request)
280            .await
281            .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?;
282
283        process_response(http_response)
284    }
285}
286
287/// Process the HTTP response from the server into a proper xrpc response statelessly.
288///
289/// Exposed to make things more easily pluggable
290#[inline]
291pub fn process_response<R: XrpcRequest + Send>(
292    http_response: http::Response<Vec<u8>>,
293) -> XrpcResult<Response<R>> {
294    let status = http_response.status();
295    // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers
296    // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh.
297    if status.as_u16() == 401 {
298        if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
299            return Err(crate::error::ClientError::Auth(
300                crate::error::AuthError::Other(hv.clone()),
301            ));
302        }
303    }
304    let buffer = Bytes::from(http_response.into_body());
305
306    if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
307        return Err(crate::error::HttpError {
308            status,
309            body: Some(buffer),
310        }
311        .into());
312    }
313
314    Ok(Response::new(buffer, status))
315}
316
317/// HTTP headers commonly used in XRPC requests
318pub enum Header {
319    /// Content-Type header
320    ContentType,
321    /// Authorization header
322    Authorization,
323    /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate.
324    ///
325    /// See: <https://atproto.com/specs/xrpc#service-proxying>
326    AtprotoProxy,
327    /// `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.
328    AtprotoAcceptLabelers,
329}
330
331impl From<Header> for HeaderName {
332    fn from(value: Header) -> Self {
333        match value {
334            Header::ContentType => CONTENT_TYPE,
335            Header::Authorization => AUTHORIZATION,
336            Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"),
337            Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"),
338        }
339    }
340}
341
342/// Build an HTTP request for an XRPC call given base URL and options
343pub fn build_http_request<R: XrpcRequest>(
344    base: &Url,
345    req: &R,
346    opts: &CallOptions<'_>,
347) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> {
348    let mut url = base.clone();
349    let mut path = url.path().trim_end_matches('/').to_owned();
350    path.push_str("/xrpc/");
351    path.push_str(R::NSID);
352    url.set_path(&path);
353
354    if let XrpcMethod::Query = R::METHOD {
355        let qs = serde_html_form::to_string(&req)
356            .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?;
357        if !qs.is_empty() {
358            url.set_query(Some(&qs));
359        } else {
360            url.set_query(None);
361        }
362    }
363
364    let method = match R::METHOD {
365        XrpcMethod::Query => http::Method::GET,
366        XrpcMethod::Procedure(_) => http::Method::POST,
367    };
368
369    let mut builder = Request::builder().method(method).uri(url.as_str());
370
371    if let XrpcMethod::Procedure(encoding) = R::METHOD {
372        builder = builder.header(Header::ContentType, encoding);
373    }
374    builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING);
375
376    if let Some(token) = &opts.auth {
377        let hv = match token {
378            AuthorizationToken::Bearer(t) => {
379                HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
380            }
381            AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())),
382        }
383        .map_err(|e| {
384            TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
385        })?;
386        builder = builder.header(Header::Authorization, hv);
387    }
388
389    if let Some(proxy) = &opts.atproto_proxy {
390        builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
391    }
392    if let Some(labelers) = &opts.atproto_accept_labelers {
393        if !labelers.is_empty() {
394            let joined = labelers
395                .iter()
396                .map(|s| s.as_ref())
397                .collect::<Vec<_>>()
398                .join(", ");
399            builder = builder.header(Header::AtprotoAcceptLabelers, joined);
400        }
401    }
402    for (name, value) in &opts.extra_headers {
403        builder = builder.header(name, value);
404    }
405
406    let body = if let XrpcMethod::Procedure(_) = R::METHOD {
407        req.encode_body()
408            .map_err(|e| TransportError::InvalidRequest(e.to_string()))?
409    } else {
410        vec![]
411    };
412
413    builder
414        .body(body)
415        .map_err(|e| TransportError::InvalidRequest(e.to_string()))
416}
417
418/// XRPC response wrapper that owns the response buffer
419///
420/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations.
421/// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`).
422pub struct Response<R: XrpcRequest> {
423    buffer: Bytes,
424    status: StatusCode,
425    _marker: PhantomData<R>,
426}
427
428impl<R: XrpcRequest> Response<R> {
429    /// Create a new response from a buffer and status code
430    pub fn new(buffer: Bytes, status: StatusCode) -> Self {
431        Self {
432            buffer,
433            status,
434            _marker: PhantomData,
435        }
436    }
437
438    /// Get the HTTP status code
439    pub fn status(&self) -> StatusCode {
440        self.status
441    }
442
443    /// Parse the response, borrowing from the internal buffer
444    pub fn parse(&self) -> Result<R::Output<'_>, XrpcError<R::Err<'_>>> {
445        // Use a helper to make lifetime inference work
446        fn parse_output<'b, R: XrpcRequest>(
447            buffer: &'b [u8],
448        ) -> Result<R::Output<'b>, serde_json::Error> {
449            serde_json::from_slice(buffer)
450        }
451
452        fn parse_error<'b, R: XrpcRequest>(
453            buffer: &'b [u8],
454        ) -> Result<R::Err<'b>, serde_json::Error> {
455            serde_json::from_slice(buffer)
456        }
457
458        // 200: parse as output
459        if self.status.is_success() {
460            match parse_output::<R>(&self.buffer) {
461                Ok(output) => Ok(output),
462                Err(e) => Err(XrpcError::Decode(e)),
463            }
464        // 400: try typed XRPC error, fallback to generic error
465        } else if self.status.as_u16() == 400 {
466            match parse_error::<R>(&self.buffer) {
467                Ok(error) => Err(XrpcError::Xrpc(error)),
468                Err(_) => {
469                    // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
470                    match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
471                        Ok(mut generic) => {
472                            generic.nsid = R::NSID;
473                            generic.method = R::METHOD.as_str();
474                            generic.http_status = self.status;
475                            // Map auth-related errors to AuthError
476                            match generic.error.as_str() {
477                                "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
478                                "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
479                                _ => Err(XrpcError::Generic(generic)),
480                            }
481                        }
482                        Err(e) => Err(XrpcError::Decode(e)),
483                    }
484                }
485            }
486        // 401: always auth error
487        } else {
488            match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
489                Ok(mut generic) => {
490                    generic.nsid = R::NSID;
491                    generic.method = R::METHOD.as_str();
492                    generic.http_status = self.status;
493                    match generic.error.as_str() {
494                        "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
495                        "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
496                        _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
497                    }
498                }
499                Err(e) => Err(XrpcError::Decode(e)),
500            }
501        }
502    }
503
504    /// Parse the response into an owned output
505    pub fn into_output(self) -> Result<R::Output<'static>, XrpcError<R::Err<'static>>>
506    where
507        for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>,
508        for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>,
509    {
510        // Use a helper to make lifetime inference work
511        fn parse_output<'b, R: XrpcRequest>(
512            buffer: &'b [u8],
513        ) -> Result<R::Output<'b>, serde_json::Error> {
514            serde_json::from_slice(buffer)
515        }
516
517        fn parse_error<'b, R: XrpcRequest>(
518            buffer: &'b [u8],
519        ) -> Result<R::Err<'b>, serde_json::Error> {
520            serde_json::from_slice(buffer)
521        }
522
523        // 200: parse as output
524        if self.status.is_success() {
525            match parse_output::<R>(&self.buffer) {
526                Ok(output) => Ok(output.into_static()),
527                Err(e) => Err(XrpcError::Decode(e)),
528            }
529        // 400: try typed XRPC error, fallback to generic error
530        } else if self.status.as_u16() == 400 {
531            match parse_error::<R>(&self.buffer) {
532                Ok(error) => Err(XrpcError::Xrpc(error.into_static())),
533                Err(_) => {
534                    // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
535                    match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
536                        Ok(mut generic) => {
537                            generic.nsid = R::NSID;
538                            generic.method = R::METHOD.as_str();
539                            generic.http_status = self.status;
540                            // Map auth-related errors to AuthError
541                            match generic.error.as_ref() {
542                                "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
543                                "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
544                                _ => Err(XrpcError::Generic(generic)),
545                            }
546                        }
547                        Err(e) => Err(XrpcError::Decode(e)),
548                    }
549                }
550            }
551        // 401: always auth error
552        } else {
553            match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
554                Ok(mut generic) => {
555                    let status = self.status;
556                    generic.nsid = R::NSID;
557                    generic.method = R::METHOD.as_str();
558                    generic.http_status = status;
559                    match generic.error.as_ref() {
560                        "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
561                        "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
562                        _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
563                    }
564                }
565                Err(e) => Err(XrpcError::Decode(e)),
566            }
567        }
568    }
569
570    /// Get the raw buffer
571    pub fn buffer(&self) -> &Bytes {
572        &self.buffer
573    }
574}
575
576/// Generic XRPC error format for untyped errors like InvalidRequest
577///
578/// Used when the error doesn't match the endpoint's specific error enum
579#[derive(Debug, Clone, Deserialize)]
580pub struct GenericXrpcError {
581    /// Error code (e.g., "InvalidRequest")
582    pub error: SmolStr,
583    /// Optional error message with details
584    pub message: Option<SmolStr>,
585    /// XRPC method NSID that produced this error (context only; not serialized)
586    #[serde(skip)]
587    pub nsid: &'static str,
588    /// HTTP method used (GET/POST) (context only; not serialized)
589    #[serde(skip)]
590    pub method: &'static str,
591    /// HTTP status code (context only; not serialized)
592    #[serde(skip)]
593    pub http_status: StatusCode,
594}
595
596impl std::fmt::Display for GenericXrpcError {
597    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        if let Some(msg) = &self.message {
599            write!(
600                f,
601                "{}: {} (nsid={}, method={}, status={})",
602                self.error, msg, self.nsid, self.method, self.http_status
603            )
604        } else {
605            write!(
606                f,
607                "{} (nsid={}, method={}, status={})",
608                self.error, self.nsid, self.method, self.http_status
609            )
610        }
611    }
612}
613
614impl std::error::Error for GenericXrpcError {}
615
616/// XRPC-specific errors returned from endpoints
617///
618/// Represents errors returned in the response body
619/// Type parameter `E` is the endpoint's specific error enum type.
620#[derive(Debug, thiserror::Error, miette::Diagnostic)]
621pub enum XrpcError<E: std::error::Error + IntoStatic> {
622    /// Typed XRPC error from the endpoint's specific error enum
623    #[error("XRPC error: {0}")]
624    #[diagnostic(code(jacquard_common::xrpc::typed))]
625    Xrpc(E),
626
627    /// Authentication error (ExpiredToken, InvalidToken, etc.)
628    #[error("Authentication error: {0}")]
629    #[diagnostic(code(jacquard_common::xrpc::auth))]
630    Auth(#[from] AuthError),
631
632    /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest)
633    #[error("XRPC error: {0}")]
634    #[diagnostic(code(jacquard_common::xrpc::generic))]
635    Generic(GenericXrpcError),
636
637    /// Failed to decode the response body
638    #[error("Failed to decode response: {0}")]
639    #[diagnostic(code(jacquard_common::xrpc::decode))]
640    Decode(#[from] serde_json::Error),
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use serde::{Deserialize, Serialize};
647
648    #[derive(Serialize)]
649    struct DummyReq;
650
651    #[derive(Deserialize, Debug, thiserror::Error)]
652    #[error("{0}")]
653    struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>);
654
655    impl IntoStatic for DummyErr<'_> {
656        type Output = DummyErr<'static>;
657        fn into_static(self) -> Self::Output {
658            DummyErr(self.0.into_static())
659        }
660    }
661
662    impl XrpcRequest for DummyReq {
663        const NSID: &'static str = "test.dummy";
664        const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json");
665        const OUTPUT_ENCODING: &'static str = "application/json";
666        type Output<'de> = ();
667        type Err<'de> = DummyErr<'de>;
668    }
669
670    #[test]
671    fn generic_error_carries_context() {
672        let body = serde_json::json!({"error":"InvalidRequest","message":"missing"});
673        let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
674        let resp: Response<DummyReq> = Response::new(buf, StatusCode::BAD_REQUEST);
675        match resp.parse().unwrap_err() {
676            XrpcError::Generic(g) => {
677                assert_eq!(g.error.as_str(), "InvalidRequest");
678                assert_eq!(g.message.as_deref(), Some("missing"));
679                assert_eq!(g.nsid, DummyReq::NSID);
680                assert_eq!(g.method, DummyReq::METHOD.as_str());
681                assert_eq!(g.http_status, StatusCode::BAD_REQUEST);
682            }
683            other => panic!("unexpected: {other:?}"),
684        }
685    }
686
687    #[test]
688    fn auth_error_mapping() {
689        for (code, expect) in [
690            ("ExpiredToken", AuthError::TokenExpired),
691            ("InvalidToken", AuthError::InvalidToken),
692        ] {
693            let body = serde_json::json!({"error": code});
694            let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
695            let resp: Response<DummyReq> = Response::new(buf, StatusCode::UNAUTHORIZED);
696            match resp.parse().unwrap_err() {
697                XrpcError::Auth(e) => match (e, expect) {
698                    (AuthError::TokenExpired, AuthError::TokenExpired) => {}
699                    (AuthError::InvalidToken, AuthError::InvalidToken) => {}
700                    other => panic!("mismatch: {other:?}"),
701                },
702                other => panic!("unexpected: {other:?}"),
703            }
704        }
705    }
706
707    #[test]
708    fn no_double_slash_in_path() {
709        #[derive(Serialize)]
710        struct Req;
711        #[derive(Deserialize, Debug, thiserror::Error)]
712        #[error("{0}")]
713        struct Err<'a>(#[serde(borrow)] CowStr<'a>);
714        impl IntoStatic for Err<'_> {
715            type Output = Err<'static>;
716            fn into_static(self) -> Self::Output {
717                Err(self.0.into_static())
718            }
719        }
720        impl XrpcRequest for Req {
721            const NSID: &'static str = "com.example.test";
722            const METHOD: XrpcMethod = XrpcMethod::Query;
723            const OUTPUT_ENCODING: &'static str = "application/json";
724            type Output<'de> = ();
725            type Err<'de> = Err<'de>;
726        }
727
728        let opts = CallOptions::default();
729        for base in [
730            Url::parse("https://pds").unwrap(),
731            Url::parse("https://pds/").unwrap(),
732            Url::parse("https://pds/base/").unwrap(),
733        ] {
734            let req = build_http_request(&base, &Req, &opts).unwrap();
735            let uri = req.uri().to_string();
736            assert!(uri.contains("/xrpc/com.example.test"));
737            assert!(!uri.contains("//xrpc"));
738        }
739    }
740}
741
742/// Stateful XRPC call trait
743pub trait XrpcClient: HttpClient {
744    /// Get the base URI for the client.
745    fn base_uri(&self) -> Url;
746
747    /// Get the call options for the client.
748    fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
749        async { CallOptions::default() }
750    }
751    /// Send an XRPC request and parse the response
752    fn send<R: XrpcRequest + Send>(
753        self,
754        request: &R,
755    ) -> impl Future<Output = XrpcResult<Response<R>>>;
756}