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;
72pub mod bearer;
73mod client;
74mod config;
75#[cfg(feature = "connectrpc")]
76pub mod connectrpc;
77mod credential;
78pub mod databricks;
79mod error;
80pub mod gcp;
81pub mod service;
82
83mod retry;
84mod token;
85mod util;
86
87pub use client::{Certificate, ClientConfigKey, ClientOptions};
88pub use credential::*;
89pub use error::*;
90pub use retry::Error as RetryError;
91pub use retry::RetryConfig;
92pub use service::{ReqwestService, SpawnService};
93
94type SharedSigner = Arc<dyn RequestSigner>;
96
97#[derive(Debug)]
99struct NoopSigner;
100
101impl RequestSigner for NoopSigner {
102 fn sign<'a>(
103 &'a self,
104 req: RequestBuilder,
105 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
106 Box::pin(async move { Ok(req) })
107 }
108}
109
110#[derive(Debug)]
112struct BearerTokenSigner {
113 token: String,
114}
115
116impl RequestSigner for BearerTokenSigner {
117 fn sign<'a>(
118 &'a self,
119 req: RequestBuilder,
120 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
121 let token = self.token.clone();
122 Box::pin(async move { Ok(req.bearer_auth(&token)) })
123 }
124}
125
126#[cfg(feature = "recording")]
127#[derive(Debug, Clone)]
128struct RecordingState {
129 out_dir: PathBuf,
130 counter: Arc<AtomicU64>,
131}
132
133#[derive(Clone)]
140pub struct CloudClient {
141 signer: SharedSigner,
142 reqwest_client: Client,
143 service: Arc<dyn HttpService>,
144 pub retry_config: RetryConfig,
151 #[cfg(feature = "recording")]
152 recording: Option<RecordingState>,
153}
154
155impl CloudClient {
156 fn new_with_signer(
157 signer: SharedSigner,
158 reqwest_client: Client,
159 service: Arc<dyn HttpService>,
160 retry_config: RetryConfig,
161 ) -> Self {
162 Self {
163 signer,
164 reqwest_client,
165 service,
166 retry_config,
167 #[cfg(feature = "recording")]
168 recording: None,
169 }
170 }
171
172 pub fn new_aws<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
177 where
178 I: IntoIterator<Item = (K, V)>,
179 K: AsRef<str>,
180 V: Into<String>,
181 {
182 let config = options
183 .into_iter()
184 .fold(
185 aws::AmazonBuilder::new(),
186 |builder, (key, value)| match key.as_ref().parse() {
187 Ok(k) => builder.with_config(k, value),
188 Err(_) => builder,
189 },
190 )
191 .build(runtime)?;
192
193 let reqwest_client = config.client_options.client()?;
194 let service = make_service(reqwest_client.clone(), runtime);
195 let retry_config = config.retry_config.clone();
196 Ok(Self::new_with_signer(
197 Arc::new(config),
198 reqwest_client,
199 service,
200 retry_config,
201 ))
202 }
203
204 pub fn new_google<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
209 where
210 I: IntoIterator<Item = (K, V)>,
211 K: AsRef<str>,
212 V: Into<String>,
213 {
214 let config = options
215 .into_iter()
216 .fold(
217 gcp::GoogleBuilder::new(),
218 |builder, (key, value)| match key.as_ref().parse() {
219 Ok(k) => builder.with_config(k, value),
220 Err(_) => builder,
221 },
222 )
223 .build(runtime)?;
224
225 let reqwest_client = config.client_options.client()?;
226 let service = make_service(reqwest_client.clone(), runtime);
227 let retry_config = config.retry_config.clone();
228 Ok(Self::new_with_signer(
229 Arc::new(config),
230 reqwest_client,
231 service,
232 retry_config,
233 ))
234 }
235
236 pub fn new_azure<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
241 where
242 I: IntoIterator<Item = (K, V)>,
243 K: AsRef<str>,
244 V: Into<String>,
245 {
246 let config = options
247 .into_iter()
248 .fold(
249 azure::AzureBuilder::new(),
250 |builder, (key, value)| match key.as_ref().parse() {
251 Ok(k) => builder.with_config(k, value),
252 Err(_) => builder,
253 },
254 )
255 .build(runtime)?;
256
257 let reqwest_client = config.client_options.client()?;
258 let service = make_service(reqwest_client.clone(), runtime);
259 let retry_config = config.retry_config.clone();
260 Ok(Self::new_with_signer(
261 Arc::new(config),
262 reqwest_client,
263 service,
264 retry_config,
265 ))
266 }
267
268 pub fn new_databricks<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
280 where
281 I: IntoIterator<Item = (K, V)>,
282 K: AsRef<str>,
283 V: Into<String>,
284 {
285 let builder = options.into_iter().fold(
286 databricks::DatabricksBuilder::new(),
287 |builder, (key, value)| match key.as_ref().parse() {
288 Ok(k) => builder.with_config(k, value),
289 Err(_) => builder,
290 },
291 );
292 Self::new_databricks_from_builder(builder, runtime)
293 }
294
295 pub fn new_databricks_from_builder(
306 builder: databricks::DatabricksBuilder,
307 runtime: Option<&Handle>,
308 ) -> Result<Self> {
309 let config = builder.build(runtime)?;
310
311 let reqwest_client = config.client_options.client()?;
312 let service = make_service(reqwest_client.clone(), runtime);
313 let retry_config = config.retry_config.clone();
314 Ok(Self::new_with_signer(
315 Arc::new(config),
316 reqwest_client,
317 service,
318 retry_config,
319 ))
320 }
321
322 pub fn new_with_bearer(
328 provider: bearer::BearerCredentialProvider,
329 options: &ClientOptions,
330 retry: RetryConfig,
331 runtime: Option<&Handle>,
332 ) -> Result<Self> {
333 let reqwest_client = options.client()?;
334 let service = make_service(reqwest_client.clone(), runtime);
335 Ok(Self::new_with_signer(
336 Arc::new(bearer::BearerProviderSigner(provider)),
337 reqwest_client,
338 service,
339 retry,
340 ))
341 }
342
343 pub fn new_with_token(token: impl ToString) -> Self {
345 let reqwest_client = Client::new();
346 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
347 Self::new_with_signer(
348 Arc::new(BearerTokenSigner {
349 token: token.to_string(),
350 }),
351 reqwest_client,
352 service,
353 RetryConfig::default(),
354 )
355 }
356
357 pub fn new_unauthenticated() -> Self {
359 let reqwest_client = Client::new();
360 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
361 Self::new_with_signer(
362 Arc::new(NoopSigner),
363 reqwest_client,
364 service,
365 RetryConfig::default(),
366 )
367 }
368
369 pub fn with_runtime(mut self, handle: Handle) -> Self {
375 self.service = Arc::new(SpawnService::new(self.service, handle));
376 self
377 }
378
379 pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
381 self.service = service;
382 self
383 }
384
385 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
393 self.retry_config = retry_config;
394 self
395 }
396
397 pub async fn sign_and_send(&self, request: reqwest::Request) -> Result<reqwest::Response> {
415 let builder = self
419 .reqwest_client
420 .request(request.method().clone(), request.url().clone());
421 let builder = builder.headers(request.headers().clone());
422 let builder = match request.body().and_then(|b| b.as_bytes()) {
423 Some(bytes) => builder.body(bytes.to_vec()),
424 None => builder,
425 };
426 let builder = self.signer.sign(builder).await?;
427 builder
428 .send_retry(&self.retry_config, self.service.clone())
429 .await
430 .map_err(|e| e.error())
431 }
432
433 pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> CloudRequestBuilder {
439 CloudRequestBuilder {
440 builder: self.reqwest_client.request(method, url),
441 client: self.clone(),
442 #[cfg(feature = "recording")]
443 out_dir: self.recording.as_ref().map(|r| r.out_dir.clone()),
444 }
445 }
446
447 pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
449 self.request(Method::GET, url)
450 }
451
452 pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
454 self.request(Method::POST, url)
455 }
456
457 pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
459 self.request(Method::PUT, url)
460 }
461
462 pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
464 self.request(Method::DELETE, url)
465 }
466
467 pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
469 self.request(Method::HEAD, url)
470 }
471
472 pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
474 self.request(Method::PATCH, url)
475 }
476
477 pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
479 self.request(Method::OPTIONS, url)
480 }
481
482 pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
484 self.request(Method::TRACE, url)
485 }
486
487 pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
489 self.request(Method::CONNECT, url)
490 }
491
492 #[cfg(feature = "recording")]
511 pub fn set_recording_dir(&mut self, out_dir: std::path::PathBuf) -> Result<(), std::io::Error> {
512 let out_dir = std::fs::canonicalize(out_dir)?;
513 self.recording = Some(RecordingState {
514 out_dir,
515 counter: Arc::new(AtomicU64::new(0)),
516 });
517 Ok(())
518 }
519}
520
521pub struct CloudRequestBuilder {
527 builder: RequestBuilder,
528 client: CloudClient,
529 #[cfg(feature = "recording")]
530 out_dir: Option<PathBuf>,
531}
532
533impl CloudRequestBuilder {
534 pub fn header<K, V>(mut self, key: K, value: V) -> CloudRequestBuilder
536 where
537 HeaderName: TryFrom<K>,
538 <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
539 HeaderValue: TryFrom<V>,
540 <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
541 {
542 self.builder = self.builder.header(key, value);
543 self
544 }
545
546 pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
550 self.builder = self.builder.headers(headers);
551 self
552 }
553
554 pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
556 self.builder = self.builder.body(body);
557 self
558 }
559
560 pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
566 self.builder = self.builder.timeout(timeout);
567 self
568 }
569
570 pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
589 self.builder = self.builder.query(query);
590 self
591 }
592
593 pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
600 self.builder = self.builder.json(json);
601 self
602 }
603
604 pub async fn send(mut self) -> Result<reqwest::Response> {
627 self.builder = self.client.signer.sign(self.builder).await?;
628
629 #[cfg(not(feature = "recording"))]
630 {
631 self.builder
632 .send_retry(&self.client.retry_config, self.client.service.clone())
633 .await
634 .map_err(|e| e.error())
635 }
636 #[cfg(feature = "recording")]
637 {
638 let response = send_record(self).await?;
639 Ok(response)
640 }
641 }
642
643 #[cfg(not(feature = "recording"))]
670 pub async fn send_raw(mut self) -> Result<reqwest::Response, SendRawError> {
671 self.builder = self.client.signer.sign(self.builder).await?;
672 self.builder
673 .send_retry(&self.client.retry_config, self.client.service.clone())
674 .await
675 .map_err(SendRawError::Retry)
676 }
677
678 #[cfg(feature = "recording")]
681 pub async fn send_raw(self) -> Result<reqwest::Response, SendRawError> {
682 send_record(self).await
683 }
684}
685
686#[cfg(not(feature = "recording"))]
694#[derive(Debug, thiserror::Error)]
695pub enum SendRawError {
696 #[error(transparent)]
698 Sign(#[from] Error),
699 #[error(transparent)]
701 Retry(RetryError),
702}
703
704#[cfg(feature = "recording")]
707pub type SendRawError = Error;
708
709#[cfg(feature = "recording")]
710#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
711pub struct RequestResponseInfo {
712 pub request: RequestInfo,
713 pub response: ResponseInfo,
714}
715
716#[cfg(feature = "recording")]
717#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
718pub struct RequestInfo {
719 pub method: String,
720 pub url_path: String,
721 pub body: Option<String>,
722}
723
724#[cfg(feature = "recording")]
725#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
726pub struct ResponseInfo {
727 pub status: u16,
728 pub headers: HashMap<String, String>,
729 pub body: Option<String>,
730}
731
732#[cfg(feature = "recording")]
737const SENSITIVE_HEADERS: &[&str] = &[
738 "authorization",
739 "x-amz-security-token",
740 "x-amz-content-sha256",
741 "x-databricks-authorization",
742 "x-ms-identity-principal-id",
743 "x-goog-iam-credentials-token",
744 "cookie",
745 "set-cookie",
746];
747
748#[cfg(feature = "recording")]
749fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
750 headers
751 .iter()
752 .map(|(k, v)| {
753 let v = if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) {
754 "<REDACTED>".to_string()
755 } else {
756 v.clone()
757 };
758 (k.clone(), v)
759 })
760 .collect()
761}
762
763#[cfg(feature = "recording")]
764async fn send_record(builder: CloudRequestBuilder) -> Result<reqwest::Response> {
765 let Some(out_dir) = builder.out_dir else {
766 let request = builder.builder.build().expect("request to be valid");
767 return builder.client.service.call(request).await;
768 };
769 let (_client, request) = builder.builder.build_split();
770 let request = request.expect("request to be valid");
771
772 let request_info = RequestInfo {
773 method: request.method().as_str().to_string(),
774 url_path: {
775 let url = request.url();
776 match url.query() {
777 Some(query) => format!("{}?{}", url.path(), query),
778 None => url.path().to_string(),
779 }
780 },
781 body: request
782 .body()
783 .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())),
784 };
785
786 let response = builder.client.service.call(request).await?;
787
788 let status = response.status().as_u16();
790 let raw_headers: HashMap<String, String> = response
791 .headers()
792 .iter()
793 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
794 .collect();
795
796 let response_bytes = response.bytes().await?;
798 let response_body = if response_bytes.is_empty() {
799 None
800 } else {
801 Some(String::from_utf8_lossy(&response_bytes).to_string())
802 };
803
804 let recording = RequestResponseInfo {
805 request: request_info,
806 response: ResponseInfo {
807 status,
808 headers: redact_headers(&raw_headers),
810 body: response_body,
811 },
812 };
813
814 let counter = builder
815 .client
816 .recording
817 .as_ref()
818 .map(|r| r.counter.fetch_add(1, Ordering::SeqCst))
819 .unwrap_or(0);
820 let file_path = out_dir.join(format!("{counter:04}.json"));
821 if let Err(e) = std::fs::File::create(&file_path)
822 .and_then(|f| serde_json::to_writer_pretty(f, &recording).map_err(Into::into))
823 {
824 tracing::warn!(
825 "Failed to write recording to {}: {}",
826 file_path.display(),
827 e
828 );
829 }
830
831 let mut mock_response = http::Response::builder().status(status);
833 for (k, v) in &raw_headers {
834 mock_response = mock_response.header(k, v);
835 }
836 let mock_response = mock_response
837 .body(response_bytes)
838 .expect("valid status code and headers");
839
840 Ok(reqwest::Response::from(mock_response))
841}
842
843#[cfg(all(test, feature = "recording"))]
844mod tests {
845 use super::*;
846 use std::fs;
847 use tempfile::TempDir;
848
849 #[tokio::test]
850 async fn test_request_response_recording() {
851 let temp_dir = TempDir::new().unwrap();
853 let temp_path = temp_dir.path().to_path_buf();
854
855 let mut server = mockito::Server::new_async().await;
857 let mock = server
858 .mock("GET", "/test")
859 .with_status(200)
860 .with_header("content-type", "application/json")
861 .with_body(r#"{"message": "Hello, World!"}"#)
862 .create_async()
863 .await;
864
865 let mut client = CloudClient::new_unauthenticated();
867 client.set_recording_dir(temp_path.clone()).unwrap();
868
869 let url = format!("{}/test", server.url());
871 let response = client.get(&url).send().await.unwrap();
872
873 assert_eq!(response.status(), 200);
875 let body = response.text().await.unwrap();
876 assert_eq!(body, r#"{"message": "Hello, World!"}"#);
877
878 let recordings: Vec<_> = fs::read_dir(&temp_path)
880 .unwrap()
881 .filter_map(|entry| {
882 let entry = entry.ok()?;
883 let path = entry.path();
884 if path.extension()? == "json" {
885 Some(path)
886 } else {
887 None
888 }
889 })
890 .collect();
891
892 assert_eq!(recordings.len(), 1, "Expected exactly one recording file");
893
894 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
896 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
897
898 assert_eq!(recording.request.method, "GET");
900 assert_eq!(recording.request.url_path, "/test");
901 assert_eq!(recording.request.body, None);
902
903 assert_eq!(recording.response.status, 200);
905 assert_eq!(
906 recording.response.headers.get("content-type").unwrap(),
907 "application/json"
908 );
909 assert_eq!(
910 recording.response.body.as_ref().unwrap(),
911 r#"{"message": "Hello, World!"}"#
912 );
913
914 mock.assert_async().await;
915 }
916
917 #[tokio::test]
918 async fn test_recording_with_request_body() {
919 let temp_dir = TempDir::new().unwrap();
921 let temp_path = temp_dir.path().to_path_buf();
922
923 let mut server = mockito::Server::new_async().await;
925 let mock = server
926 .mock("POST", "/create")
927 .with_status(201)
928 .with_header("location", "/resource/123")
929 .with_body(r#"{"id": 123, "status": "created"}"#)
930 .create_async()
931 .await;
932
933 let mut client = CloudClient::new_unauthenticated();
935 client.set_recording_dir(temp_path.clone()).unwrap();
936
937 let url = format!("{}/create", server.url());
939 let response = client
940 .post(&url)
941 .json(&serde_json::json!({"name": "test resource"}))
942 .send()
943 .await
944 .unwrap();
945
946 assert_eq!(response.status(), 201);
948
949 let recordings: Vec<_> = fs::read_dir(&temp_path)
951 .unwrap()
952 .filter_map(|entry| {
953 let entry = entry.ok()?;
954 let path = entry.path();
955 if path.extension()? == "json" {
956 Some(path)
957 } else {
958 None
959 }
960 })
961 .collect();
962
963 assert_eq!(recordings.len(), 1);
964
965 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
967 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
968
969 assert_eq!(recording.request.method, "POST");
971 assert_eq!(recording.request.url_path, "/create");
972 assert!(recording.request.body.is_some());
973 assert!(recording.request.body.unwrap().contains("test resource"));
974
975 assert_eq!(recording.response.status, 201);
977 assert_eq!(
978 recording.response.headers.get("location").unwrap(),
979 "/resource/123"
980 );
981 assert!(recording.response.body.unwrap().contains("created"));
982
983 mock.assert_async().await;
984 }
985
986 #[tokio::test]
987 async fn test_counter_based_file_naming() {
988 let temp_dir = TempDir::new().unwrap();
990 let temp_path = temp_dir.path().to_path_buf();
991
992 let mut server = mockito::Server::new_async().await;
994 let mock1 = server
995 .mock("GET", "/first")
996 .with_status(200)
997 .with_body("first response")
998 .create_async()
999 .await;
1000 let mock2 = server
1001 .mock("GET", "/second")
1002 .with_status(200)
1003 .with_body("second response")
1004 .create_async()
1005 .await;
1006
1007 let mut client = CloudClient::new_unauthenticated();
1009 client.set_recording_dir(temp_path.clone()).unwrap();
1010
1011 let url1 = format!("{}/first", server.url());
1013 let url2 = format!("{}/second", server.url());
1014
1015 let _response1 = client.get(&url1).send().await.unwrap();
1016 let _response2 = client.get(&url2).send().await.unwrap();
1017
1018 let mut recordings: Vec<_> = fs::read_dir(&temp_path)
1020 .unwrap()
1021 .filter_map(|entry| {
1022 let entry = entry.ok()?;
1023 let path = entry.path();
1024 if path.extension()? == "json" {
1025 Some(path)
1026 } else {
1027 None
1028 }
1029 })
1030 .collect();
1031
1032 recordings.sort();
1033 assert_eq!(recordings.len(), 2);
1034
1035 assert!(recordings[0].file_name().unwrap().to_str().unwrap() == "0000.json");
1037 assert!(recordings[1].file_name().unwrap().to_str().unwrap() == "0001.json");
1038
1039 let first_content = fs::read_to_string(&recordings[0]).unwrap();
1041 let first_recording: RequestResponseInfo = serde_json::from_str(&first_content).unwrap();
1042 assert_eq!(first_recording.request.url_path, "/first");
1043 assert_eq!(
1044 first_recording.response.body.as_ref().unwrap(),
1045 "first response"
1046 );
1047
1048 let second_content = fs::read_to_string(&recordings[1]).unwrap();
1049 let second_recording: RequestResponseInfo = serde_json::from_str(&second_content).unwrap();
1050 assert_eq!(second_recording.request.url_path, "/second");
1051 assert_eq!(
1052 second_recording.response.body.as_ref().unwrap(),
1053 "second response"
1054 );
1055
1056 mock1.assert_async().await;
1057 mock2.assert_async().await;
1058 }
1059
1060 #[tokio::test]
1061 async fn test_query_parameter_recording() {
1062 let temp_dir = TempDir::new().unwrap();
1064 let temp_path = temp_dir.path().to_path_buf();
1065
1066 let mut server = mockito::Server::new_async().await;
1068
1069 let mock = server
1071 .mock("GET", "/catalogs?max_results=10&page_token=abc123")
1072 .with_status(200)
1073 .with_header("content-type", "application/json")
1074 .with_body(r#"{"catalogs": []}"#)
1075 .create_async()
1076 .await;
1077
1078 let mut client = CloudClient::new_unauthenticated();
1080 client.set_recording_dir(temp_path.clone()).unwrap();
1081
1082 let url = format!("{}/catalogs?max_results=10&page_token=abc123", server.url());
1084 let response = client.get(&url).send().await.unwrap();
1085
1086 assert!(response.status().is_success());
1087
1088 let recordings: Vec<_> = fs::read_dir(&temp_path)
1090 .unwrap()
1091 .filter_map(|entry| {
1092 let entry = entry.ok()?;
1093 let path = entry.path();
1094 if path.extension()? == "json" {
1095 Some(path)
1096 } else {
1097 None
1098 }
1099 })
1100 .collect();
1101
1102 assert_eq!(recordings.len(), 1);
1103
1104 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
1106 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
1107
1108 assert_eq!(recording.request.method, "GET");
1110 assert_eq!(
1111 recording.request.url_path,
1112 "/catalogs?max_results=10&page_token=abc123"
1113 );
1114 assert_eq!(recording.request.body, None);
1115
1116 assert_eq!(recording.response.status, 200);
1118 assert_eq!(
1119 recording.response.body.as_ref().unwrap(),
1120 r#"{"catalogs": []}"#
1121 );
1122
1123 mock.assert_async().await;
1124 }
1125
1126 #[tokio::test]
1131 async fn test_recording_does_not_contain_bearer_token_value() {
1132 let temp_dir = TempDir::new().unwrap();
1133 let temp_path = temp_dir.path().to_path_buf();
1134
1135 let mut server = mockito::Server::new_async().await;
1136 let mock = server
1137 .mock("GET", "/secret")
1138 .match_header("authorization", mockito::Matcher::Any)
1139 .with_status(200)
1140 .with_body("ok")
1142 .create_async()
1143 .await;
1144
1145 let mut client = CloudClient::new_with_token("super-secret-token-12345");
1146 client.set_recording_dir(temp_path.clone()).unwrap();
1147
1148 let url = format!("{}/secret", server.url());
1149 client.get(&url).send().await.unwrap();
1150
1151 let recording_path = temp_path.join("0000.json");
1152 let content = fs::read_to_string(&recording_path).unwrap();
1153
1154 assert!(
1156 !content.contains("super-secret-token-12345"),
1157 "raw bearer token leaked into recording: {content}"
1158 );
1159
1160 mock.assert_async().await;
1161 }
1162
1163 #[tokio::test]
1167 async fn test_recording_redacts_sensitive_response_headers() {
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
1174 .mock("GET", "/s3")
1175 .with_status(200)
1176 .with_header("x-amz-security-token", "AQoXnyc4LLI2AJvUAMOGAR8a1234567890")
1177 .with_header("authorization", "Bearer should-be-redacted")
1178 .with_body("{}")
1179 .create_async()
1180 .await;
1181
1182 let mut client = CloudClient::new_unauthenticated();
1183 client.set_recording_dir(temp_path.clone()).unwrap();
1184
1185 let url = format!("{}/s3", server.url());
1186 client.get(&url).send().await.unwrap();
1187
1188 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1189 assert!(
1190 !content.contains("AQoXnyc4LLI2AJvUAMOGAR8a1234567890"),
1191 "x-amz-security-token leaked into recording: {content}"
1192 );
1193 assert!(
1194 !content.contains("should-be-redacted"),
1195 "Authorization value leaked into recording: {content}"
1196 );
1197 assert_eq!(
1199 content.matches("<REDACTED>").count(),
1200 2,
1201 "expected 2 <REDACTED> entries in recording: {content}"
1202 );
1203
1204 mock.assert_async().await;
1205 }
1206
1207 #[tokio::test]
1209 async fn test_recording_remains_valid_json_after_redaction() {
1210 let temp_dir = TempDir::new().unwrap();
1211 let temp_path = temp_dir.path().to_path_buf();
1212
1213 let mut server = mockito::Server::new_async().await;
1214 let _mock = server
1215 .mock("GET", "/check")
1216 .with_status(200)
1217 .with_header("authorization", "Bearer should-be-redacted")
1218 .with_body(r#"{"ok":true}"#)
1219 .create_async()
1220 .await;
1221
1222 let mut client = CloudClient::new_unauthenticated();
1223 client.set_recording_dir(temp_path.clone()).unwrap();
1224
1225 let url = format!("{}/check", server.url());
1226 client.get(&url).send().await.unwrap();
1227
1228 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1229 let parsed: RequestResponseInfo = serde_json::from_str(&content)
1231 .expect("recording file must be valid JSON even after redaction");
1232 assert_eq!(parsed.response.status, 200);
1233 }
1234}
1235
1236#[cfg(all(test, not(feature = "recording")))]
1241mod retry_integration_tests {
1242 use super::*;
1243 use std::time::Duration;
1244
1245 fn fast_retry(max_retries: usize) -> RetryConfig {
1246 RetryConfig {
1247 backoff: crate::backoff::BackoffConfig {
1248 init_backoff: Duration::from_millis(1),
1249 max_backoff: Duration::from_millis(5),
1250 base: 2.,
1251 },
1252 max_retries,
1253 retry_timeout: Duration::from_secs(30),
1254 }
1255 }
1256
1257 #[tokio::test]
1261 async fn send_retries_server_error_then_succeeds() {
1262 let mut server = mockito::Server::new_async().await;
1263 let fail = server
1264 .mock("GET", "/r")
1265 .with_status(503)
1266 .expect(1)
1267 .create_async()
1268 .await;
1269 let ok = server
1270 .mock("GET", "/r")
1271 .with_status(200)
1272 .with_body("ok")
1273 .expect(1)
1274 .create_async()
1275 .await;
1276
1277 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1278 let resp = client
1279 .get(format!("{}/r", server.url()))
1280 .send()
1281 .await
1282 .unwrap();
1283
1284 assert_eq!(resp.status(), 200);
1285 assert_eq!(resp.text().await.unwrap(), "ok");
1286 fail.assert_async().await;
1287 ok.assert_async().await;
1288 }
1289
1290 #[tokio::test]
1293 async fn send_does_not_retry_client_error() {
1294 let mut server = mockito::Server::new_async().await;
1295 let mock = server
1296 .mock("GET", "/r")
1297 .with_status(404)
1298 .expect(1)
1299 .create_async()
1300 .await;
1301
1302 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1303 let err = client
1304 .get(format!("{}/r", server.url()))
1305 .send()
1306 .await
1307 .unwrap_err();
1308
1309 assert!(matches!(err, Error::NotFound { .. }), "got {err:?}");
1310 mock.assert_async().await;
1311 }
1312
1313 #[tokio::test]
1318 async fn send_raw_preserves_status_and_body() {
1319 let mut server = mockito::Server::new_async().await;
1320
1321 let not_found = server
1323 .mock("GET", "/nf")
1324 .with_status(404)
1325 .with_body(r#"{"error":{"type":"NoSuchTableException"}}"#)
1326 .expect(1)
1327 .create_async()
1328 .await;
1329 let conflict = server
1331 .mock("POST", "/cf")
1332 .with_status(409)
1333 .with_body(r#"{"error":{"type":"CommitVersionConflictException"}}"#)
1334 .expect(1)
1335 .create_async()
1336 .await;
1337 let fail = server
1339 .mock("GET", "/rt")
1340 .with_status(503)
1341 .expect(1)
1342 .create_async()
1343 .await;
1344 let ok = server
1345 .mock("GET", "/rt")
1346 .with_status(200)
1347 .with_body("ok")
1348 .expect(1)
1349 .create_async()
1350 .await;
1351
1352 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1353
1354 let err = client
1355 .get(format!("{}/nf", server.url()))
1356 .send_raw()
1357 .await
1358 .unwrap_err();
1359 match err {
1360 SendRawError::Retry(e) => {
1361 assert_eq!(e.status(), Some(reqwest::StatusCode::NOT_FOUND));
1362 assert_eq!(
1363 e.body(),
1364 Some(r#"{"error":{"type":"NoSuchTableException"}}"#)
1365 );
1366 }
1367 other => panic!("expected Retry error, got {other:?}"),
1368 }
1369
1370 let err = client
1371 .post(format!("{}/cf", server.url()))
1372 .send_raw()
1373 .await
1374 .unwrap_err();
1375 match err {
1376 SendRawError::Retry(e) => {
1377 assert_eq!(e.status(), Some(reqwest::StatusCode::CONFLICT));
1378 assert_eq!(
1379 e.body(),
1380 Some(r#"{"error":{"type":"CommitVersionConflictException"}}"#)
1381 );
1382 }
1383 other => panic!("expected Retry error, got {other:?}"),
1384 }
1385
1386 let resp = client
1387 .get(format!("{}/rt", server.url()))
1388 .send_raw()
1389 .await
1390 .unwrap();
1391 assert_eq!(resp.status(), 200);
1392 assert_eq!(resp.text().await.unwrap(), "ok");
1393
1394 not_found.assert_async().await;
1395 conflict.assert_async().await;
1396 fail.assert_async().await;
1397 ok.assert_async().await;
1398 }
1399
1400 #[tokio::test]
1403 async fn sign_and_send_signs_and_retries() {
1404 let mut server = mockito::Server::new_async().await;
1405 let fail = server
1406 .mock("POST", "/rpc")
1407 .match_header("authorization", "Bearer tok")
1408 .with_status(503)
1409 .expect(1)
1410 .create_async()
1411 .await;
1412 let ok = server
1413 .mock("POST", "/rpc")
1414 .match_header("authorization", "Bearer tok")
1415 .with_status(200)
1416 .with_body("pong")
1417 .expect(1)
1418 .create_async()
1419 .await;
1420
1421 let client = CloudClient::new_with_token("tok").with_retry_config(fast_retry(3));
1422 let request = reqwest::Client::new()
1423 .post(format!("{}/rpc", server.url()))
1424 .body("ping")
1425 .build()
1426 .unwrap();
1427 let resp = client.sign_and_send(request).await.unwrap();
1428
1429 assert_eq!(resp.status(), 200);
1430 assert_eq!(resp.text().await.unwrap(), "pong");
1431 fail.assert_async().await;
1432 ok.assert_async().await;
1433 }
1434}
1435
1436#[cfg(test)]
1437mod databricks_client_tests {
1438 use super::*;
1439 use databricks::{DatabricksBuilder, DatabricksConfigKey};
1440
1441 #[tokio::test]
1443 async fn new_databricks_from_builder_signs_with_builder_credential() {
1444 let mut server = mockito::Server::new_async().await;
1445 let mock = server
1446 .mock("GET", "/api/2.1/unity-catalog/catalogs")
1447 .match_header("authorization", "Bearer builder-pat")
1448 .with_status(200)
1449 .create_async()
1450 .await;
1451
1452 let builder = DatabricksBuilder::new()
1453 .with_config(DatabricksConfigKey::Host, server.url())
1454 .with_config(DatabricksConfigKey::Token, "builder-pat")
1455 .with_config(
1456 DatabricksConfigKey::Client(ClientConfigKey::AllowHttp),
1457 "true",
1458 );
1459 let client = CloudClient::new_databricks_from_builder(builder, None).unwrap();
1460
1461 let url = format!("{}/api/2.1/unity-catalog/catalogs", server.url());
1462 let response = client.get(&url).send().await.unwrap();
1463 assert_eq!(response.status(), 200);
1464 mock.assert_async().await;
1465 }
1466
1467 #[tokio::test]
1469 async fn new_databricks_applies_string_options() {
1470 let mut server = mockito::Server::new_async().await;
1471 let mock = server
1472 .mock("GET", "/ping")
1473 .match_header("authorization", "Bearer option-pat")
1474 .with_status(200)
1475 .create_async()
1476 .await;
1477
1478 let client = CloudClient::new_databricks(
1479 [
1480 ("databricks_host", server.url()),
1481 ("databricks_token", "option-pat".to_string()),
1482 ("allow_http", "true".to_string()),
1483 ("not_a_real_key", "ignored".to_string()),
1484 ],
1485 None,
1486 )
1487 .unwrap();
1488
1489 let response = client
1490 .get(format!("{}/ping", server.url()))
1491 .send()
1492 .await
1493 .unwrap();
1494 assert_eq!(response.status(), 200);
1495 mock.assert_async().await;
1496 }
1497}
1498
1499#[cfg(test)]
1500mod databricks_azure_auth_tests {
1501 use super::*;
1502 use databricks::{DatabricksBuilder, DatabricksConfigKey};
1503
1504 fn azure_sp_builder(server: &mockito::Server) -> DatabricksBuilder {
1505 DatabricksBuilder::new()
1506 .with_config(DatabricksConfigKey::Host, server.url())
1507 .with_config(DatabricksConfigKey::ClientId, "sp-id")
1508 .with_config(DatabricksConfigKey::ClientSecret, "sp-secret")
1509 .with_config(DatabricksConfigKey::AzureTenantId, "tenant-1")
1510 .with_config(DatabricksConfigKey::AzureAuthorityHost, server.url())
1511 .with_config(
1512 DatabricksConfigKey::Client(ClientConfigKey::AllowHttp),
1513 "true",
1514 )
1515 }
1516
1517 #[tokio::test]
1520 async fn tenant_selects_azure_client_secret_auth() {
1521 let mut server = mockito::Server::new_async().await;
1522 let aad = server
1523 .mock("POST", "/tenant-1/oauth2/v2.0/token")
1524 .match_body(mockito::Matcher::AllOf(vec![
1525 mockito::Matcher::UrlEncoded("client_id".into(), "sp-id".into()),
1526 mockito::Matcher::UrlEncoded(
1527 "scope".into(),
1528 "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default".into(),
1529 ),
1530 ]))
1531 .with_status(200)
1532 .with_header("content-type", "application/json")
1533 .with_body(r#"{"access_token":"aad-token","expires_in":3600,"token_type":"Bearer"}"#)
1534 .expect(1)
1535 .create_async()
1536 .await;
1537 let m2m = server
1538 .mock("POST", "/oidc/v1/token")
1539 .expect(0)
1540 .create_async()
1541 .await;
1542 let api = server
1543 .mock("GET", "/api/2.1/unity-catalog/catalogs")
1544 .match_header("authorization", "Bearer aad-token")
1545 .with_status(200)
1546 .create_async()
1547 .await;
1548
1549 let client =
1550 CloudClient::new_databricks_from_builder(azure_sp_builder(&server), None).unwrap();
1551 let url = format!("{}/api/2.1/unity-catalog/catalogs", server.url());
1552 assert_eq!(client.get(&url).send().await.unwrap().status(), 200);
1553
1554 aad.assert_async().await;
1555 m2m.assert_async().await;
1556 api.assert_async().await;
1557 }
1558
1559 #[test]
1560 fn forced_azure_client_secret_requires_a_tenant() {
1561 let result = DatabricksBuilder::new()
1562 .with_config(DatabricksConfigKey::ClientId, "sp-id")
1563 .with_config(DatabricksConfigKey::ClientSecret, "sp-secret")
1564 .with_auth_type("azure-client-secret")
1565 .build(None);
1566 let Err(err) = result else {
1567 panic!("expected an error without a tenant id")
1568 };
1569 assert!(err.to_string().contains("AZURE_TENANT_ID"), "{err}");
1570 }
1571
1572 #[test]
1573 fn azure_config_keys_roundtrip() {
1574 for key in [
1575 DatabricksConfigKey::AzureTenantId,
1576 DatabricksConfigKey::AzureAuthorityHost,
1577 ] {
1578 assert_eq!(key.as_ref().parse::<DatabricksConfigKey>().unwrap(), key);
1579 }
1580 assert_eq!(
1581 "azure_tenant_id".parse::<DatabricksConfigKey>().unwrap(),
1582 DatabricksConfigKey::AzureTenantId
1583 );
1584 }
1585}