1#[cfg(feature = "streaming")]
14pub mod streaming;
15
16pub mod atproto;
18
19use alloc::borrow::ToOwned;
20use alloc::boxed::Box;
21use alloc::string::{String, ToString};
22use alloc::vec::Vec;
23use ipld_core::ipld::Ipld;
24#[cfg(feature = "streaming")]
25pub use streaming::{
26 StreamingResponse, XrpcProcedureSend, XrpcProcedureStream, XrpcResponseStream, XrpcStreamResp,
27};
28
29#[cfg(feature = "websocket")]
30pub mod subscription;
31
32#[cfg(feature = "streaming")]
33use crate::StreamError;
34use crate::error::DecodeError;
35use crate::http_client::HttpClient;
36#[cfg(feature = "streaming")]
37use crate::http_client::HttpClientExt;
38use crate::types::value::Data;
39use crate::{AuthorizationToken, error::AuthError};
40use crate::{CowStr, error::XrpcResult};
41use crate::{IntoStatic, types::value::RawData};
42use bytes::Bytes;
43use core::error::Error;
44use core::fmt::{self, Debug};
45use core::marker::PhantomData;
46use http::{
47 HeaderName, HeaderValue, Request, StatusCode,
48 header::{AUTHORIZATION, CONTENT_TYPE},
49};
50use serde::{Deserialize, Serialize};
51use smol_str::SmolStr;
52
53use crate::deps::fluent_uri::Uri;
54#[cfg(feature = "websocket")]
55pub use subscription::{
56 BasicSubscriptionClient, MessageEncoding, SubscriptionCall, SubscriptionClient,
57 SubscriptionEndpoint, SubscriptionExt, SubscriptionOptions, SubscriptionResp,
58 SubscriptionStream, TungsteniteSubscriptionClient, XrpcSubscription,
59};
60
61pub fn normalize_base_uri(uri: Uri<String>) -> Uri<String> {
68 let s = uri.as_str();
69 if s.ends_with('/') && s.len() > 1 {
70 let trimmed = s.trim_end_matches('/');
71 Uri::parse(trimmed.to_string())
73 .expect("trimming trailing slash from valid URI yields valid URI")
74 } else {
75 uri
76 }
77}
78
79#[derive(Debug, thiserror::Error)]
81#[cfg_attr(feature = "std", derive(miette::Diagnostic))]
82#[non_exhaustive]
83pub enum EncodeError {
84 #[error("Failed to serialize query: {0}")]
86 Query(
87 #[from]
88 #[source]
89 serde_html_form::ser::Error,
90 ),
91 #[error("Failed to serialize JSON: {0}")]
93 Json(
94 #[from]
95 #[source]
96 serde_json::Error,
97 ),
98 #[error("Encoding error: {0}")]
100 Other(String),
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub enum XrpcMethod {
106 Query,
108 Procedure(&'static str),
110}
111
112impl XrpcMethod {
113 pub const fn as_str(&self) -> &'static str {
115 match self {
116 Self::Query => "GET",
117 Self::Procedure(_) => "POST",
118 }
119 }
120
121 pub const fn body_encoding(&self) -> Option<&'static str> {
123 match self {
124 Self::Query => None,
125 Self::Procedure(enc) => Some(enc),
126 }
127 }
128}
129
130pub trait XrpcRequest: Serialize {
137 const NSID: &'static str;
139
140 const METHOD: XrpcMethod;
142
143 type Response: XrpcResp;
145
146 fn encode_body(&self) -> Result<Vec<u8>, EncodeError> {
150 Ok(serde_json::to_vec(self)?)
151 }
152
153 fn decode_body<'de>(body: &'de [u8]) -> Result<Box<Self>, DecodeError>
157 where
158 Self: Deserialize<'de>,
159 {
160 let body: Self = serde_json::from_slice(body)?;
161
162 Ok(Box::new(body))
163 }
164}
165
166pub trait XrpcResp {
170 const NSID: &'static str;
172
173 const ENCODING: &'static str;
175
176 type Output<'de>: Serialize + Deserialize<'de> + IntoStatic;
178
179 type Err<'de>: Error + Deserialize<'de> + Serialize + IntoStatic;
181
182 fn encode_output(output: &Self::Output<'_>) -> Result<Vec<u8>, EncodeError> {
184 Ok(serde_json::to_vec(output)?)
185 }
186
187 fn decode_output<'de>(body: &'de [u8]) -> core::result::Result<Self::Output<'de>, DecodeError>
191 where
192 Self::Output<'de>: Deserialize<'de>,
193 {
194 let body = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?;
195 Ok(body)
196 }
197}
198
199pub trait XrpcEndpoint {
207 const PATH: &'static str;
209 const METHOD: XrpcMethod;
211 type Request<'de>: XrpcRequest + Deserialize<'de> + IntoStatic;
213 type Response: XrpcResp;
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
219pub struct GenericError<'a>(#[serde(borrow)] Data<'a>);
220
221impl<'de> fmt::Display for GenericError<'de> {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 self.0.fmt(f)
224 }
225}
226
227impl Error for GenericError<'_> {}
228
229impl IntoStatic for GenericError<'_> {
230 type Output = GenericError<'static>;
231 fn into_static(self) -> Self::Output {
232 GenericError(self.0.into_static())
233 }
234}
235
236#[derive(Debug, Default, Clone)]
238pub struct CallOptions<'a> {
239 pub auth: Option<AuthorizationToken<'a>>,
241 pub atproto_proxy: Option<CowStr<'a>>,
243 pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
245 pub extra_headers: Vec<(HeaderName, HeaderValue)>,
247}
248
249impl IntoStatic for CallOptions<'_> {
250 type Output = CallOptions<'static>;
251
252 fn into_static(self) -> Self::Output {
253 CallOptions {
254 auth: self.auth.map(|auth| auth.into_static()),
255 atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()),
256 atproto_accept_labelers: self
257 .atproto_accept_labelers
258 .map(|labelers| labelers.into_static()),
259 extra_headers: self.extra_headers,
260 }
261 }
262}
263
264pub trait XrpcExt: HttpClient {
281 fn xrpc<'a>(&'a self, base: Uri<String>) -> XrpcCall<'a, Self>
283 where
284 Self: Sized,
285 {
286 XrpcCall {
287 client: self,
288 base,
289 opts: CallOptions::default(),
290 }
291 }
292}
293
294impl<T: HttpClient> XrpcExt for T {}
295
296pub type XrpcResponse<R> = Response<<R as XrpcRequest>::Response>;
298
299#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
301pub trait XrpcClient: HttpClient {
302 fn base_uri(&self) -> impl Future<Output = Uri<String>>;
304
305 fn set_base_uri(&self, uri: Uri<String>) -> impl Future<Output = ()> {
309 let _ = uri;
310 async {}
311 }
312
313 fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
315 async { CallOptions::default() }
316 }
317
318 fn set_opts(&self, opts: CallOptions) -> impl Future<Output = ()> {
320 let _ = opts;
321 async {}
322 }
323
324 #[cfg(not(target_arch = "wasm32"))]
326 fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
327 where
328 R: XrpcRequest + Send + Sync,
329 <R as XrpcRequest>::Response: Send + Sync,
330 Self: Sync;
331
332 #[cfg(target_arch = "wasm32")]
334 fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
335 where
336 R: XrpcRequest + Send + Sync,
337 <R as XrpcRequest>::Response: Send + Sync;
338
339 #[cfg(not(target_arch = "wasm32"))]
341 fn send_with_opts<R>(
342 &self,
343 request: R,
344 opts: CallOptions<'_>,
345 ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
346 where
347 R: XrpcRequest + Send + Sync,
348 <R as XrpcRequest>::Response: Send + Sync,
349 Self: Sync;
350
351 #[cfg(target_arch = "wasm32")]
353 fn send_with_opts<R>(
354 &self,
355 request: R,
356 opts: CallOptions<'_>,
357 ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
358 where
359 R: XrpcRequest + Send + Sync,
360 <R as XrpcRequest>::Response: Send + Sync;
361}
362
363#[cfg(feature = "streaming")]
365pub trait XrpcStreamingClient: XrpcClient + HttpClientExt {
366 #[cfg(not(target_arch = "wasm32"))]
368 fn download<R>(
369 &self,
370 request: R,
371 ) -> impl Future<Output = Result<StreamingResponse, StreamError>> + Send
372 where
373 R: XrpcRequest + Send + Sync,
374 <R as XrpcRequest>::Response: Send + Sync,
375 Self: Sync;
376
377 #[cfg(target_arch = "wasm32")]
379 fn download<R>(
380 &self,
381 request: R,
382 ) -> impl Future<Output = Result<StreamingResponse, StreamError>>
383 where
384 R: XrpcRequest + Send + Sync,
385 <R as XrpcRequest>::Response: Send + Sync;
386
387 #[cfg(not(target_arch = "wasm32"))]
389 fn stream<S>(
390 &self,
391 stream: XrpcProcedureSend<S::Frame<'static>>,
392 ) -> impl Future<
393 Output = Result<
394 XrpcResponseStream<
395 <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>,
396 >,
397 StreamError,
398 >,
399 >
400 where
401 S: XrpcProcedureStream + 'static,
402 <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>: XrpcStreamResp,
403 Self: Sync;
404
405 #[cfg(target_arch = "wasm32")]
407 fn stream<S>(
408 &self,
409 stream: XrpcProcedureSend<S::Frame<'static>>,
410 ) -> impl Future<
411 Output = Result<
412 XrpcResponseStream<
413 <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>,
414 >,
415 StreamError,
416 >,
417 >
418 where
419 S: XrpcProcedureStream + 'static,
420 <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>: XrpcStreamResp;
421}
422
423pub struct XrpcCall<'a, C: HttpClient> {
445 pub(crate) client: &'a C,
446 pub(crate) base: Uri<String>,
447 pub(crate) opts: CallOptions<'a>,
448}
449
450impl<'a, C: HttpClient> XrpcCall<'a, C> {
451 pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
453 self.opts.auth = Some(token);
454 self
455 }
456 pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
458 self.opts.atproto_proxy = Some(proxy);
459 self
460 }
461 pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
463 self.opts.atproto_accept_labelers = Some(labelers);
464 self
465 }
466 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
468 self.opts.extra_headers.push((name, value));
469 self
470 }
471 pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
473 self.opts = opts;
474 self
475 }
476
477 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self, request), fields(nsid = R::NSID)))]
486 pub async fn send<R>(self, request: &R) -> XrpcResult<Response<<R as XrpcRequest>::Response>>
487 where
488 R: XrpcRequest,
489 <R as XrpcRequest>::Response: Send + Sync,
490 {
491 let http_request = build_http_request(&self.base, request, &self.opts)?;
492
493 let http_response = self
494 .client
495 .send_http(http_request)
496 .await
497 .map_err(|e| crate::error::ClientError::transport(e).for_nsid(R::NSID))?;
498
499 process_response(http_response)
500 }
501}
502
503#[inline]
507pub fn process_response<Resp>(http_response: http::Response<Vec<u8>>) -> XrpcResult<Response<Resp>>
508where
509 Resp: XrpcResp,
510{
511 let status = http_response.status();
512
513 #[allow(deprecated)]
516 if status.as_u16() == 401 {
517 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
518 return Err(
519 crate::error::ClientError::auth(crate::error::AuthError::Other(hv.clone()))
520 .for_nsid(Resp::NSID),
521 );
522 }
523 }
524 let buffer = Bytes::from(http_response.into_body());
525
526 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
527 return Err(crate::error::ClientError::from(crate::error::HttpError {
528 status,
529 body: Some(buffer),
530 })
531 .for_nsid(Resp::NSID));
532 }
533
534 Ok(Response::new(buffer, status))
535}
536
537pub enum Header {
539 ContentType,
541 Authorization,
543 AtprotoProxy,
547 AtprotoAcceptLabelers,
549}
550
551impl From<Header> for HeaderName {
552 fn from(value: Header) -> Self {
553 match value {
554 Header::ContentType => CONTENT_TYPE,
555 Header::Authorization => AUTHORIZATION,
556 Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"),
557 Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"),
558 }
559 }
560}
561
562fn xrpc_endpoint_uri(
571 base: &Uri<String>,
572 nsid: &str,
573 query: Option<&str>,
574) -> XrpcResult<Uri<String>> {
575 use crate::error::ClientError;
576
577 let base_path = base.path().as_str().trim_end_matches('/');
578
579 let capacity = base.scheme().as_str().len()
581 + 3 + base.authority().map(|a| a.as_str().len()).unwrap_or(0)
583 + base_path.len()
584 + 6 + nsid.len()
586 + query.map(|q| q.len() + 1).unwrap_or(0); let mut uri_str = String::with_capacity(capacity);
590 uri_str.push_str(base.scheme().as_str());
591 uri_str.push_str("://");
592
593 if let Some(authority) = base.authority() {
594 uri_str.push_str(authority.as_str());
595 }
596
597 uri_str.push_str(base_path);
598 uri_str.push_str("/xrpc/");
599 uri_str.push_str(nsid);
600
601 if let Some(q) = query {
602 uri_str.push('?');
603 uri_str.push_str(q);
604 }
605
606 Uri::parse(uri_str)
607 .map(|u| u.to_owned())
608 .map_err(|_| ClientError::invalid_request("Failed to construct XRPC endpoint URI"))
609}
610
611pub fn build_http_request<'s, R>(
613 base: &Uri<String>,
614 req: &R,
615 opts: &CallOptions<'_>,
616) -> XrpcResult<Request<Vec<u8>>>
617where
618 R: XrpcRequest,
619{
620 use crate::error::ClientError;
621
622 let query_string = if let XrpcMethod::Query = <R as XrpcRequest>::METHOD {
624 let qs = serde_html_form::to_string(&req).map_err(|e| {
625 ClientError::invalid_request(format!("Failed to serialize query: {}", e))
626 })?;
627 if !qs.is_empty() { Some(qs) } else { None }
628 } else {
629 None
630 };
631
632 let uri = xrpc_endpoint_uri(base, <R as XrpcRequest>::NSID, query_string.as_deref())?;
634
635 let method = match <R as XrpcRequest>::METHOD {
636 XrpcMethod::Query => http::Method::GET,
637 XrpcMethod::Procedure(_) => http::Method::POST,
638 };
639
640 let mut builder = Request::builder().method(method).uri(uri.as_str());
641
642 let has_content_type = opts
643 .extra_headers
644 .iter()
645 .any(|(name, _)| name == CONTENT_TYPE);
646
647 if let XrpcMethod::Procedure(encoding) = <R as XrpcRequest>::METHOD {
648 if !has_content_type {
650 builder = builder.header(Header::ContentType, encoding);
651 }
652 }
653 let output_encoding = <R::Response as XrpcResp>::ENCODING;
654 builder = builder.header(http::header::ACCEPT, output_encoding);
655
656 if let Some(token) = &opts.auth {
657 let hv = match token {
658 AuthorizationToken::Bearer(t) => {
659 HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
660 }
661 AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())),
662 }
663 .map_err(|e| ClientError::invalid_request(format!("Invalid authorization token: {}", e)))?;
664 builder = builder.header(Header::Authorization, hv);
665 }
666
667 if let Some(proxy) = &opts.atproto_proxy {
668 builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
669 }
670 if let Some(labelers) = &opts.atproto_accept_labelers {
671 if !labelers.is_empty() {
672 let joined = labelers
673 .iter()
674 .map(|s| s.as_ref())
675 .collect::<Vec<_>>()
676 .join(", ");
677 builder = builder.header(Header::AtprotoAcceptLabelers, joined);
678 }
679 }
680 for (name, value) in &opts.extra_headers {
681 builder = builder.header(name, value);
682 }
683
684 let body = if let XrpcMethod::Procedure(_) = R::METHOD {
685 req.encode_body()
686 .map_err(|e| ClientError::invalid_request(format!("Failed to encode body: {}", e)))?
687 } else {
688 vec![]
689 };
690
691 builder
692 .body(body)
693 .map_err(|e| ClientError::invalid_request(format!("Failed to build request: {}", e)))
694}
695
696pub struct Response<Resp>
701where
702 Resp: XrpcResp, {
704 _marker: PhantomData<fn() -> Resp>,
705 buffer: Bytes,
706 status: StatusCode,
707}
708
709impl<R> Response<R>
710where
711 R: XrpcResp,
712{
713 pub fn new(buffer: Bytes, status: StatusCode) -> Self {
715 Self {
716 buffer,
717 status,
718 _marker: PhantomData,
719 }
720 }
721
722 pub fn status(&self) -> StatusCode {
724 self.status
725 }
726
727 pub fn buffer(&self) -> &Bytes {
729 &self.buffer
730 }
731
732 pub fn parse<'s>(&'s self) -> Result<RespOutput<'s, R>, XrpcError<RespErr<'s, R>>> {
734 if self.status.is_success() {
736 match R::decode_output(&self.buffer) {
737 Ok(output) => Ok(output),
738 Err(e) => Err(XrpcError::Decode(e)),
739 }
740 } else if self.status.as_u16() == 400 {
742 match serde_json::from_slice::<R::Err<'_>>(&self.buffer) {
743 Ok(error) => {
744 use alloc::string::ToString;
745 if error.to_string().contains("InvalidToken") {
746 Err(XrpcError::Auth(AuthError::InvalidToken))
747 } else if error.to_string().contains("ExpiredToken") {
748 Err(XrpcError::Auth(AuthError::TokenExpired))
749 } else {
750 Err(XrpcError::Xrpc(error))
751 }
752 }
753 Err(_) => {
754 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
756 Ok(mut generic) => {
757 generic.nsid = R::NSID;
758 generic.method = ""; generic.http_status = self.status;
760 match generic.error.as_str() {
762 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
763 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
764 _ => Err(XrpcError::Generic(generic)),
765 }
766 }
767 Err(e) => Err(XrpcError::Decode(DecodeError::Json(e))),
768 }
769 }
770 }
771 } else {
773 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
774 Ok(mut generic) => {
775 generic.nsid = R::NSID;
776 generic.method = ""; generic.http_status = self.status;
778 match generic.error.as_str() {
779 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
780 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
781 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
782 }
783 }
784 Err(e) => Err(XrpcError::Decode(DecodeError::Json(e))),
785 }
786 }
787 }
788
789 pub fn parse_data<'s>(&'s self) -> Result<Data<'s>, XrpcError<RespErr<'s, R>>> {
793 if self.status.is_success() {
795 match serde_json::from_slice::<_>(&self.buffer) {
796 Ok(output) => Ok(output),
797 Err(_) => {
798 if let Ok(data) = serde_ipld_dagcbor::from_slice::<Ipld>(&self.buffer) {
799 if let Ok(data) = Data::from_cbor(&data) {
800 Ok(data.into_static())
801 } else {
802 Ok(Data::Bytes(self.buffer.clone()))
803 }
804 } else {
805 Ok(Data::Bytes(self.buffer.clone()))
806 }
807 }
808 }
809 } else if self.status.as_u16() == 400 {
811 match serde_json::from_slice::<R::Err<'_>>(&self.buffer) {
812 Ok(error) => {
813 use alloc::string::ToString;
814 if error.to_string().contains("InvalidToken") {
815 Err(XrpcError::Auth(AuthError::InvalidToken))
816 } else if error.to_string().contains("ExpiredToken") {
817 Err(XrpcError::Auth(AuthError::TokenExpired))
818 } else {
819 Err(XrpcError::Xrpc(error))
820 }
821 }
822 Err(_) => {
823 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
825 Ok(mut generic) => {
826 generic.nsid = R::NSID;
827 generic.method = ""; generic.http_status = self.status;
829 match generic.error.as_str() {
831 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
832 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
833 _ => Err(XrpcError::Generic(generic)),
834 }
835 }
836 Err(e) => Err(XrpcError::Decode(DecodeError::Json(e))),
837 }
838 }
839 }
840 } else {
842 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
843 Ok(mut generic) => {
844 generic.nsid = R::NSID;
845 generic.method = ""; generic.http_status = self.status;
847 match generic.error.as_str() {
848 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
849 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
850 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
851 }
852 }
853 Err(e) => Err(XrpcError::Decode(DecodeError::Json(e))),
854 }
855 }
856 }
857
858 pub fn parse_raw<'s>(&'s self) -> Result<RawData<'s>, XrpcError<RespErr<'s, R>>> {
862 if self.status.is_success() {
864 match serde_json::from_slice::<_>(&self.buffer) {
865 Ok(output) => Ok(output),
866 Err(_) => {
867 if let Ok(data) = serde_ipld_dagcbor::from_slice::<Ipld>(&self.buffer) {
868 if let Ok(data) = RawData::from_cbor(&data) {
869 Ok(data.into_static())
870 } else {
871 Ok(RawData::Bytes(self.buffer.clone()))
872 }
873 } else {
874 Ok(RawData::Bytes(self.buffer.clone()))
875 }
876 }
877 }
878 } else if self.status.as_u16() == 400 {
880 match serde_json::from_slice::<R::Err<'_>>(&self.buffer) {
881 Ok(error) => {
882 use alloc::string::ToString;
883 if error.to_string().contains("InvalidToken") {
884 Err(XrpcError::Auth(AuthError::InvalidToken))
885 } else if error.to_string().contains("ExpiredToken") {
886 Err(XrpcError::Auth(AuthError::TokenExpired))
887 } else {
888 Err(XrpcError::Xrpc(error))
889 }
890 }
891 Err(_) => {
892 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
894 Ok(mut generic) => {
895 generic.nsid = R::NSID;
896 generic.method = ""; generic.http_status = self.status;
898 match generic.error.as_str() {
900 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
901 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
902 _ => Err(XrpcError::Generic(generic)),
903 }
904 }
905 Err(e) => Err(XrpcError::Decode(DecodeError::Json(e))),
906 }
907 }
908 }
909 } else {
911 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
912 Ok(mut generic) => {
913 generic.nsid = R::NSID;
914 generic.method = ""; generic.http_status = self.status;
916 match generic.error.as_str() {
917 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
918 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
919 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
920 }
921 }
922 Err(e) => Err(XrpcError::Decode(DecodeError::Json(e))),
923 }
924 }
925 }
926
927 pub fn transmute<NEW: XrpcResp>(self) -> Response<NEW> {
939 Response {
940 buffer: self.buffer,
941 status: self.status,
942 _marker: PhantomData,
943 }
944 }
945}
946
947pub type RespOutput<'a, Resp> = <Resp as XrpcResp>::Output<'a>;
949pub type RespErr<'a, Resp> = <Resp as XrpcResp>::Err<'a>;
951
952impl<R> Response<R>
953where
954 R: XrpcResp,
955{
956 pub fn into_output(self) -> Result<RespOutput<'static, R>, XrpcError<RespErr<'static, R>>>
958 where
959 for<'a> RespOutput<'a, R>: IntoStatic<Output = RespOutput<'static, R>>,
960 for<'a> RespErr<'a, R>: IntoStatic<Output = RespErr<'static, R>>,
961 {
962 fn parse_error<'b, R: XrpcResp>(buffer: &'b [u8]) -> Result<R::Err<'b>, serde_json::Error> {
963 serde_json::from_slice(buffer)
964 }
965
966 if self.status.is_success() {
968 match R::decode_output(&self.buffer) {
969 Ok(output) => Ok(output.into_static()),
970 Err(e) => Err(XrpcError::Decode(e)),
971 }
972 } else if self.status.as_u16() == 400 {
974 let error = match parse_error::<R>(&self.buffer) {
975 Ok(error) => {
976 use alloc::string::ToString;
977 if error.to_string().contains("InvalidToken") {
978 XrpcError::Auth(AuthError::InvalidToken)
979 } else if error.to_string().contains("ExpiredToken") {
980 XrpcError::Auth(AuthError::TokenExpired)
981 } else {
982 XrpcError::Xrpc(error)
983 }
984 }
985 Err(_) => {
986 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
988 Ok(mut generic) => {
989 generic.nsid = R::NSID;
990 generic.method = ""; generic.http_status = self.status;
992 match generic.error.as_ref() {
994 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired),
995 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken),
996 _ => XrpcError::Generic(generic),
997 }
998 }
999 Err(e) => XrpcError::Decode(DecodeError::Json(e)),
1000 }
1001 }
1002 };
1003 Err(error.into_static())
1004 } else {
1006 let error: XrpcError<<R as XrpcResp>::Err<'_>> =
1007 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
1008 Ok(mut generic) => {
1009 let status = self.status;
1010 generic.nsid = R::NSID;
1011 generic.method = ""; generic.http_status = status;
1013 match generic.error.as_ref() {
1014 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired),
1015 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken),
1016 _ => XrpcError::Auth(AuthError::NotAuthenticated),
1017 }
1018 }
1019 Err(e) => XrpcError::Decode(DecodeError::Json(e)),
1020 };
1021
1022 Err(error.into_static())
1023 }
1024 }
1025}
1026
1027#[derive(Debug, Clone, Deserialize, Serialize)]
1031pub struct GenericXrpcError {
1032 pub error: SmolStr,
1034 pub message: Option<SmolStr>,
1036 #[serde(skip)]
1038 pub nsid: &'static str,
1039 #[serde(skip)]
1041 pub method: &'static str,
1042 #[serde(skip)]
1044 pub http_status: StatusCode,
1045}
1046
1047impl core::fmt::Display for GenericXrpcError {
1048 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1049 if let Some(msg) = &self.message {
1050 write!(
1051 f,
1052 "{}: {} (nsid={}, method={}, status={})",
1053 self.error, msg, self.nsid, self.method, self.http_status
1054 )
1055 } else {
1056 write!(
1057 f,
1058 "{} (nsid={}, method={}, status={})",
1059 self.error, self.nsid, self.method, self.http_status
1060 )
1061 }
1062 }
1063}
1064
1065impl IntoStatic for GenericXrpcError {
1066 type Output = Self;
1067
1068 fn into_static(self) -> Self::Output {
1069 self
1070 }
1071}
1072
1073impl core::error::Error for GenericXrpcError {}
1074
1075#[derive(Debug, thiserror::Error)]
1080#[cfg_attr(feature = "std", derive(miette::Diagnostic))]
1081#[non_exhaustive]
1082pub enum XrpcError<E: core::error::Error + IntoStatic> {
1083 #[error("XRPC error: {0}")]
1085 #[cfg_attr(feature = "std", diagnostic(code(jacquard_common::xrpc::typed)))]
1086 Xrpc(E),
1087
1088 #[error("Authentication error: {0}")]
1090 #[cfg_attr(feature = "std", diagnostic(code(jacquard_common::xrpc::auth)))]
1091 Auth(#[from] AuthError),
1092
1093 #[error("XRPC error: {0}")]
1095 #[cfg_attr(feature = "std", diagnostic(code(jacquard_common::xrpc::generic)))]
1096 Generic(GenericXrpcError),
1097
1098 #[error("Failed to decode response: {0}")]
1100 #[cfg_attr(feature = "std", diagnostic(code(jacquard_common::xrpc::decode)))]
1101 Decode(#[from] DecodeError),
1102}
1103
1104impl<E> IntoStatic for XrpcError<E>
1105where
1106 E: core::error::Error + IntoStatic,
1107 E::Output: core::error::Error + IntoStatic,
1108 <E as IntoStatic>::Output: core::error::Error + IntoStatic,
1109{
1110 type Output = XrpcError<E::Output>;
1111 fn into_static(self) -> Self::Output {
1112 match self {
1113 XrpcError::Xrpc(e) => XrpcError::Xrpc(e.into_static()),
1114 XrpcError::Auth(e) => XrpcError::Auth(e.into_static()),
1115 XrpcError::Generic(e) => XrpcError::Generic(e),
1116 XrpcError::Decode(e) => XrpcError::Decode(e),
1117 }
1118 }
1119}
1120
1121impl<E> Serialize for XrpcError<E>
1122where
1123 E: core::error::Error + IntoStatic + Serialize,
1124{
1125 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1126 where
1127 S: serde::Serializer,
1128 {
1129 use serde::ser::SerializeStruct;
1130
1131 match self {
1132 XrpcError::Xrpc(e) => e.serialize(serializer),
1134 XrpcError::Generic(g) => g.serialize(serializer),
1136 XrpcError::Auth(auth) => {
1138 let mut state = serializer.serialize_struct("XrpcError", 2)?;
1139 let (error, message) = match auth {
1140 AuthError::TokenExpired => ("ExpiredToken", Some("Access token has expired")),
1141 AuthError::InvalidToken => {
1142 ("InvalidToken", Some("Access token is invalid or malformed"))
1143 }
1144 AuthError::RefreshFailed => {
1145 ("RefreshFailed", Some("Token refresh request failed"))
1146 }
1147 AuthError::NotAuthenticated => (
1148 "AuthenticationRequired",
1149 Some("Request requires authentication but none was provided"),
1150 ),
1151 AuthError::DpopProofFailed => {
1152 ("DpopProofFailed", Some("DPoP proof construction failed"))
1153 }
1154 AuthError::DpopNonceFailed => {
1155 ("DpopNonceFailed", Some("DPoP nonce negotiation failed"))
1156 }
1157 AuthError::Other(hv) => {
1158 let msg = hv.to_str().unwrap_or("[non-utf8 header]");
1159 ("AuthenticationError", Some(msg))
1160 }
1161 };
1162 state.serialize_field("error", error)?;
1163 if let Some(msg) = message {
1164 state.serialize_field("message", msg)?;
1165 }
1166 state.end()
1167 }
1168 XrpcError::Decode(decode_err) => {
1169 let mut state = serializer.serialize_struct("XrpcError", 2)?;
1170 state.serialize_field("error", "ResponseDecodeError")?;
1171 let msg = format!("{:?}", decode_err);
1173 state.serialize_field("message", &msg)?;
1174 state.end()
1175 }
1176 }
1177 }
1178}
1179
1180#[cfg(feature = "streaming")]
1181impl<'a, C: HttpClient + HttpClientExt> XrpcCall<'a, C> {
1182 pub async fn download<R>(self, request: &R) -> Result<StreamingResponse, StreamError>
1186 where
1187 R: XrpcRequest,
1188 <R as XrpcRequest>::Response: Send + Sync,
1189 {
1190 let http_request =
1191 build_http_request(&self.base, request, &self.opts).map_err(StreamError::transport)?;
1192
1193 let http_response = self
1194 .client
1195 .send_http_streaming(http_request)
1196 .await
1197 .map_err(StreamError::transport)?;
1198 let (parts, body) = http_response.into_parts();
1199
1200 Ok(StreamingResponse::new(parts, body))
1201 }
1202
1203 pub async fn stream<S>(
1208 self,
1209 stream: XrpcProcedureSend<S::Frame<'static>>,
1210 ) -> Result<XrpcResponseStream<<S::Response as XrpcStreamResp>::Frame<'static>>, StreamError>
1211 where
1212 S: XrpcProcedureStream + 'static,
1213 <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>: XrpcStreamResp,
1214 {
1215 use futures::TryStreamExt;
1216
1217 let uri = xrpc_endpoint_uri(&self.base, <S::Request as XrpcRequest>::NSID, None).map_err(
1218 |e| StreamError::protocol(format!("Failed to construct endpoint URI: {}", e)),
1219 )?;
1220
1221 let mut builder = http::Request::post(uri.as_str());
1222
1223 if let Some(token) = &self.opts.auth {
1224 let hv = match token {
1225 AuthorizationToken::Bearer(t) => {
1226 HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
1227 }
1228 AuthorizationToken::Dpop(t) => {
1229 HeaderValue::from_str(&format!("DPoP {}", t.as_ref()))
1230 }
1231 }
1232 .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?;
1233 builder = builder.header(Header::Authorization, hv);
1234 }
1235
1236 if let Some(proxy) = &self.opts.atproto_proxy {
1237 builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
1238 }
1239 if let Some(labelers) = &self.opts.atproto_accept_labelers {
1240 if !labelers.is_empty() {
1241 let joined = labelers
1242 .iter()
1243 .map(|s| s.as_ref())
1244 .collect::<Vec<_>>()
1245 .join(", ");
1246 builder = builder.header(Header::AtprotoAcceptLabelers, joined);
1247 }
1248 }
1249
1250 for (name, value) in &self.opts.extra_headers {
1251 builder = builder.header(name, value);
1252 }
1253
1254 let (parts, _) = builder
1255 .body(())
1256 .map_err(|e| StreamError::protocol(e.to_string()))?
1257 .into_parts();
1258
1259 let body_stream = Box::pin(stream.0.map_ok(|f| f.buffer));
1260
1261 let resp = self
1262 .client
1263 .send_http_bidirectional(parts, body_stream)
1264 .await
1265 .map_err(StreamError::transport)?;
1266
1267 let (parts, body) = resp.into_parts();
1268
1269 Ok(XrpcResponseStream::<
1270 <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>,
1271 >::from_typed_parts(parts, body))
1272 }
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::*;
1278 use serde::{Deserialize, Serialize};
1279
1280 #[derive(Serialize, Deserialize)]
1281 #[allow(dead_code)]
1282 struct DummyReq;
1283
1284 #[derive(Deserialize, Serialize, Debug, thiserror::Error)]
1285 #[error("{0}")]
1286 struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>);
1287
1288 impl IntoStatic for DummyErr<'_> {
1289 type Output = DummyErr<'static>;
1290 fn into_static(self) -> Self::Output {
1291 DummyErr(self.0.into_static())
1292 }
1293 }
1294
1295 struct DummyResp;
1296
1297 impl XrpcResp for DummyResp {
1298 const NSID: &'static str = "test.dummy";
1299 const ENCODING: &'static str = "application/json";
1300 type Output<'de> = ();
1301 type Err<'de> = DummyErr<'de>;
1302 }
1303
1304 impl XrpcRequest for DummyReq {
1305 const NSID: &'static str = "test.dummy";
1306 const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json");
1307 type Response = DummyResp;
1308 }
1309
1310 #[test]
1311 fn generic_error_carries_context() {
1312 let body = serde_json::json!({"error":"InvalidRequest","message":"missing"});
1313 let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
1314 let resp: Response<DummyResp> = Response::new(buf, StatusCode::BAD_REQUEST);
1315 match resp.parse().unwrap_err() {
1316 XrpcError::Generic(g) => {
1317 assert_eq!(g.error.as_str(), "InvalidRequest");
1318 assert_eq!(g.message.as_deref(), Some("missing"));
1319 assert_eq!(g.nsid, DummyResp::NSID);
1320 assert_eq!(g.method, ""); assert_eq!(g.http_status, StatusCode::BAD_REQUEST);
1322 }
1323 other => panic!("unexpected: {other:?}"),
1324 }
1325 }
1326
1327 #[test]
1328 fn auth_error_mapping() {
1329 for (code, expect) in [
1330 ("ExpiredToken", AuthError::TokenExpired),
1331 ("InvalidToken", AuthError::InvalidToken),
1332 ] {
1333 let body = serde_json::json!({"error": code});
1334 let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
1335 let resp: Response<DummyResp> = Response::new(buf, StatusCode::UNAUTHORIZED);
1336 match resp.parse().unwrap_err() {
1337 XrpcError::Auth(e) => match (e, expect) {
1338 (AuthError::TokenExpired, AuthError::TokenExpired) => {}
1339 (AuthError::InvalidToken, AuthError::InvalidToken) => {}
1340 other => panic!("mismatch: {other:?}"),
1341 },
1342 other => panic!("unexpected: {other:?}"),
1343 }
1344 }
1345 }
1346
1347 #[test]
1348 fn xrpc_uri_construction_basic() {
1349 use crate::alloc::string::ToString;
1350 #[derive(Serialize, Deserialize)]
1351 struct Req;
1352 #[derive(Deserialize, Serialize, Debug, thiserror::Error)]
1353 #[error("{0}")]
1354 struct Err<'a>(#[serde(borrow)] CowStr<'a>);
1355 impl IntoStatic for Err<'_> {
1356 type Output = Err<'static>;
1357 fn into_static(self) -> Self::Output {
1358 Err(self.0.into_static())
1359 }
1360 }
1361 struct Resp;
1362 impl XrpcResp for Resp {
1363 const NSID: &'static str = "com.example.test";
1364 const ENCODING: &'static str = "application/json";
1365 type Output<'de> = ();
1366 type Err<'de> = Err<'de>;
1367 }
1368 impl XrpcRequest for Req {
1369 const NSID: &'static str = "com.example.test";
1370 const METHOD: XrpcMethod = XrpcMethod::Query;
1371 type Response = Resp;
1372 }
1373
1374 let opts = CallOptions::default();
1375
1376 let base1 = Uri::parse("https://pds.example.com")
1378 .expect("URI should be valid")
1379 .to_owned();
1380 let req1 = build_http_request(&base1, &Req, &opts).unwrap();
1381 let uri1 = req1.uri().to_string();
1382 assert!(
1383 uri1.contains("/xrpc/com.example.test"),
1384 "AC1.1: URI {} should contain '/xrpc/com.example.test'",
1385 uri1
1386 );
1387 assert_eq!(
1388 uri1, "https://pds.example.com/xrpc/com.example.test",
1389 "AC1.1: URI should be exact match"
1390 );
1391
1392 let base2 = Uri::parse("https://pds.example.com/base")
1394 .expect("URI should be valid")
1395 .to_owned();
1396 let req2 = build_http_request(&base2, &Req, &opts).unwrap();
1397 let uri2 = req2.uri().to_string();
1398 assert!(
1399 uri2.contains("/base/xrpc/com.example.test"),
1400 "AC1.2: URI {} should contain '/base/xrpc/com.example.test'",
1401 uri2
1402 );
1403 assert_eq!(
1404 uri2, "https://pds.example.com/base/xrpc/com.example.test",
1405 "AC1.2: URI should preserve sub-path"
1406 );
1407
1408 let base_with_slash = Uri::parse("https://pds.example.com/")
1410 .expect("URI should be valid")
1411 .to_owned();
1412 let req_slash = build_http_request(&base_with_slash, &Req, &opts).unwrap();
1413 let uri_slash = req_slash.uri().to_string();
1414 assert!(
1415 !uri_slash.contains("//xrpc"),
1416 "AC1.5: URI {} should not contain '//xrpc'",
1417 uri_slash
1418 );
1419 assert_eq!(
1420 uri_slash, "https://pds.example.com/xrpc/com.example.test",
1421 "AC1.5: URI should handle trailing slash"
1422 );
1423 }
1424
1425 #[test]
1426 fn xrpc_uri_query_parameters() {
1427 use crate::alloc::string::ToString;
1428 use serde::Serialize;
1429
1430 #[derive(Serialize)]
1431 struct QueryReq {
1432 #[serde(skip_serializing_if = "Option::is_none")]
1433 param1: Option<String>,
1434 #[serde(skip_serializing_if = "Option::is_none")]
1435 param2: Option<String>,
1436 }
1437
1438 #[derive(Serialize, Deserialize, Debug, thiserror::Error)]
1439 #[error("test error")]
1440 struct Err;
1441 impl IntoStatic for Err {
1442 type Output = Err;
1443 fn into_static(self) -> Self::Output {
1444 self
1445 }
1446 }
1447
1448 struct Resp;
1449 impl XrpcResp for Resp {
1450 const NSID: &'static str = "com.example.test";
1451 const ENCODING: &'static str = "application/json";
1452 type Output<'de> = ();
1453 type Err<'de> = Err;
1454 }
1455 impl XrpcRequest for QueryReq {
1456 const NSID: &'static str = "com.example.test";
1457 const METHOD: XrpcMethod = XrpcMethod::Query;
1458 type Response = Resp;
1459 }
1460
1461 let opts = CallOptions::default();
1462 let base = Uri::parse("https://pds.example.com")
1463 .expect("URI should be valid")
1464 .to_owned();
1465
1466 let req_with_params = QueryReq {
1468 param1: Some("value1".to_string()),
1469 param2: Some("value2".to_string()),
1470 };
1471 let http_req = build_http_request(&base, &req_with_params, &opts).unwrap();
1472 let uri_str = http_req.uri().to_string();
1473 assert!(
1474 uri_str.contains("?"),
1475 "AC1.3: URI should contain query string"
1476 );
1477 assert!(
1478 uri_str.contains("param1=value1"),
1479 "AC1.3: URI should contain param1"
1480 );
1481 assert!(
1482 uri_str.contains("param2=value2"),
1483 "AC1.3: URI should contain param2"
1484 );
1485
1486 let req_empty_params = QueryReq {
1488 param1: None,
1489 param2: None,
1490 };
1491 let http_req_empty = build_http_request(&base, &req_empty_params, &opts).unwrap();
1492 let uri_str_empty = http_req_empty.uri().to_string();
1493 assert!(
1494 !uri_str_empty.contains("?"),
1495 "AC1.4: URI {} should not contain '?' with empty params",
1496 uri_str_empty
1497 );
1498 assert_eq!(
1499 uri_str_empty, "https://pds.example.com/xrpc/com.example.test",
1500 "AC1.4: URI should have no query string"
1501 );
1502 }
1503
1504 #[test]
1505 fn xrpc_uri_special_characters_in_query() {
1506 use crate::alloc::string::ToString;
1507 use serde::Serialize;
1508
1509 #[derive(Serialize)]
1510 struct QueryReq {
1511 #[serde(skip_serializing_if = "Option::is_none")]
1512 search: Option<String>,
1513 #[serde(skip_serializing_if = "Option::is_none")]
1514 filter: Option<String>,
1515 #[serde(skip_serializing_if = "Option::is_none")]
1516 unicode_param: Option<String>,
1517 }
1518
1519 #[derive(Serialize, Deserialize, Debug, thiserror::Error)]
1520 #[error("test error")]
1521 struct Err;
1522 impl IntoStatic for Err {
1523 type Output = Err;
1524 fn into_static(self) -> Self::Output {
1525 self
1526 }
1527 }
1528
1529 struct Resp;
1530 impl XrpcResp for Resp {
1531 const NSID: &'static str = "com.example.test";
1532 const ENCODING: &'static str = "application/json";
1533 type Output<'de> = ();
1534 type Err<'de> = Err;
1535 }
1536 impl XrpcRequest for QueryReq {
1537 const NSID: &'static str = "com.example.test";
1538 const METHOD: XrpcMethod = XrpcMethod::Query;
1539 type Response = Resp;
1540 }
1541
1542 let opts = CallOptions::default();
1543 let base = Uri::parse("https://pds.example.com")
1544 .expect("URI should be valid")
1545 .to_owned();
1546
1547 let req_spaces = QueryReq {
1549 search: Some("hello world".to_string()),
1550 filter: None,
1551 unicode_param: None,
1552 };
1553 let http_req_spaces = build_http_request(&base, &req_spaces, &opts).unwrap();
1554 let uri_spaces = http_req_spaces.uri().to_string();
1555 assert!(
1556 uri_spaces.contains("search=hello"),
1557 "AC1.3: URI should contain search param"
1558 );
1559 assert!(
1561 uri_spaces.contains("hello+world") || uri_spaces.contains("hello%20world"),
1562 "AC1.3: URI {} should encode space in 'hello world'",
1563 uri_spaces
1564 );
1565
1566 let req_special = QueryReq {
1568 search: Some("a=b&c+d".to_string()),
1569 filter: None,
1570 unicode_param: None,
1571 };
1572 let http_req_special = build_http_request(&base, &req_special, &opts).unwrap();
1573 let uri_special = http_req_special.uri().to_string();
1574 assert!(
1575 uri_special.contains("?"),
1576 "AC1.3: URI should contain query string for special chars"
1577 );
1578 let parsed = Uri::parse(uri_special.clone());
1580 assert!(
1581 parsed.is_ok(),
1582 "AC1.3: URI {} should be parseable by fluent-uri",
1583 uri_special
1584 );
1585
1586 let req_unicode = QueryReq {
1588 search: None,
1589 filter: None,
1590 unicode_param: Some("你好世界".to_string()),
1591 };
1592 let http_req_unicode = build_http_request(&base, &req_unicode, &opts).unwrap();
1593 let uri_unicode = http_req_unicode.uri().to_string();
1594 assert!(
1595 uri_unicode.contains("?"),
1596 "AC1.3: URI should contain query string for unicode"
1597 );
1598 let parsed_unicode = Uri::parse(uri_unicode.clone());
1600 assert!(
1601 parsed_unicode.is_ok(),
1602 "AC1.3: URI {} should be parseable for unicode params",
1603 uri_unicode
1604 );
1605 }
1606
1607 #[test]
1608 fn no_double_slash_in_path() {
1609 use crate::alloc::string::ToString;
1610 #[derive(Serialize, Deserialize)]
1611 struct Req;
1612 #[derive(Deserialize, Serialize, Debug, thiserror::Error)]
1613 #[error("{0}")]
1614 struct Err<'a>(#[serde(borrow)] CowStr<'a>);
1615 impl IntoStatic for Err<'_> {
1616 type Output = Err<'static>;
1617 fn into_static(self) -> Self::Output {
1618 Err(self.0.into_static())
1619 }
1620 }
1621 struct Resp;
1622 impl XrpcResp for Resp {
1623 const NSID: &'static str = "com.example.test";
1624 const ENCODING: &'static str = "application/json";
1625 type Output<'de> = ();
1626 type Err<'de> = Err<'de>;
1627 }
1628 impl XrpcRequest for Req {
1629 const NSID: &'static str = "com.example.test";
1630 const METHOD: XrpcMethod = XrpcMethod::Query;
1631 type Response = Resp;
1632 }
1633
1634 let opts = CallOptions::default();
1635
1636 let base1 = Uri::parse("https://pds")
1638 .expect("URI should be valid")
1639 .to_owned();
1640 let req1 = build_http_request(&base1, &Req, &opts).unwrap();
1641 let uri1 = req1.uri().to_string();
1642 assert!(
1643 !uri1.contains("//xrpc"),
1644 "URI {} should not contain '//xrpc'",
1645 uri1
1646 );
1647
1648 let base2 = Uri::parse("https://pds/base")
1649 .expect("URI should be valid")
1650 .to_owned();
1651 let req2 = build_http_request(&base2, &Req, &opts).unwrap();
1652 let uri2 = req2.uri().to_string();
1653 assert!(
1654 !uri2.contains("//xrpc"),
1655 "URI {} should not contain '//xrpc'",
1656 uri2
1657 );
1658 }
1659}