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