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<'de>: Serialize + Deserialize<'de> {
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(body: &'de [u8]) -> Result<Box<Self>, DecodeError> {
106 let body: Self = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?;
107
108 Ok(Box::new(body))
109 }
110}
111
112pub trait XrpcResp {
116 const NSID: &'static str;
118
119 const ENCODING: &'static str;
121
122 type Output<'de>: Deserialize<'de> + IntoStatic;
124
125 type Err<'de>: Error + Deserialize<'de> + IntoStatic;
127}
128
129pub trait XrpcEndpoint {
137 const PATH: &'static str;
139 const METHOD: XrpcMethod;
141 type Request<'de>: XrpcRequest<'de> + IntoStatic;
143 type Response: XrpcResp;
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
149pub struct GenericError<'a>(#[serde(borrow)] Data<'a>);
150
151impl<'de> fmt::Display for GenericError<'de> {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 self.0.fmt(f)
154 }
155}
156
157impl Error for GenericError<'_> {}
158
159impl IntoStatic for GenericError<'_> {
160 type Output = GenericError<'static>;
161 fn into_static(self) -> Self::Output {
162 GenericError(self.0.into_static())
163 }
164}
165
166#[derive(Debug, Default, Clone)]
168pub struct CallOptions<'a> {
169 pub auth: Option<AuthorizationToken<'a>>,
171 pub atproto_proxy: Option<CowStr<'a>>,
173 pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
175 pub extra_headers: Vec<(HeaderName, HeaderValue)>,
177}
178
179impl IntoStatic for CallOptions<'_> {
180 type Output = CallOptions<'static>;
181
182 fn into_static(self) -> Self::Output {
183 CallOptions {
184 auth: self.auth.map(|auth| auth.into_static()),
185 atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()),
186 atproto_accept_labelers: self
187 .atproto_accept_labelers
188 .map(|labelers| labelers.into_static()),
189 extra_headers: self.extra_headers,
190 }
191 }
192}
193
194pub trait XrpcExt: HttpClient {
210 fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self>
212 where
213 Self: Sized,
214 {
215 XrpcCall {
216 client: self,
217 base,
218 opts: CallOptions::default(),
219 }
220 }
221}
222
223impl<T: HttpClient> XrpcExt for T {}
224
225pub trait XrpcClient: HttpClient {
227 fn base_uri(&self) -> Url;
229
230 fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
232 async { CallOptions::default() }
233 }
234 fn send<'s, R>(
236 &self,
237 request: R,
238 ) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest<'s>>::Response>>>
239 where
240 R: XrpcRequest<'s>;
241}
242
243pub struct XrpcCall<'a, C: HttpClient> {
264 pub(crate) client: &'a C,
265 pub(crate) base: Url,
266 pub(crate) opts: CallOptions<'a>,
267}
268
269impl<'a, C: HttpClient> XrpcCall<'a, C> {
270 pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
272 self.opts.auth = Some(token);
273 self
274 }
275 pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
277 self.opts.atproto_proxy = Some(proxy);
278 self
279 }
280 pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
282 self.opts.atproto_accept_labelers = Some(labelers);
283 self
284 }
285 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
287 self.opts.extra_headers.push((name, value));
288 self
289 }
290 pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
292 self.opts = opts;
293 self
294 }
295
296 pub async fn send<'s, R>(
305 self,
306 request: &R,
307 ) -> XrpcResult<Response<<R as XrpcRequest<'s>>::Response>>
308 where
309 R: XrpcRequest<'s>,
310 {
311 let http_request = build_http_request(&self.base, request, &self.opts)
312 .map_err(crate::error::TransportError::from)?;
313
314 let http_response = self
315 .client
316 .send_http(http_request)
317 .await
318 .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?;
319
320 process_response(http_response)
321 }
322}
323
324#[inline]
328pub fn process_response<Resp>(http_response: http::Response<Vec<u8>>) -> XrpcResult<Response<Resp>>
329where
330 Resp: XrpcResp,
331{
332 let status = http_response.status();
333 if status.as_u16() == 401 {
336 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
337 return Err(crate::error::ClientError::Auth(
338 crate::error::AuthError::Other(hv.clone()),
339 ));
340 }
341 }
342 let buffer = Bytes::from(http_response.into_body());
343
344 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
345 return Err(crate::error::HttpError {
346 status,
347 body: Some(buffer),
348 }
349 .into());
350 }
351
352 Ok(Response::new(buffer, status))
353}
354
355pub enum Header {
357 ContentType,
359 Authorization,
361 AtprotoProxy,
365 AtprotoAcceptLabelers,
367}
368
369impl From<Header> for HeaderName {
370 fn from(value: Header) -> Self {
371 match value {
372 Header::ContentType => CONTENT_TYPE,
373 Header::Authorization => AUTHORIZATION,
374 Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"),
375 Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"),
376 }
377 }
378}
379
380pub fn build_http_request<'s, R>(
382 base: &Url,
383 req: &R,
384 opts: &CallOptions<'_>,
385) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError>
386where
387 R: XrpcRequest<'s>,
388{
389 let mut url = base.clone();
390 let mut path = url.path().trim_end_matches('/').to_owned();
391 path.push_str("/xrpc/");
392 path.push_str(<R as XrpcRequest<'s>>::NSID);
393 url.set_path(&path);
394
395 if let XrpcMethod::Query = <R as XrpcRequest<'s>>::METHOD {
396 let qs = serde_html_form::to_string(&req)
397 .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?;
398 if !qs.is_empty() {
399 url.set_query(Some(&qs));
400 } else {
401 url.set_query(None);
402 }
403 }
404
405 let method = match <R as XrpcRequest<'_>>::METHOD {
406 XrpcMethod::Query => http::Method::GET,
407 XrpcMethod::Procedure(_) => http::Method::POST,
408 };
409
410 let mut builder = Request::builder().method(method).uri(url.as_str());
411
412 if let XrpcMethod::Procedure(encoding) = <R as XrpcRequest<'_>>::METHOD {
413 builder = builder.header(Header::ContentType, encoding);
414 }
415 let output_encoding = <R::Response as XrpcResp>::ENCODING;
416 builder = builder.header(http::header::ACCEPT, output_encoding);
417
418 if let Some(token) = &opts.auth {
419 let hv = match token {
420 AuthorizationToken::Bearer(t) => {
421 HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
422 }
423 AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())),
424 }
425 .map_err(|e| {
426 TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
427 })?;
428 builder = builder.header(Header::Authorization, hv);
429 }
430
431 if let Some(proxy) = &opts.atproto_proxy {
432 builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
433 }
434 if let Some(labelers) = &opts.atproto_accept_labelers {
435 if !labelers.is_empty() {
436 let joined = labelers
437 .iter()
438 .map(|s| s.as_ref())
439 .collect::<Vec<_>>()
440 .join(", ");
441 builder = builder.header(Header::AtprotoAcceptLabelers, joined);
442 }
443 }
444 for (name, value) in &opts.extra_headers {
445 builder = builder.header(name, value);
446 }
447
448 let body = if let XrpcMethod::Procedure(_) = R::METHOD {
449 req.encode_body()
450 .map_err(|e| TransportError::InvalidRequest(e.to_string()))?
451 } else {
452 vec![]
453 };
454
455 builder
456 .body(body)
457 .map_err(|e| TransportError::InvalidRequest(e.to_string()))
458}
459
460pub struct Response<Resp>
465where
466 Resp: XrpcResp, {
468 _marker: PhantomData<fn() -> Resp>,
469 buffer: Bytes,
470 status: StatusCode,
471}
472
473impl<Resp> Response<Resp>
474where
475 Resp: XrpcResp,
476{
477 pub fn new(buffer: Bytes, status: StatusCode) -> Self {
479 Self {
480 buffer,
481 status,
482 _marker: PhantomData,
483 }
484 }
485
486 pub fn status(&self) -> StatusCode {
488 self.status
489 }
490
491 pub fn buffer(&self) -> &Bytes {
493 &self.buffer
494 }
495
496 pub fn parse<'s>(
498 &'s self,
499 ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
500 if self.status.is_success() {
502 match serde_json::from_slice::<_>(&self.buffer) {
503 Ok(output) => Ok(output),
504 Err(e) => Err(XrpcError::Decode(e)),
505 }
506 } else if self.status.as_u16() == 400 {
508 match serde_json::from_slice::<_>(&self.buffer) {
509 Ok(error) => Err(XrpcError::Xrpc(error)),
510 Err(_) => {
511 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
513 Ok(mut generic) => {
514 generic.nsid = Resp::NSID;
515 generic.method = ""; generic.http_status = self.status;
517 match generic.error.as_str() {
519 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
520 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
521 _ => Err(XrpcError::Generic(generic)),
522 }
523 }
524 Err(e) => Err(XrpcError::Decode(e)),
525 }
526 }
527 }
528 } else {
530 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
531 Ok(mut generic) => {
532 generic.nsid = Resp::NSID;
533 generic.method = ""; generic.http_status = self.status;
535 match generic.error.as_str() {
536 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
537 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
538 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
539 }
540 }
541 Err(e) => Err(XrpcError::Decode(e)),
542 }
543 }
544 }
545
546 pub fn parse_data<'s>(&'s self) -> Result<Data<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
550 if self.status.is_success() {
552 match serde_json::from_slice::<_>(&self.buffer) {
553 Ok(output) => Ok(output),
554 Err(e) => Err(XrpcError::Decode(e)),
555 }
556 } else if self.status.as_u16() == 400 {
558 match serde_json::from_slice::<_>(&self.buffer) {
559 Ok(error) => Err(XrpcError::Xrpc(error)),
560 Err(_) => {
561 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
563 Ok(mut generic) => {
564 generic.nsid = Resp::NSID;
565 generic.method = ""; generic.http_status = self.status;
567 match generic.error.as_str() {
569 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
570 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
571 _ => Err(XrpcError::Generic(generic)),
572 }
573 }
574 Err(e) => Err(XrpcError::Decode(e)),
575 }
576 }
577 }
578 } else {
580 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
581 Ok(mut generic) => {
582 generic.nsid = Resp::NSID;
583 generic.method = ""; generic.http_status = self.status;
585 match generic.error.as_str() {
586 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
587 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
588 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
589 }
590 }
591 Err(e) => Err(XrpcError::Decode(e)),
592 }
593 }
594 }
595
596 pub fn parse_raw<'s>(&'s self) -> Result<RawData<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
600 if self.status.is_success() {
602 match serde_json::from_slice::<_>(&self.buffer) {
603 Ok(output) => Ok(output),
604 Err(e) => Err(XrpcError::Decode(e)),
605 }
606 } else if self.status.as_u16() == 400 {
608 match serde_json::from_slice::<_>(&self.buffer) {
609 Ok(error) => Err(XrpcError::Xrpc(error)),
610 Err(_) => {
611 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
613 Ok(mut generic) => {
614 generic.nsid = Resp::NSID;
615 generic.method = ""; generic.http_status = self.status;
617 match generic.error.as_str() {
619 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
620 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
621 _ => Err(XrpcError::Generic(generic)),
622 }
623 }
624 Err(e) => Err(XrpcError::Decode(e)),
625 }
626 }
627 }
628 } else {
630 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
631 Ok(mut generic) => {
632 generic.nsid = Resp::NSID;
633 generic.method = ""; generic.http_status = self.status;
635 match generic.error.as_str() {
636 "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
637 "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
638 _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
639 }
640 }
641 Err(e) => Err(XrpcError::Decode(e)),
642 }
643 }
644 }
645
646 pub fn transmute<NEW: XrpcResp>(self) -> Response<NEW> {
658 Response {
659 buffer: self.buffer,
660 status: self.status,
661 _marker: PhantomData,
662 }
663 }
664}
665
666impl<Resp> Response<Resp>
667where
668 Resp: XrpcResp,
669{
670 pub fn into_output(
672 self,
673 ) -> Result<<Resp as XrpcResp>::Output<'static>, XrpcError<<Resp as XrpcResp>::Err<'static>>>
674 where
675 for<'a> <Resp as XrpcResp>::Output<'a>:
676 IntoStatic<Output = <Resp as XrpcResp>::Output<'static>>,
677 for<'a> <Resp as XrpcResp>::Err<'a>: IntoStatic<Output = <Resp as XrpcResp>::Err<'static>>,
678 {
679 fn parse_output<'b, R: XrpcResp>(
681 buffer: &'b [u8],
682 ) -> Result<R::Output<'b>, serde_json::Error> {
683 serde_json::from_slice(buffer)
684 }
685
686 fn parse_error<'b, R: XrpcResp>(buffer: &'b [u8]) -> Result<R::Err<'b>, serde_json::Error> {
687 serde_json::from_slice(buffer)
688 }
689
690 if self.status.is_success() {
692 match parse_output::<Resp>(&self.buffer) {
693 Ok(output) => {
694 return Ok(output.into_static());
695 }
696 Err(e) => Err(XrpcError::Decode(e)),
697 }
698 } else if self.status.as_u16() == 400 {
700 let error = match parse_error::<Resp>(&self.buffer) {
701 Ok(error) => XrpcError::Xrpc(error),
702 Err(_) => {
703 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
705 Ok(mut generic) => {
706 generic.nsid = Resp::NSID;
707 generic.method = ""; generic.http_status = self.status;
709 match generic.error.as_ref() {
711 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired),
712 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken),
713 _ => XrpcError::Generic(generic),
714 }
715 }
716 Err(e) => XrpcError::Decode(e),
717 }
718 }
719 };
720 Err(error.into_static())
721 } else {
723 let error: XrpcError<<Resp as XrpcResp>::Err<'_>> =
724 match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
725 Ok(mut generic) => {
726 let status = self.status;
727 generic.nsid = Resp::NSID;
728 generic.method = ""; generic.http_status = status;
730 match generic.error.as_ref() {
731 "ExpiredToken" => XrpcError::Auth(AuthError::TokenExpired),
732 "InvalidToken" => XrpcError::Auth(AuthError::InvalidToken),
733 _ => XrpcError::Auth(AuthError::NotAuthenticated),
734 }
735 }
736 Err(e) => XrpcError::Decode(e),
737 };
738
739 Err(error.into_static())
740 }
741 }
742}
743
744#[derive(Debug, Clone, Deserialize, Serialize)]
748pub struct GenericXrpcError {
749 pub error: SmolStr,
751 pub message: Option<SmolStr>,
753 #[serde(skip)]
755 pub nsid: &'static str,
756 #[serde(skip)]
758 pub method: &'static str,
759 #[serde(skip)]
761 pub http_status: StatusCode,
762}
763
764impl std::fmt::Display for GenericXrpcError {
765 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766 if let Some(msg) = &self.message {
767 write!(
768 f,
769 "{}: {} (nsid={}, method={}, status={})",
770 self.error, msg, self.nsid, self.method, self.http_status
771 )
772 } else {
773 write!(
774 f,
775 "{} (nsid={}, method={}, status={})",
776 self.error, self.nsid, self.method, self.http_status
777 )
778 }
779 }
780}
781
782impl IntoStatic for GenericXrpcError {
783 type Output = Self;
784
785 fn into_static(self) -> Self::Output {
786 self
787 }
788}
789
790impl std::error::Error for GenericXrpcError {}
791
792#[derive(Debug, thiserror::Error, miette::Diagnostic)]
797pub enum XrpcError<E: std::error::Error + IntoStatic> {
798 #[error("XRPC error: {0}")]
800 #[diagnostic(code(jacquard_common::xrpc::typed))]
801 Xrpc(E),
802
803 #[error("Authentication error: {0}")]
805 #[diagnostic(code(jacquard_common::xrpc::auth))]
806 Auth(#[from] AuthError),
807
808 #[error("XRPC error: {0}")]
810 #[diagnostic(code(jacquard_common::xrpc::generic))]
811 Generic(GenericXrpcError),
812
813 #[error("Failed to decode response: {0}")]
815 #[diagnostic(code(jacquard_common::xrpc::decode))]
816 Decode(#[from] serde_json::Error),
817}
818
819impl<E> IntoStatic for XrpcError<E>
820where
821 E: std::error::Error + IntoStatic,
822 E::Output: std::error::Error + IntoStatic,
823 <E as IntoStatic>::Output: std::error::Error + IntoStatic,
824{
825 type Output = XrpcError<E::Output>;
826 fn into_static(self) -> Self::Output {
827 match self {
828 XrpcError::Xrpc(e) => XrpcError::Xrpc(e.into_static()),
829 XrpcError::Auth(e) => XrpcError::Auth(e.into_static()),
830 XrpcError::Generic(e) => XrpcError::Generic(e),
831 XrpcError::Decode(e) => XrpcError::Decode(e),
832 }
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use serde::{Deserialize, Serialize};
840
841 #[derive(Serialize, Deserialize)]
842 #[allow(dead_code)]
843 struct DummyReq;
844
845 #[derive(Deserialize, Debug, thiserror::Error)]
846 #[error("{0}")]
847 struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>);
848
849 impl IntoStatic for DummyErr<'_> {
850 type Output = DummyErr<'static>;
851 fn into_static(self) -> Self::Output {
852 DummyErr(self.0.into_static())
853 }
854 }
855
856 struct DummyResp;
857
858 impl XrpcResp for DummyResp {
859 const NSID: &'static str = "test.dummy";
860 const ENCODING: &'static str = "application/json";
861 type Output<'de> = ();
862 type Err<'de> = DummyErr<'de>;
863 }
864
865 impl<'de> XrpcRequest<'de> for DummyReq {
866 const NSID: &'static str = "test.dummy";
867 const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json");
868 type Response = DummyResp;
869 }
870
871 #[test]
872 fn generic_error_carries_context() {
873 let body = serde_json::json!({"error":"InvalidRequest","message":"missing"});
874 let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
875 let resp: Response<DummyResp> = Response::new(buf, StatusCode::BAD_REQUEST);
876 match resp.parse().unwrap_err() {
877 XrpcError::Generic(g) => {
878 assert_eq!(g.error.as_str(), "InvalidRequest");
879 assert_eq!(g.message.as_deref(), Some("missing"));
880 assert_eq!(g.nsid, DummyResp::NSID);
881 assert_eq!(g.method, ""); assert_eq!(g.http_status, StatusCode::BAD_REQUEST);
883 }
884 other => panic!("unexpected: {other:?}"),
885 }
886 }
887
888 #[test]
889 fn auth_error_mapping() {
890 for (code, expect) in [
891 ("ExpiredToken", AuthError::TokenExpired),
892 ("InvalidToken", AuthError::InvalidToken),
893 ] {
894 let body = serde_json::json!({"error": code});
895 let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
896 let resp: Response<DummyResp> = Response::new(buf, StatusCode::UNAUTHORIZED);
897 match resp.parse().unwrap_err() {
898 XrpcError::Auth(e) => match (e, expect) {
899 (AuthError::TokenExpired, AuthError::TokenExpired) => {}
900 (AuthError::InvalidToken, AuthError::InvalidToken) => {}
901 other => panic!("mismatch: {other:?}"),
902 },
903 other => panic!("unexpected: {other:?}"),
904 }
905 }
906 }
907
908 #[test]
909 fn no_double_slash_in_path() {
910 #[derive(Serialize, Deserialize)]
911 struct Req;
912 #[derive(Deserialize, Debug, thiserror::Error)]
913 #[error("{0}")]
914 struct Err<'a>(#[serde(borrow)] CowStr<'a>);
915 impl IntoStatic for Err<'_> {
916 type Output = Err<'static>;
917 fn into_static(self) -> Self::Output {
918 Err(self.0.into_static())
919 }
920 }
921 struct Resp;
922 impl XrpcResp for Resp {
923 const NSID: &'static str = "com.example.test";
924 const ENCODING: &'static str = "application/json";
925 type Output<'de> = ();
926 type Err<'de> = Err<'de>;
927 }
928 impl<'de> XrpcRequest<'de> for Req {
929 const NSID: &'static str = "com.example.test";
930 const METHOD: XrpcMethod = XrpcMethod::Query;
931 type Response = Resp;
932 }
933
934 let opts = CallOptions::default();
935 for base in [
936 Url::parse("https://pds").unwrap(),
937 Url::parse("https://pds/").unwrap(),
938 Url::parse("https://pds/base/").unwrap(),
939 ] {
940 let req = build_http_request(&base, &Req, &opts).unwrap();
941 let uri = req.uri().to_string();
942 assert!(uri.contains("/xrpc/com.example.test"));
943 assert!(!uri.contains("//xrpc"));
944 }
945 }
946}