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 pub async fn send<R>(self, request: &R) -> XrpcResult<Response<<R as XrpcRequest>::Response>>
309 where
310 R: XrpcRequest + Send + Sync,
311 <R as XrpcRequest>::Response: Send + Sync,
312 {
313 let http_request = build_http_request(&self.base, request, &self.opts)
314 .map_err(crate::error::TransportError::from)?;
315
316 let http_response = self
317 .client
318 .send_http(http_request)
319 .await
320 .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?;
321
322 process_response(http_response)
323 }
324}
325
326/// Process the HTTP response from the server into a proper xrpc response statelessly.
327///
328/// Exposed to make things more easily pluggable
329#[inline]
330pub fn process_response<Resp>(http_response: http::Response<Vec<u8>>) -> XrpcResult<Response<Resp>>
331where
332 Resp: XrpcResp,
333{
334 let status = http_response.status();
335 // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers
336 // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh.
337 if status.as_u16() == 401 {
338 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
339 return Err(crate::error::ClientError::Auth(
340 crate::error::AuthError::Other(hv.clone()),
341 ));
342 }
343 }
344 let buffer = Bytes::from(http_response.into_body());
345
346 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
347 return Err(crate::error::HttpError {
348 status,
349 body: Some(buffer),
350 }
351 .into());
352 }
353
354 Ok(Response::new(buffer, status))
355}
356
357/// HTTP headers commonly used in XRPC requests
358pub enum Header {
359 /// Content-Type header
360 ContentType,
361 /// Authorization header
362 Authorization,
363 /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate.
364 ///
365 /// See: <https://atproto.com/specs/xrpc#service-proxying>
366 AtprotoProxy,
367 /// `atproto-accept-labelers` header used by clients to request labels from specific labelers to be included and applied in the response. See [label](https://atproto.com/specs/label) specification for details.
368 AtprotoAcceptLabelers,
369}
370
371impl From<Header> for HeaderName {
372 fn from(value: Header) -> Self {
373 match value {
374 Header::ContentType => CONTENT_TYPE,
375 Header::Authorization => AUTHORIZATION,
376 Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"),
377 Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"),
378 }
379 }
380}
381
382/// Build an HTTP request for an XRPC call given base URL and options
383pub fn build_http_request<'s, R>(
384 base: &Url,
385 req: &R,
386 opts: &CallOptions<'_>,
387) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError>
388where
389 R: XrpcRequest,
390{
391 let mut url = base.clone();
392 let mut path = url.path().trim_end_matches('/').to_owned();
393 path.push_str("/xrpc/");
394 path.push_str(<R as XrpcRequest>::NSID);
395 url.set_path(&path);
396
397 if let XrpcMethod::Query = <R as XrpcRequest>::METHOD {
398 let qs = serde_html_form::to_string(&req)
399 .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?;
400 if !qs.is_empty() {
401 url.set_query(Some(&qs));
402 } else {
403 url.set_query(None);
404 }
405 }
406
407 let method = match <R as XrpcRequest>::METHOD {
408 XrpcMethod::Query => http::Method::GET,
409 XrpcMethod::Procedure(_) => http::Method::POST,
410 };
411
412 let mut builder = Request::builder().method(method).uri(url.as_str());
413
414 if let XrpcMethod::Procedure(encoding) = <R as XrpcRequest>::METHOD {
415 builder = builder.header(Header::ContentType, encoding);
416 }
417 let output_encoding = <R::Response as XrpcResp>::ENCODING;
418 builder = builder.header(http::header::ACCEPT, output_encoding);
419
420 if let Some(token) = &opts.auth {
421 let hv = match token {
422 AuthorizationToken::Bearer(t) => {
423 HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
424 }
425 AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())),
426 }
427 .map_err(|e| {
428 TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
429 })?;
430 builder = builder.header(Header::Authorization, hv);
431 }
432
433 if let Some(proxy) = &opts.atproto_proxy {
434 builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
435 }
436 if let Some(labelers) = &opts.atproto_accept_labelers {
437 if !labelers.is_empty() {
438 let joined = labelers
439 .iter()
440 .map(|s| s.as_ref())
441 .collect::<Vec<_>>()
442 .join(", ");
443 builder = builder.header(Header::AtprotoAcceptLabelers, joined);
444 }
445 }
446 for (name, value) in &opts.extra_headers {
447 builder = builder.header(name, value);
448 }
449
450 let body = if let XrpcMethod::Procedure(_) = R::METHOD {
451 req.encode_body()
452 .map_err(|e| TransportError::InvalidRequest(e.to_string()))?
453 } else {
454 vec![]
455 };
456
457 builder
458 .body(body)
459 .map_err(|e| TransportError::InvalidRequest(e.to_string()))
460}
461
462/// XRPC response wrapper that owns the response buffer
463///
464/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations.
465/// Generic over the response marker type (e.g., `GetAuthorFeedResponse`), not the request.
466pub struct Response<Resp>
467where
468 Resp: XrpcResp, // HRTB: Resp works with any lifetime
469{
470 _marker: PhantomData<fn() -> Resp>,
471 buffer: Bytes,
472 status: StatusCode,
473}
474
475impl<Resp> Response<Resp>
476where
477 Resp: XrpcResp,
478{
479 /// Create a new response from a buffer and status code
480 pub fn new(buffer: Bytes, status: StatusCode) -> Self {
481 Self {
482 buffer,
483 status,
484 _marker: PhantomData,
485 }
486 }
487
488 /// Get the HTTP status code
489 pub fn status(&self) -> StatusCode {
490 self.status
491 }
492
493 /// Get the raw buffer
494 pub fn buffer(&self) -> &Bytes {
495 &self.buffer
496 }
497
498 /// Parse the response, borrowing from the internal buffer
499 pub fn parse<'s>(
500 &'s self,
501 ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
502 // 200: parse as output
503 if self.status.is_success() {
504 match serde_json::from_slice::<_>(&self.buffer) {
505 Ok(output) => Ok(output),
506 Err(e) => Err(XrpcError::Decode(e)),
507 }
508 // 400: try typed XRPC error, fallback to generic error
509 } else if self.status.as_u16() == 400 {
510 match serde_json::from_slice::<_>(&self.buffer) {
511 Ok(error) => Err(XrpcError::Xrpc(error)),
512 Err(_) => {
513 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
514 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
515 Ok(mut generic) => {
516 generic.nsid = Resp::NSID;
517 generic.method = ""; // method info only available on request
518 generic.http_status = self.status;
519 // Map auth-related errors to AuthError
520 match generic.error.as_str() {
521 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
522 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
523 _ => Err(XrpcError::Generic(generic)),
524 }
525 }
526 Err(e) => Err(XrpcError::Decode(e)),
527 }
528 }
529 }
530 // 401: always auth error
531 } else {
532 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
533 Ok(mut generic) => {
534 generic.nsid = Resp::NSID;
535 generic.method = ""; // method info only available on request
536 generic.http_status = self.status;
537 match generic.error.as_str() {
538 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
539 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
540 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
541 }
542 }
543 Err(e) => Err(XrpcError::Decode(e)),
544 }
545 }
546 }
547
548 /// Parse this as validated, loosely typed atproto data.
549 ///
550 /// NOTE: If the response is an error, it will still parse as the matching error type for the request.
551 pub fn parse_data<'s>(&'s self) -> Result<Data<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
552 // 200: parse as output
553 if self.status.is_success() {
554 match serde_json::from_slice::<_>(&self.buffer) {
555 Ok(output) => Ok(output),
556 Err(e) => Err(XrpcError::Decode(e)),
557 }
558 // 400: try typed XRPC error, fallback to generic error
559 } else if self.status.as_u16() == 400 {
560 match serde_json::from_slice::<_>(&self.buffer) {
561 Ok(error) => Err(XrpcError::Xrpc(error)),
562 Err(_) => {
563 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
564 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
565 Ok(mut generic) => {
566 generic.nsid = Resp::NSID;
567 generic.method = ""; // method info only available on request
568 generic.http_status = self.status;
569 // Map auth-related errors to AuthError
570 match generic.error.as_str() {
571 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
572 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
573 _ => Err(XrpcError::Generic(generic)),
574 }
575 }
576 Err(e) => Err(XrpcError::Decode(e)),
577 }
578 }
579 }
580 // 401: always auth error
581 } else {
582 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
583 Ok(mut generic) => {
584 generic.nsid = Resp::NSID;
585 generic.method = ""; // method info only available on request
586 generic.http_status = self.status;
587 match generic.error.as_str() {
588 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
589 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
590 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
591 }
592 }
593 Err(e) => Err(XrpcError::Decode(e)),
594 }
595 }
596 }
597
598 /// Parse this as raw atproto data with minimal validation.
599 ///
600 /// NOTE: If the response is an error, it will still parse as the matching error type for the request.
601 pub fn parse_raw<'s>(&'s self) -> Result<RawData<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
602 // 200: parse as output
603 if self.status.is_success() {
604 match serde_json::from_slice::<_>(&self.buffer) {
605 Ok(output) => Ok(output),
606 Err(e) => Err(XrpcError::Decode(e)),
607 }
608 // 400: try typed XRPC error, fallback to generic error
609 } else if self.status.as_u16() == 400 {
610 match serde_json::from_slice::<_>(&self.buffer) {
611 Ok(error) => Err(XrpcError::Xrpc(error)),
612 Err(_) => {
613 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
614 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
615 Ok(mut generic) => {
616 generic.nsid = Resp::NSID;
617 generic.method = ""; // method info only available on request
618 generic.http_status = self.status;
619 // Map auth-related errors to AuthError
620 match generic.error.as_str() {
621 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
622 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
623 _ => Err(XrpcError::Generic(generic)),
624 }
625 }
626 Err(e) => Err(XrpcError::Decode(e)),
627 }
628 }
629 }
630 // 401: always auth error
631 } else {
632 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
633 Ok(mut generic) => {
634 generic.nsid = Resp::NSID;
635 generic.method = ""; // method info only available on request
636 generic.http_status = self.status;
637 match generic.error.as_str() {
638 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
639 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
640 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
641 }
642 }
643 Err(e) => Err(XrpcError::Decode(e)),
644 }
645 }
646 }
647
648 /// Reinterpret this response as a different response type.
649 ///
650 /// This transmutes the response by keeping the same buffer and status code,
651 /// but changing the type-level marker. Useful for converting generic XRPC responses
652 /// into collection-specific typed responses.
653 ///
654 /// # Safety
655 ///
656 /// This is safe in the sense that no memory unsafety occurs, but logical correctness
657 /// depends on ensuring the buffer actually contains data that can deserialize to `NEW`.
658 /// Incorrect conversion will cause deserialization errors at runtime.
659 pub fn transmute<NEW: XrpcResp>(self) -> Response<NEW> {
660 Response {
661 buffer: self.buffer,
662 status: self.status,
663 _marker: PhantomData,
664 }
665 }
666}
667
668impl<Resp> Response<Resp>
669where
670 Resp: XrpcResp,
671{
672 /// Parse the response into an owned output
673 pub fn into_output(
674 self,
675 ) -> Result<<Resp as XrpcResp>::Output<'static>, XrpcError<<Resp as XrpcResp>::Err<'static>>>
676 where
677 for<'a> <Resp as XrpcResp>::Output<'a>:
678 IntoStatic<Output = <Resp as XrpcResp>::Output<'static>>,
679 for<'a> <Resp as XrpcResp>::Err<'a>: IntoStatic<Output = <Resp as XrpcResp>::Err<'static>>,
680 {
681 // Use a helper to make lifetime inference work
682 fn parse_output<'b, R: XrpcResp>(
683 buffer: &'b [u8],
684 ) -> Result<R::Output<'b>, serde_json::Error> {
685 serde_json::from_slice(buffer)
686 }
687
688 fn parse_error<'b, R: XrpcResp>(buffer: &'b [u8]) -> Result<R::Err<'b>, serde_json::Error> {
689 serde_json::from_slice(buffer)
690 }
691
692 // 200: parse as output
693 if self.status.is_success() {
694 match parse_output::<Resp>(&self.buffer) {
695 Ok(output) => {
696 return Ok(output.into_static());
697 }
698 Err(e) => Err(XrpcError::Decode(e)),
699 }
700 // 400: try typed XRPC error, fallback to generic error
701 } else if self.status.as_u16() == 400 {
702 let error = match parse_error::<Resp>(&self.buffer) {
703 Ok(error) => XrpcError::Xrpc(error),
704 Err(_) => {
705 // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
706 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
707 Ok(mut generic) => {
708 generic.nsid = Resp::NSID;
709 generic.method = ""; // method info only available on request
710 generic.http_status = self.status;
711 // Map auth-related errors to AuthError
712 match generic.error.as_ref() {
713 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired),
714 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken),
715 _ => XrpcError::Generic(generic),
716 }
717 }
718 Err(e) => XrpcError::Decode(e),
719 }
720 }
721 };
722 Err(error.into_static())
723 // 401: always auth error
724 } else {
725 let error: XrpcError<<Resp as XrpcResp>::Err<'_>> =
726 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
727 Ok(mut generic) => {
728 let status = self.status;
729 generic.nsid = Resp::NSID;
730 generic.method = ""; // method info only available on request
731 generic.http_status = status;
732 match generic.error.as_ref() {
733 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired),
734 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken),
735 _ => XrpcError::Auth(AuthError::NotAuthenticated),
736 }
737 }
738 Err(e) => XrpcError::Decode(e),
739 };
740
741 Err(error.into_static())
742 }
743 }
744}
745
746/// Generic XRPC error format for untyped errors like InvalidRequest
747///
748/// Used when the error doesn't match the endpoint's specific error enum
749#[derive(Debug, Clone, Deserialize, Serialize)]
750pub struct GenericXrpcError {
751 /// Error code (e.g., "InvalidRequest")
752 pub error: SmolStr,
753 /// Optional error message with details
754 pub message: Option<SmolStr>,
755 /// XRPC method NSID that produced this error (context only; not serialized)
756 #[serde(skip)]
757 pub nsid: &'static str,
758 /// HTTP method used (GET/POST) (context only; not serialized)
759 #[serde(skip)]
760 pub method: &'static str,
761 /// HTTP status code (context only; not serialized)
762 #[serde(skip)]
763 pub http_status: StatusCode,
764}
765
766impl std::fmt::Display for GenericXrpcError {
767 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
768 if let Some(msg) = &self.message {
769 write!(
770 f,
771 "{}: {} (nsid={}, method={}, status={})",
772 self.error, msg, self.nsid, self.method, self.http_status
773 )
774 } else {
775 write!(
776 f,
777 "{} (nsid={}, method={}, status={})",
778 self.error, self.nsid, self.method, self.http_status
779 )
780 }
781 }
782}
783
784impl IntoStatic for GenericXrpcError {
785 type Output = Self;
786
787 fn into_static(self) -> Self::Output {
788 self
789 }
790}
791
792impl std::error::Error for GenericXrpcError {}
793
794/// XRPC-specific errors returned from endpoints
795///
796/// Represents errors returned in the response body
797/// Type parameter `E` is the endpoint's specific error enum type.
798#[derive(Debug, thiserror::Error, miette::Diagnostic)]
799pub enum XrpcError<E: std::error::Error + IntoStatic> {
800 /// Typed XRPC error from the endpoint's specific error enum
801 #[error("XRPC error: {0}")]
802 #[diagnostic(code(jacquard_common::xrpc::typed))]
803 Xrpc(E),
804
805 /// Authentication error (ExpiredToken, InvalidToken, etc.)
806 #[error("Authentication error: {0}")]
807 #[diagnostic(code(jacquard_common::xrpc::auth))]
808 Auth(#[from] AuthError),
809
810 /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest)
811 #[error("XRPC error: {0}")]
812 #[diagnostic(code(jacquard_common::xrpc::generic))]
813 Generic(GenericXrpcError),
814
815 /// Failed to decode the response body
816 #[error("Failed to decode response: {0}")]
817 #[diagnostic(code(jacquard_common::xrpc::decode))]
818 Decode(#[from] serde_json::Error),
819}
820
821impl<E> IntoStatic for XrpcError<E>
822where
823 E: std::error::Error + IntoStatic,
824 E::Output: std::error::Error + IntoStatic,
825 <E as IntoStatic>::Output: std::error::Error + IntoStatic,
826{
827 type Output = XrpcError<E::Output>;
828 fn into_static(self) -> Self::Output {
829 match self {
830 XrpcError::Xrpc(e) => XrpcError::Xrpc(e.into_static()),
831 XrpcError::Auth(e) => XrpcError::Auth(e.into_static()),
832 XrpcError::Generic(e) => XrpcError::Generic(e),
833 XrpcError::Decode(e) => XrpcError::Decode(e),
834 }
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use serde::{Deserialize, Serialize};
842
843 #[derive(Serialize, Deserialize)]
844 #[allow(dead_code)]
845 struct DummyReq;
846
847 #[derive(Deserialize, Debug, thiserror::Error)]
848 #[error("{0}")]
849 struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>);
850
851 impl IntoStatic for DummyErr<'_> {
852 type Output = DummyErr<'static>;
853 fn into_static(self) -> Self::Output {
854 DummyErr(self.0.into_static())
855 }
856 }
857
858 struct DummyResp;
859
860 impl XrpcResp for DummyResp {
861 const NSID: &'static str = "test.dummy";
862 const ENCODING: &'static str = "application/json";
863 type Output<'de> = ();
864 type Err<'de> = DummyErr<'de>;
865 }
866
867 impl XrpcRequest for DummyReq {
868 const NSID: &'static str = "test.dummy";
869 const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json");
870 type Response = DummyResp;
871 }
872
873 #[test]
874 fn generic_error_carries_context() {
875 let body = serde_json::json!({"error":"InvalidRequest","message":"missing"});
876 let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
877 let resp: Response<DummyResp> = Response::new(buf, StatusCode::BAD_REQUEST);
878 match resp.parse().unwrap_err() {
879 XrpcError::Generic(g) => {
880 assert_eq!(g.error.as_str(), "InvalidRequest");
881 assert_eq!(g.message.as_deref(), Some("missing"));
882 assert_eq!(g.nsid, DummyResp::NSID);
883 assert_eq!(g.method, ""); // method info only on request
884 assert_eq!(g.http_status, StatusCode::BAD_REQUEST);
885 }
886 other => panic!("unexpected: {other:?}"),
887 }
888 }
889
890 #[test]
891 fn auth_error_mapping() {
892 for (code, expect) in [
893 ("ExpiredToken", AuthError::TokenExpired),
894 ("InvalidToken", AuthError::InvalidToken),
895 ] {
896 let body = serde_json::json!({"error": code});
897 let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
898 let resp: Response<DummyResp> = Response::new(buf, StatusCode::UNAUTHORIZED);
899 match resp.parse().unwrap_err() {
900 XrpcError::Auth(e) => match (e, expect) {
901 (AuthError::TokenExpired, AuthError::TokenExpired) => {}
902 (AuthError::InvalidToken, AuthError::InvalidToken) => {}
903 other => panic!("mismatch: {other:?}"),
904 },
905 other => panic!("unexpected: {other:?}"),
906 }
907 }
908 }
909
910 #[test]
911 fn no_double_slash_in_path() {
912 #[derive(Serialize, Deserialize)]
913 struct Req;
914 #[derive(Deserialize, Debug, thiserror::Error)]
915 #[error("{0}")]
916 struct Err<'a>(#[serde(borrow)] CowStr<'a>);
917 impl IntoStatic for Err<'_> {
918 type Output = Err<'static>;
919 fn into_static(self) -> Self::Output {
920 Err(self.0.into_static())
921 }
922 }
923 struct Resp;
924 impl XrpcResp for Resp {
925 const NSID: &'static str = "com.example.test";
926 const ENCODING: &'static str = "application/json";
927 type Output<'de> = ();
928 type Err<'de> = Err<'de>;
929 }
930 impl XrpcRequest for Req {
931 const NSID: &'static str = "com.example.test";
932 const METHOD: XrpcMethod = XrpcMethod::Query;
933 type Response = Resp;
934 }
935
936 let opts = CallOptions::default();
937 for base in [
938 Url::parse("https://pds").unwrap(),
939 Url::parse("https://pds/").unwrap(),
940 Url::parse("https://pds/base/").unwrap(),
941 ] {
942 let req = build_http_request(&base, &Req, &opts).unwrap();
943 let uri = req.uri().to_string();
944 assert!(uri.contains("/xrpc/com.example.test"));
945 assert!(!uri.contains("//xrpc"));
946 }
947 }
948}