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