1#[cfg(feature = "recording")]
52use std::collections::HashMap;
53#[cfg(feature = "recording")]
54use std::path::PathBuf;
55use std::sync::Arc;
56#[cfg(feature = "recording")]
57use std::sync::atomic::{AtomicU64, Ordering};
58use std::time::Duration;
59
60use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
61use reqwest::{Body, Client, IntoUrl, Method, RequestBuilder};
62use serde::Serialize;
63use tokio::runtime::Handle;
64
65use self::retry::RetryExt;
66use self::service::{HttpService, make_service};
67pub use self::token::{TemporaryToken, TokenCache};
68
69pub mod aws;
70pub mod azure;
71mod backoff;
72mod client;
73mod config;
74#[cfg(feature = "connectrpc")]
75pub mod connectrpc;
76mod credential;
77pub mod databricks;
78mod error;
79pub mod gcp;
80pub mod service;
81
82mod retry;
83mod token;
84mod util;
85
86pub use client::{Certificate, ClientConfigKey, ClientOptions};
87pub use credential::*;
88pub use error::*;
89pub use retry::Error as RetryError;
90pub use retry::RetryConfig;
91pub use service::{ReqwestService, SpawnService};
92
93type SharedSigner = Arc<dyn RequestSigner>;
95
96#[derive(Debug)]
98struct NoopSigner;
99
100impl RequestSigner for NoopSigner {
101 fn sign<'a>(
102 &'a self,
103 req: RequestBuilder,
104 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
105 Box::pin(async move { Ok(req) })
106 }
107}
108
109#[derive(Debug)]
111struct BearerTokenSigner {
112 token: String,
113}
114
115impl RequestSigner for BearerTokenSigner {
116 fn sign<'a>(
117 &'a self,
118 req: RequestBuilder,
119 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
120 let token = self.token.clone();
121 Box::pin(async move { Ok(req.bearer_auth(&token)) })
122 }
123}
124
125#[cfg(feature = "recording")]
126#[derive(Debug, Clone)]
127struct RecordingState {
128 out_dir: PathBuf,
129 counter: Arc<AtomicU64>,
130}
131
132#[derive(Clone)]
139pub struct CloudClient {
140 signer: SharedSigner,
141 reqwest_client: Client,
142 service: Arc<dyn HttpService>,
143 pub retry_config: RetryConfig,
150 #[cfg(feature = "recording")]
151 recording: Option<RecordingState>,
152}
153
154impl CloudClient {
155 fn new_with_signer(
156 signer: SharedSigner,
157 reqwest_client: Client,
158 service: Arc<dyn HttpService>,
159 retry_config: RetryConfig,
160 ) -> Self {
161 Self {
162 signer,
163 reqwest_client,
164 service,
165 retry_config,
166 #[cfg(feature = "recording")]
167 recording: None,
168 }
169 }
170
171 pub fn new_aws<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
176 where
177 I: IntoIterator<Item = (K, V)>,
178 K: AsRef<str>,
179 V: Into<String>,
180 {
181 let config = options
182 .into_iter()
183 .fold(
184 aws::AmazonBuilder::new(),
185 |builder, (key, value)| match key.as_ref().parse() {
186 Ok(k) => builder.with_config(k, value),
187 Err(_) => builder,
188 },
189 )
190 .build(runtime)?;
191
192 let reqwest_client = config.client_options.client()?;
193 let service = make_service(reqwest_client.clone(), runtime);
194 let retry_config = config.retry_config.clone();
195 Ok(Self::new_with_signer(
196 Arc::new(config),
197 reqwest_client,
198 service,
199 retry_config,
200 ))
201 }
202
203 pub fn new_google<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
208 where
209 I: IntoIterator<Item = (K, V)>,
210 K: AsRef<str>,
211 V: Into<String>,
212 {
213 let config = options
214 .into_iter()
215 .fold(
216 gcp::GoogleBuilder::new(),
217 |builder, (key, value)| match key.as_ref().parse() {
218 Ok(k) => builder.with_config(k, value),
219 Err(_) => builder,
220 },
221 )
222 .build(runtime)?;
223
224 let reqwest_client = config.client_options.client()?;
225 let service = make_service(reqwest_client.clone(), runtime);
226 let retry_config = config.retry_config.clone();
227 Ok(Self::new_with_signer(
228 Arc::new(config),
229 reqwest_client,
230 service,
231 retry_config,
232 ))
233 }
234
235 pub fn new_azure<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
240 where
241 I: IntoIterator<Item = (K, V)>,
242 K: AsRef<str>,
243 V: Into<String>,
244 {
245 let config = options
246 .into_iter()
247 .fold(
248 azure::AzureBuilder::new(),
249 |builder, (key, value)| match key.as_ref().parse() {
250 Ok(k) => builder.with_config(k, value),
251 Err(_) => builder,
252 },
253 )
254 .build(runtime)?;
255
256 let reqwest_client = config.client_options.client()?;
257 let service = make_service(reqwest_client.clone(), runtime);
258 let retry_config = config.retry_config.clone();
259 Ok(Self::new_with_signer(
260 Arc::new(config),
261 reqwest_client,
262 service,
263 retry_config,
264 ))
265 }
266
267 pub fn new_databricks<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
272 where
273 I: IntoIterator<Item = (K, V)>,
274 K: AsRef<str>,
275 V: Into<String>,
276 {
277 use databricks::DatabricksBuilder;
278
279 let config = options
280 .into_iter()
281 .fold(
282 DatabricksBuilder::new(),
283 |builder, (key, value)| match key.as_ref().parse() {
284 Ok(k) => builder.with_config(k, value),
285 Err(_) => builder,
286 },
287 )
288 .build(runtime)?;
289
290 let reqwest_client = config.client_options.client()?;
291 let service = make_service(reqwest_client.clone(), runtime);
292 let retry_config = config.retry_config.clone();
293 Ok(Self::new_with_signer(
294 Arc::new(config),
295 reqwest_client,
296 service,
297 retry_config,
298 ))
299 }
300
301 pub fn new_with_token(token: impl ToString) -> Self {
303 let reqwest_client = Client::new();
304 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
305 Self::new_with_signer(
306 Arc::new(BearerTokenSigner {
307 token: token.to_string(),
308 }),
309 reqwest_client,
310 service,
311 RetryConfig::default(),
312 )
313 }
314
315 pub fn new_unauthenticated() -> Self {
317 let reqwest_client = Client::new();
318 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
319 Self::new_with_signer(
320 Arc::new(NoopSigner),
321 reqwest_client,
322 service,
323 RetryConfig::default(),
324 )
325 }
326
327 pub fn with_runtime(mut self, handle: Handle) -> Self {
333 self.service = Arc::new(SpawnService::new(self.service, handle));
334 self
335 }
336
337 pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
339 self.service = service;
340 self
341 }
342
343 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
351 self.retry_config = retry_config;
352 self
353 }
354
355 pub async fn sign_and_send(&self, request: reqwest::Request) -> Result<reqwest::Response> {
373 let builder = self
377 .reqwest_client
378 .request(request.method().clone(), request.url().clone());
379 let builder = builder.headers(request.headers().clone());
380 let builder = match request.body().and_then(|b| b.as_bytes()) {
381 Some(bytes) => builder.body(bytes.to_vec()),
382 None => builder,
383 };
384 let builder = self.signer.sign(builder).await?;
385 builder
386 .send_retry(&self.retry_config, self.service.clone())
387 .await
388 .map_err(|e| e.error())
389 }
390
391 pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> CloudRequestBuilder {
397 CloudRequestBuilder {
398 builder: self.reqwest_client.request(method, url),
399 client: self.clone(),
400 #[cfg(feature = "recording")]
401 out_dir: self.recording.as_ref().map(|r| r.out_dir.clone()),
402 }
403 }
404
405 pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
407 self.request(Method::GET, url)
408 }
409
410 pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
412 self.request(Method::POST, url)
413 }
414
415 pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
417 self.request(Method::PUT, url)
418 }
419
420 pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
422 self.request(Method::DELETE, url)
423 }
424
425 pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
427 self.request(Method::HEAD, url)
428 }
429
430 pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
432 self.request(Method::PATCH, url)
433 }
434
435 pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
437 self.request(Method::OPTIONS, url)
438 }
439
440 pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
442 self.request(Method::TRACE, url)
443 }
444
445 pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
447 self.request(Method::CONNECT, url)
448 }
449
450 #[cfg(feature = "recording")]
469 pub fn set_recording_dir(&mut self, out_dir: std::path::PathBuf) -> Result<(), std::io::Error> {
470 let out_dir = std::fs::canonicalize(out_dir)?;
471 self.recording = Some(RecordingState {
472 out_dir,
473 counter: Arc::new(AtomicU64::new(0)),
474 });
475 Ok(())
476 }
477}
478
479pub struct CloudRequestBuilder {
485 builder: RequestBuilder,
486 client: CloudClient,
487 #[cfg(feature = "recording")]
488 out_dir: Option<PathBuf>,
489}
490
491impl CloudRequestBuilder {
492 pub fn header<K, V>(mut self, key: K, value: V) -> CloudRequestBuilder
494 where
495 HeaderName: TryFrom<K>,
496 <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
497 HeaderValue: TryFrom<V>,
498 <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
499 {
500 self.builder = self.builder.header(key, value);
501 self
502 }
503
504 pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
508 self.builder = self.builder.headers(headers);
509 self
510 }
511
512 pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
514 self.builder = self.builder.body(body);
515 self
516 }
517
518 pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
524 self.builder = self.builder.timeout(timeout);
525 self
526 }
527
528 pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
547 self.builder = self.builder.query(query);
548 self
549 }
550
551 pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
558 self.builder = self.builder.json(json);
559 self
560 }
561
562 pub async fn send(mut self) -> Result<reqwest::Response> {
585 self.builder = self.client.signer.sign(self.builder).await?;
586
587 #[cfg(not(feature = "recording"))]
588 {
589 self.builder
590 .send_retry(&self.client.retry_config, self.client.service.clone())
591 .await
592 .map_err(|e| e.error())
593 }
594 #[cfg(feature = "recording")]
595 {
596 let response = send_record(self).await?;
597 Ok(response)
598 }
599 }
600
601 #[cfg(not(feature = "recording"))]
628 pub async fn send_raw(mut self) -> Result<reqwest::Response, SendRawError> {
629 self.builder = self.client.signer.sign(self.builder).await?;
630 self.builder
631 .send_retry(&self.client.retry_config, self.client.service.clone())
632 .await
633 .map_err(SendRawError::Retry)
634 }
635
636 #[cfg(feature = "recording")]
639 pub async fn send_raw(self) -> Result<reqwest::Response, SendRawError> {
640 send_record(self).await
641 }
642}
643
644#[cfg(not(feature = "recording"))]
652#[derive(Debug, thiserror::Error)]
653pub enum SendRawError {
654 #[error(transparent)]
656 Sign(#[from] Error),
657 #[error(transparent)]
659 Retry(RetryError),
660}
661
662#[cfg(feature = "recording")]
665pub type SendRawError = Error;
666
667#[cfg(feature = "recording")]
668#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
669pub struct RequestResponseInfo {
670 pub request: RequestInfo,
671 pub response: ResponseInfo,
672}
673
674#[cfg(feature = "recording")]
675#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
676pub struct RequestInfo {
677 pub method: String,
678 pub url_path: String,
679 pub body: Option<String>,
680}
681
682#[cfg(feature = "recording")]
683#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
684pub struct ResponseInfo {
685 pub status: u16,
686 pub headers: HashMap<String, String>,
687 pub body: Option<String>,
688}
689
690#[cfg(feature = "recording")]
695const SENSITIVE_HEADERS: &[&str] = &[
696 "authorization",
697 "x-amz-security-token",
698 "x-amz-content-sha256",
699 "x-databricks-authorization",
700 "x-ms-identity-principal-id",
701 "x-goog-iam-credentials-token",
702 "cookie",
703 "set-cookie",
704];
705
706#[cfg(feature = "recording")]
707fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
708 headers
709 .iter()
710 .map(|(k, v)| {
711 let v = if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) {
712 "<REDACTED>".to_string()
713 } else {
714 v.clone()
715 };
716 (k.clone(), v)
717 })
718 .collect()
719}
720
721#[cfg(feature = "recording")]
722async fn send_record(builder: CloudRequestBuilder) -> Result<reqwest::Response> {
723 let Some(out_dir) = builder.out_dir else {
724 let request = builder.builder.build().expect("request to be valid");
725 return builder.client.service.call(request).await;
726 };
727 let (_client, request) = builder.builder.build_split();
728 let request = request.expect("request to be valid");
729
730 let request_info = RequestInfo {
731 method: request.method().as_str().to_string(),
732 url_path: {
733 let url = request.url();
734 match url.query() {
735 Some(query) => format!("{}?{}", url.path(), query),
736 None => url.path().to_string(),
737 }
738 },
739 body: request
740 .body()
741 .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())),
742 };
743
744 let response = builder.client.service.call(request).await?;
745
746 let status = response.status().as_u16();
748 let raw_headers: HashMap<String, String> = response
749 .headers()
750 .iter()
751 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
752 .collect();
753
754 let response_bytes = response.bytes().await?;
756 let response_body = if response_bytes.is_empty() {
757 None
758 } else {
759 Some(String::from_utf8_lossy(&response_bytes).to_string())
760 };
761
762 let recording = RequestResponseInfo {
763 request: request_info,
764 response: ResponseInfo {
765 status,
766 headers: redact_headers(&raw_headers),
768 body: response_body,
769 },
770 };
771
772 let counter = builder
773 .client
774 .recording
775 .as_ref()
776 .map(|r| r.counter.fetch_add(1, Ordering::SeqCst))
777 .unwrap_or(0);
778 let file_path = out_dir.join(format!("{counter:04}.json"));
779 if let Err(e) = std::fs::File::create(&file_path)
780 .and_then(|f| serde_json::to_writer_pretty(f, &recording).map_err(Into::into))
781 {
782 tracing::warn!(
783 "Failed to write recording to {}: {}",
784 file_path.display(),
785 e
786 );
787 }
788
789 let mut mock_response = http::Response::builder().status(status);
791 for (k, v) in &raw_headers {
792 mock_response = mock_response.header(k, v);
793 }
794 let mock_response = mock_response
795 .body(response_bytes)
796 .expect("valid status code and headers");
797
798 Ok(reqwest::Response::from(mock_response))
799}
800
801#[cfg(all(test, feature = "recording"))]
802mod tests {
803 use super::*;
804 use std::fs;
805 use tempfile::TempDir;
806
807 #[tokio::test]
808 async fn test_request_response_recording() {
809 let temp_dir = TempDir::new().unwrap();
811 let temp_path = temp_dir.path().to_path_buf();
812
813 let mut server = mockito::Server::new_async().await;
815 let mock = server
816 .mock("GET", "/test")
817 .with_status(200)
818 .with_header("content-type", "application/json")
819 .with_body(r#"{"message": "Hello, World!"}"#)
820 .create_async()
821 .await;
822
823 let mut client = CloudClient::new_unauthenticated();
825 client.set_recording_dir(temp_path.clone()).unwrap();
826
827 let url = format!("{}/test", server.url());
829 let response = client.get(&url).send().await.unwrap();
830
831 assert_eq!(response.status(), 200);
833 let body = response.text().await.unwrap();
834 assert_eq!(body, r#"{"message": "Hello, World!"}"#);
835
836 let recordings: Vec<_> = fs::read_dir(&temp_path)
838 .unwrap()
839 .filter_map(|entry| {
840 let entry = entry.ok()?;
841 let path = entry.path();
842 if path.extension()? == "json" {
843 Some(path)
844 } else {
845 None
846 }
847 })
848 .collect();
849
850 assert_eq!(recordings.len(), 1, "Expected exactly one recording file");
851
852 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
854 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
855
856 assert_eq!(recording.request.method, "GET");
858 assert_eq!(recording.request.url_path, "/test");
859 assert_eq!(recording.request.body, None);
860
861 assert_eq!(recording.response.status, 200);
863 assert_eq!(
864 recording.response.headers.get("content-type").unwrap(),
865 "application/json"
866 );
867 assert_eq!(
868 recording.response.body.as_ref().unwrap(),
869 r#"{"message": "Hello, World!"}"#
870 );
871
872 mock.assert_async().await;
873 }
874
875 #[tokio::test]
876 async fn test_recording_with_request_body() {
877 let temp_dir = TempDir::new().unwrap();
879 let temp_path = temp_dir.path().to_path_buf();
880
881 let mut server = mockito::Server::new_async().await;
883 let mock = server
884 .mock("POST", "/create")
885 .with_status(201)
886 .with_header("location", "/resource/123")
887 .with_body(r#"{"id": 123, "status": "created"}"#)
888 .create_async()
889 .await;
890
891 let mut client = CloudClient::new_unauthenticated();
893 client.set_recording_dir(temp_path.clone()).unwrap();
894
895 let url = format!("{}/create", server.url());
897 let response = client
898 .post(&url)
899 .json(&serde_json::json!({"name": "test resource"}))
900 .send()
901 .await
902 .unwrap();
903
904 assert_eq!(response.status(), 201);
906
907 let recordings: Vec<_> = fs::read_dir(&temp_path)
909 .unwrap()
910 .filter_map(|entry| {
911 let entry = entry.ok()?;
912 let path = entry.path();
913 if path.extension()? == "json" {
914 Some(path)
915 } else {
916 None
917 }
918 })
919 .collect();
920
921 assert_eq!(recordings.len(), 1);
922
923 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
925 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
926
927 assert_eq!(recording.request.method, "POST");
929 assert_eq!(recording.request.url_path, "/create");
930 assert!(recording.request.body.is_some());
931 assert!(recording.request.body.unwrap().contains("test resource"));
932
933 assert_eq!(recording.response.status, 201);
935 assert_eq!(
936 recording.response.headers.get("location").unwrap(),
937 "/resource/123"
938 );
939 assert!(recording.response.body.unwrap().contains("created"));
940
941 mock.assert_async().await;
942 }
943
944 #[tokio::test]
945 async fn test_counter_based_file_naming() {
946 let temp_dir = TempDir::new().unwrap();
948 let temp_path = temp_dir.path().to_path_buf();
949
950 let mut server = mockito::Server::new_async().await;
952 let mock1 = server
953 .mock("GET", "/first")
954 .with_status(200)
955 .with_body("first response")
956 .create_async()
957 .await;
958 let mock2 = server
959 .mock("GET", "/second")
960 .with_status(200)
961 .with_body("second response")
962 .create_async()
963 .await;
964
965 let mut client = CloudClient::new_unauthenticated();
967 client.set_recording_dir(temp_path.clone()).unwrap();
968
969 let url1 = format!("{}/first", server.url());
971 let url2 = format!("{}/second", server.url());
972
973 let _response1 = client.get(&url1).send().await.unwrap();
974 let _response2 = client.get(&url2).send().await.unwrap();
975
976 let mut recordings: Vec<_> = fs::read_dir(&temp_path)
978 .unwrap()
979 .filter_map(|entry| {
980 let entry = entry.ok()?;
981 let path = entry.path();
982 if path.extension()? == "json" {
983 Some(path)
984 } else {
985 None
986 }
987 })
988 .collect();
989
990 recordings.sort();
991 assert_eq!(recordings.len(), 2);
992
993 assert!(recordings[0].file_name().unwrap().to_str().unwrap() == "0000.json");
995 assert!(recordings[1].file_name().unwrap().to_str().unwrap() == "0001.json");
996
997 let first_content = fs::read_to_string(&recordings[0]).unwrap();
999 let first_recording: RequestResponseInfo = serde_json::from_str(&first_content).unwrap();
1000 assert_eq!(first_recording.request.url_path, "/first");
1001 assert_eq!(
1002 first_recording.response.body.as_ref().unwrap(),
1003 "first response"
1004 );
1005
1006 let second_content = fs::read_to_string(&recordings[1]).unwrap();
1007 let second_recording: RequestResponseInfo = serde_json::from_str(&second_content).unwrap();
1008 assert_eq!(second_recording.request.url_path, "/second");
1009 assert_eq!(
1010 second_recording.response.body.as_ref().unwrap(),
1011 "second response"
1012 );
1013
1014 mock1.assert_async().await;
1015 mock2.assert_async().await;
1016 }
1017
1018 #[tokio::test]
1019 async fn test_query_parameter_recording() {
1020 let temp_dir = TempDir::new().unwrap();
1022 let temp_path = temp_dir.path().to_path_buf();
1023
1024 let mut server = mockito::Server::new_async().await;
1026
1027 let mock = server
1029 .mock("GET", "/catalogs?max_results=10&page_token=abc123")
1030 .with_status(200)
1031 .with_header("content-type", "application/json")
1032 .with_body(r#"{"catalogs": []}"#)
1033 .create_async()
1034 .await;
1035
1036 let mut client = CloudClient::new_unauthenticated();
1038 client.set_recording_dir(temp_path.clone()).unwrap();
1039
1040 let url = format!("{}/catalogs?max_results=10&page_token=abc123", server.url());
1042 let response = client.get(&url).send().await.unwrap();
1043
1044 assert!(response.status().is_success());
1045
1046 let recordings: Vec<_> = fs::read_dir(&temp_path)
1048 .unwrap()
1049 .filter_map(|entry| {
1050 let entry = entry.ok()?;
1051 let path = entry.path();
1052 if path.extension()? == "json" {
1053 Some(path)
1054 } else {
1055 None
1056 }
1057 })
1058 .collect();
1059
1060 assert_eq!(recordings.len(), 1);
1061
1062 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
1064 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
1065
1066 assert_eq!(recording.request.method, "GET");
1068 assert_eq!(
1069 recording.request.url_path,
1070 "/catalogs?max_results=10&page_token=abc123"
1071 );
1072 assert_eq!(recording.request.body, None);
1073
1074 assert_eq!(recording.response.status, 200);
1076 assert_eq!(
1077 recording.response.body.as_ref().unwrap(),
1078 r#"{"catalogs": []}"#
1079 );
1080
1081 mock.assert_async().await;
1082 }
1083
1084 #[tokio::test]
1089 async fn test_recording_does_not_contain_bearer_token_value() {
1090 let temp_dir = TempDir::new().unwrap();
1091 let temp_path = temp_dir.path().to_path_buf();
1092
1093 let mut server = mockito::Server::new_async().await;
1094 let mock = server
1095 .mock("GET", "/secret")
1096 .match_header("authorization", mockito::Matcher::Any)
1097 .with_status(200)
1098 .with_body("ok")
1100 .create_async()
1101 .await;
1102
1103 let mut client = CloudClient::new_with_token("super-secret-token-12345");
1104 client.set_recording_dir(temp_path.clone()).unwrap();
1105
1106 let url = format!("{}/secret", server.url());
1107 client.get(&url).send().await.unwrap();
1108
1109 let recording_path = temp_path.join("0000.json");
1110 let content = fs::read_to_string(&recording_path).unwrap();
1111
1112 assert!(
1114 !content.contains("super-secret-token-12345"),
1115 "raw bearer token leaked into recording: {content}"
1116 );
1117
1118 mock.assert_async().await;
1119 }
1120
1121 #[tokio::test]
1125 async fn test_recording_redacts_sensitive_response_headers() {
1126 let temp_dir = TempDir::new().unwrap();
1127 let temp_path = temp_dir.path().to_path_buf();
1128
1129 let mut server = mockito::Server::new_async().await;
1130 let mock = server
1132 .mock("GET", "/s3")
1133 .with_status(200)
1134 .with_header("x-amz-security-token", "AQoXnyc4LLI2AJvUAMOGAR8a1234567890")
1135 .with_header("authorization", "Bearer should-be-redacted")
1136 .with_body("{}")
1137 .create_async()
1138 .await;
1139
1140 let mut client = CloudClient::new_unauthenticated();
1141 client.set_recording_dir(temp_path.clone()).unwrap();
1142
1143 let url = format!("{}/s3", server.url());
1144 client.get(&url).send().await.unwrap();
1145
1146 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1147 assert!(
1148 !content.contains("AQoXnyc4LLI2AJvUAMOGAR8a1234567890"),
1149 "x-amz-security-token leaked into recording: {content}"
1150 );
1151 assert!(
1152 !content.contains("should-be-redacted"),
1153 "Authorization value leaked into recording: {content}"
1154 );
1155 assert_eq!(
1157 content.matches("<REDACTED>").count(),
1158 2,
1159 "expected 2 <REDACTED> entries in recording: {content}"
1160 );
1161
1162 mock.assert_async().await;
1163 }
1164
1165 #[tokio::test]
1167 async fn test_recording_remains_valid_json_after_redaction() {
1168 let temp_dir = TempDir::new().unwrap();
1169 let temp_path = temp_dir.path().to_path_buf();
1170
1171 let mut server = mockito::Server::new_async().await;
1172 let _mock = server
1173 .mock("GET", "/check")
1174 .with_status(200)
1175 .with_header("authorization", "Bearer should-be-redacted")
1176 .with_body(r#"{"ok":true}"#)
1177 .create_async()
1178 .await;
1179
1180 let mut client = CloudClient::new_unauthenticated();
1181 client.set_recording_dir(temp_path.clone()).unwrap();
1182
1183 let url = format!("{}/check", server.url());
1184 client.get(&url).send().await.unwrap();
1185
1186 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1187 let parsed: RequestResponseInfo = serde_json::from_str(&content)
1189 .expect("recording file must be valid JSON even after redaction");
1190 assert_eq!(parsed.response.status, 200);
1191 }
1192}
1193
1194#[cfg(all(test, not(feature = "recording")))]
1199mod retry_integration_tests {
1200 use super::*;
1201 use std::time::Duration;
1202
1203 fn fast_retry(max_retries: usize) -> RetryConfig {
1204 RetryConfig {
1205 backoff: crate::backoff::BackoffConfig {
1206 init_backoff: Duration::from_millis(1),
1207 max_backoff: Duration::from_millis(5),
1208 base: 2.,
1209 },
1210 max_retries,
1211 retry_timeout: Duration::from_secs(30),
1212 }
1213 }
1214
1215 #[tokio::test]
1219 async fn send_retries_server_error_then_succeeds() {
1220 let mut server = mockito::Server::new_async().await;
1221 let fail = server
1222 .mock("GET", "/r")
1223 .with_status(503)
1224 .expect(1)
1225 .create_async()
1226 .await;
1227 let ok = server
1228 .mock("GET", "/r")
1229 .with_status(200)
1230 .with_body("ok")
1231 .expect(1)
1232 .create_async()
1233 .await;
1234
1235 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1236 let resp = client
1237 .get(format!("{}/r", server.url()))
1238 .send()
1239 .await
1240 .unwrap();
1241
1242 assert_eq!(resp.status(), 200);
1243 assert_eq!(resp.text().await.unwrap(), "ok");
1244 fail.assert_async().await;
1245 ok.assert_async().await;
1246 }
1247
1248 #[tokio::test]
1251 async fn send_does_not_retry_client_error() {
1252 let mut server = mockito::Server::new_async().await;
1253 let mock = server
1254 .mock("GET", "/r")
1255 .with_status(404)
1256 .expect(1)
1257 .create_async()
1258 .await;
1259
1260 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1261 let err = client
1262 .get(format!("{}/r", server.url()))
1263 .send()
1264 .await
1265 .unwrap_err();
1266
1267 assert!(matches!(err, Error::NotFound { .. }), "got {err:?}");
1268 mock.assert_async().await;
1269 }
1270
1271 #[tokio::test]
1276 async fn send_raw_preserves_status_and_body() {
1277 let mut server = mockito::Server::new_async().await;
1278
1279 let not_found = server
1281 .mock("GET", "/nf")
1282 .with_status(404)
1283 .with_body(r#"{"error":{"type":"NoSuchTableException"}}"#)
1284 .expect(1)
1285 .create_async()
1286 .await;
1287 let conflict = server
1289 .mock("POST", "/cf")
1290 .with_status(409)
1291 .with_body(r#"{"error":{"type":"CommitVersionConflictException"}}"#)
1292 .expect(1)
1293 .create_async()
1294 .await;
1295 let fail = server
1297 .mock("GET", "/rt")
1298 .with_status(503)
1299 .expect(1)
1300 .create_async()
1301 .await;
1302 let ok = server
1303 .mock("GET", "/rt")
1304 .with_status(200)
1305 .with_body("ok")
1306 .expect(1)
1307 .create_async()
1308 .await;
1309
1310 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1311
1312 let err = client
1313 .get(format!("{}/nf", server.url()))
1314 .send_raw()
1315 .await
1316 .unwrap_err();
1317 match err {
1318 SendRawError::Retry(e) => {
1319 assert_eq!(e.status(), Some(reqwest::StatusCode::NOT_FOUND));
1320 assert_eq!(
1321 e.body(),
1322 Some(r#"{"error":{"type":"NoSuchTableException"}}"#)
1323 );
1324 }
1325 other => panic!("expected Retry error, got {other:?}"),
1326 }
1327
1328 let err = client
1329 .post(format!("{}/cf", server.url()))
1330 .send_raw()
1331 .await
1332 .unwrap_err();
1333 match err {
1334 SendRawError::Retry(e) => {
1335 assert_eq!(e.status(), Some(reqwest::StatusCode::CONFLICT));
1336 assert_eq!(
1337 e.body(),
1338 Some(r#"{"error":{"type":"CommitVersionConflictException"}}"#)
1339 );
1340 }
1341 other => panic!("expected Retry error, got {other:?}"),
1342 }
1343
1344 let resp = client
1345 .get(format!("{}/rt", server.url()))
1346 .send_raw()
1347 .await
1348 .unwrap();
1349 assert_eq!(resp.status(), 200);
1350 assert_eq!(resp.text().await.unwrap(), "ok");
1351
1352 not_found.assert_async().await;
1353 conflict.assert_async().await;
1354 fail.assert_async().await;
1355 ok.assert_async().await;
1356 }
1357
1358 #[tokio::test]
1361 async fn sign_and_send_signs_and_retries() {
1362 let mut server = mockito::Server::new_async().await;
1363 let fail = server
1364 .mock("POST", "/rpc")
1365 .match_header("authorization", "Bearer tok")
1366 .with_status(503)
1367 .expect(1)
1368 .create_async()
1369 .await;
1370 let ok = server
1371 .mock("POST", "/rpc")
1372 .match_header("authorization", "Bearer tok")
1373 .with_status(200)
1374 .with_body("pong")
1375 .expect(1)
1376 .create_async()
1377 .await;
1378
1379 let client = CloudClient::new_with_token("tok").with_retry_config(fast_retry(3));
1380 let request = reqwest::Client::new()
1381 .post(format!("{}/rpc", server.url()))
1382 .body("ping")
1383 .build()
1384 .unwrap();
1385 let resp = client.sign_and_send(request).await.unwrap();
1386
1387 assert_eq!(resp.status(), 200);
1388 assert_eq!(resp.text().await.unwrap(), "pong");
1389 fail.assert_async().await;
1390 ok.assert_async().await;
1391 }
1392}