Skip to main content

olai_http/
lib.rs

1//! Unified cloud credential abstraction and HTTP client for AWS, Azure, GCP, and
2//! Databricks.
3//!
4//! This crate provides a single authenticated HTTP client, [`CloudClient`], that
5//! signs outgoing requests for any supported cloud provider. Rather than pulling
6//! in a separate vendor SDK for each cloud, a service constructs one
7//! [`CloudClient`] per provider and issues requests through a familiar,
8//! `reqwest`-style builder ([`CloudRequestBuilder`]). Every provider is reached
9//! through the same [`RequestSigner`] trait, so credential resolution, token
10//! refresh, and request signing are uniform across clouds. The credential
11//! machinery is extracted from the
12//! [`object_store`](https://crates.io/crates/object_store) crate's internal client.
13//!
14//! # Providers
15//!
16//! Each provider has a builder under its own module and a matching
17//! [`CloudClient`] constructor:
18//!
19//! - **AWS** ([`aws`]) — SigV4 signing with static keys, IMDS, ECS/EKS task
20//!   roles, web identity, and STS `AssumeRole`. See [`CloudClient::new_aws`].
21//! - **Azure** ([`azure`]) — Azure AD bearer tokens via client secret, managed
22//!   identity, workload identity, or the Azure CLI. See [`CloudClient::new_azure`].
23//! - **Google Cloud** ([`gcp`]) — OAuth 2.0 bearer tokens via service-account
24//!   JWTs, the GCE metadata server, or workload identity federation. See
25//!   [`CloudClient::new_google`].
26//! - **Databricks** ([`databricks`]) — OAuth M2M and OIDC token exchange. See
27//!   [`CloudClient::new_databricks`].
28//!
29//! For a static token or no authentication at all, use
30//! [`CloudClient::new_with_token`] or [`CloudClient::new_unauthenticated`].
31//!
32//! # Examples
33//!
34//! ```no_run
35//! use olai_http::CloudClient;
36//!
37//! # async fn run() -> olai_http::Result<()> {
38//! let client = CloudClient::new_with_token("my-token");
39//! let resp = client
40//!     .get("https://api.example.com/data")
41//!     .send()
42//!     .await?;
43//! println!("status: {}", resp.status());
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! Enable the `recording` feature to capture HTTP interactions to JSON (with
49//! sensitive headers redacted) for test replay.
50
51#[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::service::{HttpService, make_service};
66pub use self::token::{TemporaryToken, TokenCache};
67
68pub mod aws;
69pub mod azure;
70mod backoff;
71mod client;
72mod config;
73mod credential;
74pub mod databricks;
75mod error;
76pub mod gcp;
77pub mod service;
78
79mod retry;
80mod token;
81mod util;
82
83pub use client::{Certificate, ClientConfigKey, ClientOptions};
84pub use credential::*;
85pub use error::*;
86pub use retry::RetryConfig;
87pub use service::{ReqwestService, SpawnService};
88
89/// A shared, cloneable signer for a `CloudClient`.
90type SharedSigner = Arc<dyn RequestSigner>;
91
92/// A no-op signer used by `new_unauthenticated`.
93#[derive(Debug)]
94struct NoopSigner;
95
96impl RequestSigner for NoopSigner {
97    fn sign<'a>(
98        &'a self,
99        req: RequestBuilder,
100    ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
101        Box::pin(async move { Ok(req) })
102    }
103}
104
105/// A signer that injects a static bearer token.
106#[derive(Debug)]
107struct BearerTokenSigner {
108    token: String,
109}
110
111impl RequestSigner for BearerTokenSigner {
112    fn sign<'a>(
113        &'a self,
114        req: RequestBuilder,
115    ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
116        let token = self.token.clone();
117        Box::pin(async move { Ok(req.bearer_auth(&token)) })
118    }
119}
120
121#[cfg(feature = "recording")]
122#[derive(Debug, Clone)]
123struct RecordingState {
124    out_dir: PathBuf,
125    counter: Arc<AtomicU64>,
126}
127
128/// An authenticated HTTP client for cloud provider APIs.
129///
130/// Created via the provider-specific constructors [`CloudClient::new_aws`],
131/// [`CloudClient::new_azure`], [`CloudClient::new_google`], or
132/// [`CloudClient::new_databricks`], or the simpler [`CloudClient::new_with_token`]
133/// and [`CloudClient::new_unauthenticated`].
134#[derive(Clone)]
135pub struct CloudClient {
136    signer: SharedSigner,
137    reqwest_client: Client,
138    service: Arc<dyn HttpService>,
139    /// Retry configuration stored for future use by [`CloudRequestBuilder::send`].
140    ///
141    /// Currently the retry policy is applied inside credential providers (token
142    /// refresh), but is not yet applied to user-initiated requests. Stored here
143    /// so that a `with_retry_config()` builder method can expose it without a
144    /// breaking change.
145    pub retry_config: RetryConfig,
146    #[cfg(feature = "recording")]
147    recording: Option<RecordingState>,
148}
149
150impl CloudClient {
151    fn new_with_signer(
152        signer: SharedSigner,
153        reqwest_client: Client,
154        service: Arc<dyn HttpService>,
155        retry_config: RetryConfig,
156    ) -> Self {
157        Self {
158            signer,
159            reqwest_client,
160            service,
161            retry_config,
162            #[cfg(feature = "recording")]
163            recording: None,
164        }
165    }
166
167    /// Create a new client with AWS credentials.
168    ///
169    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
170    /// will be spawned on the given runtime handle.
171    pub fn new_aws<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
172    where
173        I: IntoIterator<Item = (K, V)>,
174        K: AsRef<str>,
175        V: Into<String>,
176    {
177        let config = options
178            .into_iter()
179            .fold(
180                aws::AmazonBuilder::new(),
181                |builder, (key, value)| match key.as_ref().parse() {
182                    Ok(k) => builder.with_config(k, value),
183                    Err(_) => builder,
184                },
185            )
186            .build(runtime)?;
187
188        let reqwest_client = config.client_options.client()?;
189        let service = make_service(reqwest_client.clone(), runtime);
190        let retry_config = config.retry_config.clone();
191        Ok(Self::new_with_signer(
192            Arc::new(config),
193            reqwest_client,
194            service,
195            retry_config,
196        ))
197    }
198
199    /// Create a new client with Google Cloud credentials.
200    ///
201    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
202    /// will be spawned on the given runtime handle.
203    pub fn new_google<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
204    where
205        I: IntoIterator<Item = (K, V)>,
206        K: AsRef<str>,
207        V: Into<String>,
208    {
209        let config = options
210            .into_iter()
211            .fold(
212                gcp::GoogleBuilder::new(),
213                |builder, (key, value)| match key.as_ref().parse() {
214                    Ok(k) => builder.with_config(k, value),
215                    Err(_) => builder,
216                },
217            )
218            .build(runtime)?;
219
220        let reqwest_client = config.client_options.client()?;
221        let service = make_service(reqwest_client.clone(), runtime);
222        let retry_config = config.retry_config.clone();
223        Ok(Self::new_with_signer(
224            Arc::new(config),
225            reqwest_client,
226            service,
227            retry_config,
228        ))
229    }
230
231    /// Create a new client with Azure credentials.
232    ///
233    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
234    /// will be spawned on the given runtime handle.
235    pub fn new_azure<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
236    where
237        I: IntoIterator<Item = (K, V)>,
238        K: AsRef<str>,
239        V: Into<String>,
240    {
241        let config = options
242            .into_iter()
243            .fold(
244                azure::AzureBuilder::new(),
245                |builder, (key, value)| match key.as_ref().parse() {
246                    Ok(k) => builder.with_config(k, value),
247                    Err(_) => builder,
248                },
249            )
250            .build(runtime)?;
251
252        let reqwest_client = config.client_options.client()?;
253        let service = make_service(reqwest_client.clone(), runtime);
254        let retry_config = config.retry_config.clone();
255        Ok(Self::new_with_signer(
256            Arc::new(config),
257            reqwest_client,
258            service,
259            retry_config,
260        ))
261    }
262
263    /// Create a new client with Databricks credentials.
264    ///
265    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
266    /// will be spawned on the given runtime handle.
267    pub fn new_databricks<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
268    where
269        I: IntoIterator<Item = (K, V)>,
270        K: AsRef<str>,
271        V: Into<String>,
272    {
273        use databricks::DatabricksBuilder;
274
275        let config = options
276            .into_iter()
277            .fold(
278                DatabricksBuilder::new(),
279                |builder, (key, value)| match key.as_ref().parse() {
280                    Ok(k) => builder.with_config(k, value),
281                    Err(_) => builder,
282                },
283            )
284            .build(runtime)?;
285
286        let reqwest_client = config.client_options.client()?;
287        let service = make_service(reqwest_client.clone(), runtime);
288        let retry_config = config.retry_config.clone();
289        Ok(Self::new_with_signer(
290            Arc::new(config),
291            reqwest_client,
292            service,
293            retry_config,
294        ))
295    }
296
297    /// Create a new client with a personal access token.
298    pub fn new_with_token(token: impl ToString) -> Self {
299        let reqwest_client = Client::new();
300        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
301        Self::new_with_signer(
302            Arc::new(BearerTokenSigner {
303                token: token.to_string(),
304            }),
305            reqwest_client,
306            service,
307            RetryConfig::default(),
308        )
309    }
310
311    /// Create a new unauthenticated client.
312    pub fn new_unauthenticated() -> Self {
313        let reqwest_client = Client::new();
314        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
315        Self::new_with_signer(
316            Arc::new(NoopSigner),
317            reqwest_client,
318            service,
319            RetryConfig::default(),
320        )
321    }
322
323    /// Route all HTTP I/O through the given runtime handle.
324    ///
325    /// This is useful for simple constructors (`new_with_token`, `new_unauthenticated`)
326    /// where no credential providers need to perform HTTP I/O. For cloud provider
327    /// constructors, pass the handle at construction time instead.
328    pub fn with_runtime(mut self, handle: Handle) -> Self {
329        self.service = Arc::new(SpawnService::new(self.service, handle));
330        self
331    }
332
333    /// Replace the [`HttpService`] used for request execution.
334    pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
335        self.service = service;
336        self
337    }
338
339    /// Start building a request for the given HTTP `method` and `url`.
340    ///
341    /// The returned [`CloudRequestBuilder`] borrows this client's signer, so the
342    /// request is signed for the configured provider when
343    /// [`CloudRequestBuilder::send`] is called.
344    pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> CloudRequestBuilder {
345        CloudRequestBuilder {
346            builder: self.reqwest_client.request(method, url),
347            client: self.clone(),
348            #[cfg(feature = "recording")]
349            out_dir: self.recording.as_ref().map(|r| r.out_dir.clone()),
350        }
351    }
352
353    /// Start building a `GET` request for `url`. Shortcut for [`request`](Self::request).
354    pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
355        self.request(Method::GET, url)
356    }
357
358    /// Start building a `POST` request for `url`. Shortcut for [`request`](Self::request).
359    pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
360        self.request(Method::POST, url)
361    }
362
363    /// Start building a `PUT` request for `url`. Shortcut for [`request`](Self::request).
364    pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
365        self.request(Method::PUT, url)
366    }
367
368    /// Start building a `DELETE` request for `url`. Shortcut for [`request`](Self::request).
369    pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
370        self.request(Method::DELETE, url)
371    }
372
373    /// Start building a `HEAD` request for `url`. Shortcut for [`request`](Self::request).
374    pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
375        self.request(Method::HEAD, url)
376    }
377
378    /// Start building a `PATCH` request for `url`. Shortcut for [`request`](Self::request).
379    pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
380        self.request(Method::PATCH, url)
381    }
382
383    /// Start building an `OPTIONS` request for `url`. Shortcut for [`request`](Self::request).
384    pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
385        self.request(Method::OPTIONS, url)
386    }
387
388    /// Start building a `TRACE` request for `url`. Shortcut for [`request`](Self::request).
389    pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
390        self.request(Method::TRACE, url)
391    }
392
393    /// Start building a `CONNECT` request for `url`. Shortcut for [`request`](Self::request).
394    pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
395        self.request(Method::CONNECT, url)
396    }
397
398    /// Enable request/response recording, writing each interaction to `out_dir`.
399    ///
400    /// Once set, every request sent through this client is captured to a
401    /// numbered JSON file (`0000.json`, `0001.json`, …) under `out_dir` for
402    /// later test replay. Sensitive response headers (`authorization`,
403    /// `x-amz-security-token`, `cookie`, and similar) are replaced with
404    /// `"<REDACTED>"` before anything is written to disk, so
405    /// recordings never persist bearer tokens or signing secrets. Request
406    /// headers are not recorded at all.
407    ///
408    /// `out_dir` is canonicalized eagerly, so it must already exist.
409    ///
410    /// Only available when the `recording` feature is enabled.
411    ///
412    /// # Errors
413    ///
414    /// Returns the [`io::Error`](std::io::Error) from canonicalizing `out_dir`,
415    /// for example if the directory does not exist or is not accessible.
416    #[cfg(feature = "recording")]
417    pub fn set_recording_dir(&mut self, out_dir: std::path::PathBuf) -> Result<(), std::io::Error> {
418        let out_dir = std::fs::canonicalize(out_dir)?;
419        self.recording = Some(RecordingState {
420            out_dir,
421            counter: Arc::new(AtomicU64::new(0)),
422        });
423        Ok(())
424    }
425}
426
427/// A builder for a single request issued through a [`CloudClient`].
428///
429/// Created by [`CloudClient::request`] and the per-verb shortcuts such as
430/// [`CloudClient::get`]. Configure the request with the builder methods, then
431/// call [`send`](Self::send) to sign and dispatch it.
432pub struct CloudRequestBuilder {
433    builder: RequestBuilder,
434    client: CloudClient,
435    #[cfg(feature = "recording")]
436    out_dir: Option<PathBuf>,
437}
438
439impl CloudRequestBuilder {
440    /// Add a `Header` to this Request.
441    pub fn header<K, V>(mut self, key: K, value: V) -> CloudRequestBuilder
442    where
443        HeaderName: TryFrom<K>,
444        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
445        HeaderValue: TryFrom<V>,
446        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
447    {
448        self.builder = self.builder.header(key, value);
449        self
450    }
451
452    /// Add a set of Headers to the existing ones on this Request.
453    ///
454    /// The headers will be merged in to any already set.
455    pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
456        self.builder = self.builder.headers(headers);
457        self
458    }
459
460    /// Set the request body.
461    pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
462        self.builder = self.builder.body(body);
463        self
464    }
465
466    /// Enables a request timeout.
467    ///
468    /// The timeout is applied from when the request starts connecting until the
469    /// response body has finished. It affects only this request and overrides
470    /// the timeout configured using `ClientBuilder::timeout()`.
471    pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
472        self.builder = self.builder.timeout(timeout);
473        self
474    }
475
476    /// Modify the query string of the URL.
477    ///
478    /// Modifies the URL of this request, adding the parameters provided.
479    /// This method appends and does not overwrite. This means that it can
480    /// be called multiple times and that existing query parameters are not
481    /// overwritten if the same key is used. The key will simply show up
482    /// twice in the query string.
483    /// Calling `.query(&[("foo", "a"), ("foo", "b")])` gives `"foo=a&foo=b"`.
484    ///
485    /// # Note
486    /// This method does not support serializing a single key-value
487    /// pair. Instead of using `.query(("key", "val"))`, use a sequence, such
488    /// as `.query(&[("key", "val")])`. It's also possible to serialize structs
489    /// and maps into a key-value pair.
490    ///
491    /// # Errors
492    /// This method will fail if the object you provide cannot be serialized
493    /// into a query string.
494    pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
495        self.builder = self.builder.query(query);
496        self
497    }
498
499    /// Send a JSON body.
500    ///
501    /// # Errors
502    ///
503    /// Serialization can fail if `T`'s implementation of `Serialize` decides to
504    /// fail, or if `T` contains a map with non-string keys.
505    pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
506        self.builder = self.builder.json(json);
507        self
508    }
509
510    /// Sign and send the request, returning the [`reqwest::Response`].
511    ///
512    /// The request is first passed to the client's [`RequestSigner`], which
513    /// attaches the provider-specific authentication (e.g. AWS SigV4 headers or
514    /// an `Authorization: Bearer` header), refreshing any cached credential as
515    /// needed. The signed request is then dispatched through the client's
516    /// [`HttpService`].
517    ///
518    /// When the `recording` feature is enabled and a recording directory has
519    /// been configured via `CloudClient::set_recording_dir`, the request and
520    /// response are also written to disk (with sensitive headers redacted)
521    /// before the response is returned to the caller.
522    ///
523    /// # Errors
524    ///
525    /// Returns an [`Error`] if signing fails, if the request cannot be built, or
526    /// if the underlying HTTP transport returns an error. A non-success HTTP
527    /// status code is not itself an error; inspect [`reqwest::Response::status`]
528    /// on the returned response.
529    pub async fn send(mut self) -> Result<reqwest::Response> {
530        self.builder = self.client.signer.sign(self.builder).await?;
531
532        #[cfg(not(feature = "recording"))]
533        {
534            let request = self.builder.build()?;
535            let response = self.client.service.call(request).await?;
536            Ok(response)
537        }
538        #[cfg(feature = "recording")]
539        {
540            let response = send_record(self).await?;
541            Ok(response)
542        }
543    }
544}
545
546#[cfg(feature = "recording")]
547#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
548pub struct RequestResponseInfo {
549    pub request: RequestInfo,
550    pub response: ResponseInfo,
551}
552
553#[cfg(feature = "recording")]
554#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
555pub struct RequestInfo {
556    pub method: String,
557    pub url_path: String,
558    pub body: Option<String>,
559}
560
561#[cfg(feature = "recording")]
562#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
563pub struct ResponseInfo {
564    pub status: u16,
565    pub headers: HashMap<String, String>,
566    pub body: Option<String>,
567}
568
569/// Header names whose values must never appear in recording files.
570///
571/// These headers carry bearer tokens, signing secrets, or session tokens.
572/// They are replaced with `"<REDACTED>"` before any recording is written to disk.
573#[cfg(feature = "recording")]
574const SENSITIVE_HEADERS: &[&str] = &[
575    "authorization",
576    "x-amz-security-token",
577    "x-amz-content-sha256",
578    "x-databricks-authorization",
579    "x-ms-identity-principal-id",
580    "x-goog-iam-credentials-token",
581    "cookie",
582    "set-cookie",
583];
584
585#[cfg(feature = "recording")]
586fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
587    headers
588        .iter()
589        .map(|(k, v)| {
590            let v = if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) {
591                "<REDACTED>".to_string()
592            } else {
593                v.clone()
594            };
595            (k.clone(), v)
596        })
597        .collect()
598}
599
600#[cfg(feature = "recording")]
601async fn send_record(builder: CloudRequestBuilder) -> Result<reqwest::Response> {
602    let Some(out_dir) = builder.out_dir else {
603        let request = builder.builder.build().expect("request to be valid");
604        return builder.client.service.call(request).await;
605    };
606    let (_client, request) = builder.builder.build_split();
607    let request = request.expect("request to be valid");
608
609    let request_info = RequestInfo {
610        method: request.method().as_str().to_string(),
611        url_path: {
612            let url = request.url();
613            match url.query() {
614                Some(query) => format!("{}?{}", url.path(), query),
615                None => url.path().to_string(),
616            }
617        },
618        body: request
619            .body()
620            .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())),
621    };
622
623    let response = builder.client.service.call(request).await?;
624
625    // Record the response
626    let status = response.status().as_u16();
627    let raw_headers: HashMap<String, String> = response
628        .headers()
629        .iter()
630        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
631        .collect();
632
633    // Get response body while preserving it for the caller
634    let response_bytes = response.bytes().await?;
635    let response_body = if response_bytes.is_empty() {
636        None
637    } else {
638        Some(String::from_utf8_lossy(&response_bytes).to_string())
639    };
640
641    let recording = RequestResponseInfo {
642        request: request_info,
643        response: ResponseInfo {
644            status,
645            // Redact sensitive headers before writing to disk
646            headers: redact_headers(&raw_headers),
647            body: response_body,
648        },
649    };
650
651    let counter = builder
652        .client
653        .recording
654        .as_ref()
655        .map(|r| r.counter.fetch_add(1, Ordering::SeqCst))
656        .unwrap_or(0);
657    let file_path = out_dir.join(format!("{:04}.json", counter));
658    if let Err(e) = std::fs::File::create(&file_path)
659        .and_then(|f| serde_json::to_writer_pretty(f, &recording).map_err(Into::into))
660    {
661        tracing::warn!(
662            "Failed to write recording to {}: {}",
663            file_path.display(),
664            e
665        );
666    }
667
668    // Return a new response built from the recorded data, using the raw (unredacted) headers
669    let mut mock_response = http::Response::builder().status(status);
670    for (k, v) in &raw_headers {
671        mock_response = mock_response.header(k, v);
672    }
673    let mock_response = mock_response
674        .body(response_bytes)
675        .expect("valid status code and headers");
676
677    Ok(reqwest::Response::from(mock_response))
678}
679
680#[cfg(all(test, feature = "recording"))]
681mod tests {
682    use super::*;
683    use std::fs;
684    use tempfile::TempDir;
685
686    #[tokio::test]
687    async fn test_request_response_recording() {
688        // Create a temporary directory for recordings
689        let temp_dir = TempDir::new().unwrap();
690        let temp_path = temp_dir.path().to_path_buf();
691
692        // Set up a mock server
693        let mut server = mockito::Server::new_async().await;
694        let mock = server
695            .mock("GET", "/test")
696            .with_status(200)
697            .with_header("content-type", "application/json")
698            .with_body(r#"{"message": "Hello, World!"}"#)
699            .create_async()
700            .await;
701
702        // Create a cloud client with recording enabled
703        let mut client = CloudClient::new_unauthenticated();
704        client.set_recording_dir(temp_path.clone()).unwrap();
705
706        // Make a request
707        let url = format!("{}/test", server.url());
708        let response = client.get(&url).send().await.unwrap();
709
710        // Verify the response is correct
711        assert_eq!(response.status(), 200);
712        let body = response.text().await.unwrap();
713        assert_eq!(body, r#"{"message": "Hello, World!"}"#);
714
715        // Verify that a recording file was created
716        let recordings: Vec<_> = fs::read_dir(&temp_path)
717            .unwrap()
718            .filter_map(|entry| {
719                let entry = entry.ok()?;
720                let path = entry.path();
721                if path.extension()? == "json" {
722                    Some(path)
723                } else {
724                    None
725                }
726            })
727            .collect();
728
729        assert_eq!(recordings.len(), 1, "Expected exactly one recording file");
730
731        // Read and verify the recording content
732        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
733        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
734
735        // Verify request information
736        assert_eq!(recording.request.method, "GET");
737        assert_eq!(recording.request.url_path, "/test");
738        assert_eq!(recording.request.body, None);
739
740        // Verify response information
741        assert_eq!(recording.response.status, 200);
742        assert_eq!(
743            recording.response.headers.get("content-type").unwrap(),
744            "application/json"
745        );
746        assert_eq!(
747            recording.response.body.as_ref().unwrap(),
748            r#"{"message": "Hello, World!"}"#
749        );
750
751        mock.assert_async().await;
752    }
753
754    #[tokio::test]
755    async fn test_recording_with_request_body() {
756        // Create a temporary directory for recordings
757        let temp_dir = TempDir::new().unwrap();
758        let temp_path = temp_dir.path().to_path_buf();
759
760        // Set up a mock server
761        let mut server = mockito::Server::new_async().await;
762        let mock = server
763            .mock("POST", "/create")
764            .with_status(201)
765            .with_header("location", "/resource/123")
766            .with_body(r#"{"id": 123, "status": "created"}"#)
767            .create_async()
768            .await;
769
770        // Create a cloud client with recording enabled
771        let mut client = CloudClient::new_unauthenticated();
772        client.set_recording_dir(temp_path.clone()).unwrap();
773
774        // Make a POST request with body
775        let url = format!("{}/create", server.url());
776        let response = client
777            .post(&url)
778            .json(&serde_json::json!({"name": "test resource"}))
779            .send()
780            .await
781            .unwrap();
782
783        // Verify the response
784        assert_eq!(response.status(), 201);
785
786        // Verify that a recording file was created
787        let recordings: Vec<_> = fs::read_dir(&temp_path)
788            .unwrap()
789            .filter_map(|entry| {
790                let entry = entry.ok()?;
791                let path = entry.path();
792                if path.extension()? == "json" {
793                    Some(path)
794                } else {
795                    None
796                }
797            })
798            .collect();
799
800        assert_eq!(recordings.len(), 1);
801
802        // Read and verify the recording content
803        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
804        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
805
806        // Verify request information
807        assert_eq!(recording.request.method, "POST");
808        assert_eq!(recording.request.url_path, "/create");
809        assert!(recording.request.body.is_some());
810        assert!(recording.request.body.unwrap().contains("test resource"));
811
812        // Verify response information
813        assert_eq!(recording.response.status, 201);
814        assert_eq!(
815            recording.response.headers.get("location").unwrap(),
816            "/resource/123"
817        );
818        assert!(recording.response.body.unwrap().contains("created"));
819
820        mock.assert_async().await;
821    }
822
823    #[tokio::test]
824    async fn test_counter_based_file_naming() {
825        // Create a temporary directory for recordings
826        let temp_dir = TempDir::new().unwrap();
827        let temp_path = temp_dir.path().to_path_buf();
828
829        // Set up a mock server
830        let mut server = mockito::Server::new_async().await;
831        let mock1 = server
832            .mock("GET", "/first")
833            .with_status(200)
834            .with_body("first response")
835            .create_async()
836            .await;
837        let mock2 = server
838            .mock("GET", "/second")
839            .with_status(200)
840            .with_body("second response")
841            .create_async()
842            .await;
843
844        // Create a cloud client with recording enabled
845        let mut client = CloudClient::new_unauthenticated();
846        client.set_recording_dir(temp_path.clone()).unwrap();
847
848        // Make multiple requests
849        let url1 = format!("{}/first", server.url());
850        let url2 = format!("{}/second", server.url());
851
852        let _response1 = client.get(&url1).send().await.unwrap();
853        let _response2 = client.get(&url2).send().await.unwrap();
854
855        // Verify that files are named with incrementing counter
856        let mut recordings: Vec<_> = fs::read_dir(&temp_path)
857            .unwrap()
858            .filter_map(|entry| {
859                let entry = entry.ok()?;
860                let path = entry.path();
861                if path.extension()? == "json" {
862                    Some(path)
863                } else {
864                    None
865                }
866            })
867            .collect();
868
869        recordings.sort();
870        assert_eq!(recordings.len(), 2);
871
872        // Check that files are named 000000.json and 000001.json
873        assert!(recordings[0].file_name().unwrap().to_str().unwrap() == "0000.json");
874        assert!(recordings[1].file_name().unwrap().to_str().unwrap() == "0001.json");
875
876        // Verify content matches the order of requests
877        let first_content = fs::read_to_string(&recordings[0]).unwrap();
878        let first_recording: RequestResponseInfo = serde_json::from_str(&first_content).unwrap();
879        assert_eq!(first_recording.request.url_path, "/first");
880        assert_eq!(
881            first_recording.response.body.as_ref().unwrap(),
882            "first response"
883        );
884
885        let second_content = fs::read_to_string(&recordings[1]).unwrap();
886        let second_recording: RequestResponseInfo = serde_json::from_str(&second_content).unwrap();
887        assert_eq!(second_recording.request.url_path, "/second");
888        assert_eq!(
889            second_recording.response.body.as_ref().unwrap(),
890            "second response"
891        );
892
893        mock1.assert_async().await;
894        mock2.assert_async().await;
895    }
896
897    #[tokio::test]
898    async fn test_query_parameter_recording() {
899        // Create a temporary directory for recordings
900        let temp_dir = TempDir::new().unwrap();
901        let temp_path = temp_dir.path().to_path_buf();
902
903        // Start a mock server
904        let mut server = mockito::Server::new_async().await;
905
906        // Create a mock that expects query parameters
907        let mock = server
908            .mock("GET", "/catalogs?max_results=10&page_token=abc123")
909            .with_status(200)
910            .with_header("content-type", "application/json")
911            .with_body(r#"{"catalogs": []}"#)
912            .create_async()
913            .await;
914
915        // Create a client with recording enabled
916        let mut client = CloudClient::new_unauthenticated();
917        client.set_recording_dir(temp_path.clone()).unwrap();
918
919        // Make a request with query parameters
920        let url = format!("{}/catalogs?max_results=10&page_token=abc123", server.url());
921        let response = client.get(&url).send().await.unwrap();
922
923        assert!(response.status().is_success());
924
925        // Verify that the recording file was created
926        let recordings: Vec<_> = fs::read_dir(&temp_path)
927            .unwrap()
928            .filter_map(|entry| {
929                let entry = entry.ok()?;
930                let path = entry.path();
931                if path.extension()? == "json" {
932                    Some(path)
933                } else {
934                    None
935                }
936            })
937            .collect();
938
939        assert_eq!(recordings.len(), 1);
940
941        // Read and verify the recording content includes query parameters
942        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
943        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
944
945        // Verify request information includes query parameters
946        assert_eq!(recording.request.method, "GET");
947        assert_eq!(
948            recording.request.url_path,
949            "/catalogs?max_results=10&page_token=abc123"
950        );
951        assert_eq!(recording.request.body, None);
952
953        // Verify response information
954        assert_eq!(recording.response.status, 200);
955        assert_eq!(
956            recording.response.body.as_ref().unwrap(),
957            r#"{"catalogs": []}"#
958        );
959
960        mock.assert_async().await;
961    }
962
963    /// Verify that the bearer token injected by a signed request does not appear
964    /// in the recording file. Request headers are currently not recorded, so this
965    /// test confirms the token does not leak via any other path (e.g. echoed back
966    /// in a response header or response body).
967    #[tokio::test]
968    async fn test_recording_does_not_contain_bearer_token_value() {
969        let temp_dir = TempDir::new().unwrap();
970        let temp_path = temp_dir.path().to_path_buf();
971
972        let mut server = mockito::Server::new_async().await;
973        let mock = server
974            .mock("GET", "/secret")
975            .match_header("authorization", mockito::Matcher::Any)
976            .with_status(200)
977            // Server does NOT echo the token back — simulates a well-behaved API
978            .with_body("ok")
979            .create_async()
980            .await;
981
982        let mut client = CloudClient::new_with_token("super-secret-token-12345");
983        client.set_recording_dir(temp_path.clone()).unwrap();
984
985        let url = format!("{}/secret", server.url());
986        client.get(&url).send().await.unwrap();
987
988        let recording_path = temp_path.join("0000.json");
989        let content = fs::read_to_string(&recording_path).unwrap();
990
991        // The raw token must not appear in the file at all
992        assert!(
993            !content.contains("super-secret-token-12345"),
994            "raw bearer token leaked into recording: {content}"
995        );
996
997        mock.assert_async().await;
998    }
999
1000    /// Verify that sensitive headers returned by the server (e.g. a reflected
1001    /// Authorization or AWS security token) are redacted before being written
1002    /// to the recording file.
1003    #[tokio::test]
1004    async fn test_recording_redacts_sensitive_response_headers() {
1005        let temp_dir = TempDir::new().unwrap();
1006        let temp_path = temp_dir.path().to_path_buf();
1007
1008        let mut server = mockito::Server::new_async().await;
1009        // Simulate a server that echoes a sensitive header back in its response
1010        let mock = server
1011            .mock("GET", "/s3")
1012            .with_status(200)
1013            .with_header("x-amz-security-token", "AQoXnyc4LLI2AJvUAMOGAR8a1234567890")
1014            .with_header("authorization", "Bearer should-be-redacted")
1015            .with_body("{}")
1016            .create_async()
1017            .await;
1018
1019        let mut client = CloudClient::new_unauthenticated();
1020        client.set_recording_dir(temp_path.clone()).unwrap();
1021
1022        let url = format!("{}/s3", server.url());
1023        client.get(&url).send().await.unwrap();
1024
1025        let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1026        assert!(
1027            !content.contains("AQoXnyc4LLI2AJvUAMOGAR8a1234567890"),
1028            "x-amz-security-token leaked into recording: {content}"
1029        );
1030        assert!(
1031            !content.contains("should-be-redacted"),
1032            "Authorization value leaked into recording: {content}"
1033        );
1034        // Both headers should be replaced with the redaction sentinel
1035        assert_eq!(
1036            content.matches("<REDACTED>").count(),
1037            2,
1038            "expected 2 <REDACTED> entries in recording: {content}"
1039        );
1040
1041        mock.assert_async().await;
1042    }
1043
1044    /// Verify that a recording produced with redacted headers can still be parsed.
1045    #[tokio::test]
1046    async fn test_recording_remains_valid_json_after_redaction() {
1047        let temp_dir = TempDir::new().unwrap();
1048        let temp_path = temp_dir.path().to_path_buf();
1049
1050        let mut server = mockito::Server::new_async().await;
1051        let _mock = server
1052            .mock("GET", "/check")
1053            .with_status(200)
1054            .with_header("authorization", "Bearer should-be-redacted")
1055            .with_body(r#"{"ok":true}"#)
1056            .create_async()
1057            .await;
1058
1059        let mut client = CloudClient::new_unauthenticated();
1060        client.set_recording_dir(temp_path.clone()).unwrap();
1061
1062        let url = format!("{}/check", server.url());
1063        client.get(&url).send().await.unwrap();
1064
1065        let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1066        // Must parse cleanly as a RequestResponseInfo
1067        let parsed: RequestResponseInfo = serde_json::from_str(&content)
1068            .expect("recording file must be valid JSON even after redaction");
1069        assert_eq!(parsed.response.status, 200);
1070    }
1071}