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