1use 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#[derive(Debug, thiserror::Error, miette::Diagnostic)]
32pub enum EncodeError {
33 #[error("Failed to serialize query: {0}")]
35 Query(
36 #[from]
37 #[source]
38 serde_html_form::ser::Error,
39 ),
40 #[error("Failed to serialize JSON: {0}")]
42 Json(
43 #[from]
44 #[source]
45 serde_json::Error,
46 ),
47 #[error("Encoding error: {0}")]
49 Other(String),
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum XrpcMethod {
55 Query,
57 Procedure(&'static str),
59}
60
61impl XrpcMethod {
62 pub const fn as_str(&self) -> &'static str {
64 match self {
65 Self::Query => "GET",
66 Self::Procedure(_) => "POST",
67 }
68 }
69
70 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
79pub trait XrpcRequest: Serialize {
86 const NSID: &'static str;
88
89 const METHOD: XrpcMethod;
91
92 type Response: XrpcResp;
94
95 fn encode_body(&self) -> Result<Vec<u8>, EncodeError> {
99 Ok(serde_json::to_vec(self)?)
100 }
101
102 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
115pub trait XrpcResp {
119 const NSID: &'static str;
121
122 const ENCODING: &'static str;
124
125 type Output<'de>: Deserialize<'de> + IntoStatic;
127
128 type Err<'de>: Error + Deserialize<'de> + IntoStatic;
130}
131
132pub trait XrpcEndpoint {
140 const PATH: &'static str;
142 const METHOD: XrpcMethod;
144 type Request<'de>: XrpcRequest + Deserialize<'de> + IntoStatic;
146 type Response: XrpcResp;
148}
149
150#[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#[derive(Debug, Default, Clone)]
171pub struct CallOptions<'a> {
172 pub auth: Option<AuthorizationToken<'a>>,
174 pub atproto_proxy: Option<CowStr<'a>>,
176 pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
178 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
197pub trait XrpcExt: HttpClient {
213 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
228pub trait XrpcClient: HttpClient {
230 fn base_uri(&self) -> Url;
232
233 fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
235 async { CallOptions::default() }
236 }
237 fn send<R>(
239 &self,
240 request: R,
241 ) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>>
242 where
243 R: XrpcRequest + Send + Sync,
244 <R as XrpcRequest>::Response: Send + Sync;
245}
246
247pub 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 pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
276 self.opts.auth = Some(token);
277 self
278 }
279 pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
281 self.opts.atproto_proxy = Some(proxy);
282 self
283 }
284 pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
286 self.opts.atproto_accept_labelers = Some(labelers);
287 self
288 }
289 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
291 self.opts.extra_headers.push((name, value));
292 self
293 }
294 pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
296 self.opts = opts;
297 self
298 }
299
300 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#[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 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
357pub enum Header {
359 ContentType,
361 Authorization,
363 AtprotoProxy,
367 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
382pub 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
462pub struct Response<Resp>
467where
468 Resp: XrpcResp, {
470 _marker: PhantomData<fn() -> Resp>,
471 buffer: Bytes,
472 status: StatusCode,
473}
474
475impl<Resp> Response<Resp>
476where
477 Resp: XrpcResp,
478{
479 pub fn new(buffer: Bytes, status: StatusCode) -> Self {
481 Self {
482 buffer,
483 status,
484 _marker: PhantomData,
485 }
486 }
487
488 pub fn status(&self) -> StatusCode {
490 self.status
491 }
492
493 pub fn buffer(&self) -> &Bytes {
495 &self.buffer
496 }
497
498 pub fn parse<'s>(
500 &'s self,
501 ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
502 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 } 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 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
515 Ok(mut generic) => {
516 generic.nsid = Resp::NSID;
517 generic.method = ""; generic.http_status = self.status;
519 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 } else {
532 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
533 Ok(mut generic) => {
534 generic.nsid = Resp::NSID;
535 generic.method = ""; 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 pub fn parse_data<'s>(&'s self) -> Result<Data<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
552 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 } 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 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
565 Ok(mut generic) => {
566 generic.nsid = Resp::NSID;
567 generic.method = ""; generic.http_status = self.status;
569 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 } else {
582 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
583 Ok(mut generic) => {
584 generic.nsid = Resp::NSID;
585 generic.method = ""; 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 pub fn parse_raw<'s>(&'s self) -> Result<RawData<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
602 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 } 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 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
615 Ok(mut generic) => {
616 generic.nsid = Resp::NSID;
617 generic.method = ""; generic.http_status = self.status;
619 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 } else {
632 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
633 Ok(mut generic) => {
634 generic.nsid = Resp::NSID;
635 generic.method = ""; 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 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 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 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 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 } 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 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
707 Ok(mut generic) => {
708 generic.nsid = Resp::NSID;
709 generic.method = ""; generic.http_status = self.status;
711 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 } 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 = ""; 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#[derive(Debug, Clone, Deserialize, Serialize)]
750pub struct GenericXrpcError {
751 pub error: SmolStr,
753 pub message: Option<SmolStr>,
755 #[serde(skip)]
757 pub nsid: &'static str,
758 #[serde(skip)]
760 pub method: &'static str,
761 #[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#[derive(Debug, thiserror::Error, miette::Diagnostic)]
799pub enum XrpcError<E: std::error::Error + IntoStatic> {
800 #[error("XRPC error: {0}")]
802 #[diagnostic(code(jacquard_common::xrpc::typed))]
803 Xrpc(E),
804
805 #[error("Authentication error: {0}")]
807 #[diagnostic(code(jacquard_common::xrpc::auth))]
808 Auth(#[from] AuthError),
809
810 #[error("XRPC error: {0}")]
812 #[diagnostic(code(jacquard_common::xrpc::generic))]
813 Generic(GenericXrpcError),
814
815 #[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, ""); 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}