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;
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
94/// A shared, cloneable signer for a `CloudClient`.
95type SharedSigner = Arc<dyn RequestSigner>;
96
97/// A no-op signer used by `new_unauthenticated`.
98#[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/// A signer that injects a static bearer token.
111#[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/// An authenticated HTTP client for cloud provider APIs.
134///
135/// Created via the provider-specific constructors [`CloudClient::new_aws`],
136/// [`CloudClient::new_azure`], [`CloudClient::new_google`], or
137/// [`CloudClient::new_databricks`], or the simpler [`CloudClient::new_with_token`]
138/// and [`CloudClient::new_unauthenticated`].
139#[derive(Clone)]
140pub struct CloudClient {
141    signer: SharedSigner,
142    reqwest_client: Client,
143    service: Arc<dyn HttpService>,
144    /// Retry configuration applied to requests sent through this client.
145    ///
146    /// Used both by credential providers (token refresh) and by user-initiated
147    /// requests via [`CloudRequestBuilder::send`] and
148    /// [`CloudClient::sign_and_send`]. Override with
149    /// [`CloudClient::with_retry_config`].
150    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    /// Create a new client with AWS credentials.
173    ///
174    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
175    /// will be spawned on the given runtime handle.
176    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    /// Create a new client with Google Cloud credentials.
205    ///
206    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
207    /// will be spawned on the given runtime handle.
208    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    /// Create a new client with Azure credentials.
237    ///
238    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
239    /// will be spawned on the given runtime handle.
240    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    /// Create a new client with Databricks credentials.
269    ///
270    /// Options are applied to a fresh [`databricks::DatabricksBuilder`]; unknown
271    /// keys are ignored. Use [`new_databricks_from_builder`] to start from
272    /// [`databricks::DatabricksBuilder::from_env`] instead, which also loads
273    /// `.databrickscfg` profiles.
274    ///
275    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
276    /// will be spawned on the given runtime handle.
277    ///
278    /// [`new_databricks_from_builder`]: CloudClient::new_databricks_from_builder
279    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    /// Create a new client with Databricks credentials from a configured builder.
296    ///
297    /// Unlike [`new_databricks`], this keeps everything the builder already
298    /// resolved — notably the `.databrickscfg` profile values loaded by
299    /// [`databricks::DatabricksBuilder::from_env`].
300    ///
301    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
302    /// will be spawned on the given runtime handle.
303    ///
304    /// [`new_databricks`]: CloudClient::new_databricks
305    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    /// Create a client that signs requests with tokens from `provider`.
323    ///
324    /// See [`bearer::BearerAuth`] for providers that work against any API, not
325    /// just Databricks. `options` and `retry` configure requests sent through
326    /// this client; if `runtime` is provided, their I/O is spawned on it.
327    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    /// Create a new client with a personal access token.
344    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    /// Create a new unauthenticated client.
358    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    /// Route all HTTP I/O through the given runtime handle.
370    ///
371    /// This is useful for simple constructors (`new_with_token`, `new_unauthenticated`)
372    /// where no credential providers need to perform HTTP I/O. For cloud provider
373    /// constructors, pass the handle at construction time instead.
374    pub fn with_runtime(mut self, handle: Handle) -> Self {
375        self.service = Arc::new(SpawnService::new(self.service, handle));
376        self
377    }
378
379    /// Replace the [`HttpService`] used for request execution.
380    pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
381        self.service = service;
382        self
383    }
384
385    /// Override the [`RetryConfig`] applied to requests sent through this client.
386    ///
387    /// Requests issued via [`CloudRequestBuilder::send`] and
388    /// [`sign_and_send`](Self::sign_and_send) are retried per this config
389    /// (exponential backoff with jitter; safe/idempotent requests are also
390    /// retried on timeout). The [default](RetryConfig::default) allows up to 10
391    /// retries — lower it for latency-sensitive interactive calls.
392    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
393        self.retry_config = retry_config;
394        self
395    }
396
397    /// Sign an already-built [`reqwest::Request`] and dispatch it, with retries.
398    ///
399    /// This is the request-level counterpart to the [`CloudRequestBuilder`]
400    /// flow: it applies the client's [`RequestSigner`] (refreshing credentials
401    /// as needed) and sends the request through the configured [`HttpService`],
402    /// retrying transient failures per the client's [`RetryConfig`]. It is
403    /// useful when the request is produced elsewhere (for example by a protocol
404    /// stack such as ConnectRPC) and only needs authentication and transport.
405    ///
406    /// The request body must be in memory (not a stream): retries clone the
407    /// request, and provider signers such as AWS SigV4 hash the body to sign it.
408    ///
409    /// # Errors
410    ///
411    /// Returns an [`Error`] if signing fails or if the request ultimately fails
412    /// after exhausting retries. A non-success HTTP status code is not itself an
413    /// error; inspect [`reqwest::Response::status`] on the returned response.
414    pub async fn sign_and_send(&self, request: reqwest::Request) -> Result<reqwest::Response> {
415        // The signer operates on a `RequestBuilder`; reconstruct one from the
416        // pre-built request (cloning the in-memory body) so the existing
417        // sign + retry path applies unchanged.
418        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    /// Start building a request for the given HTTP `method` and `url`.
434    ///
435    /// The returned [`CloudRequestBuilder`] borrows this client's signer, so the
436    /// request is signed for the configured provider when
437    /// [`CloudRequestBuilder::send`] is called.
438    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    /// Start building a `GET` request for `url`. Shortcut for [`request`](Self::request).
448    pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
449        self.request(Method::GET, url)
450    }
451
452    /// Start building a `POST` request for `url`. Shortcut for [`request`](Self::request).
453    pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
454        self.request(Method::POST, url)
455    }
456
457    /// Start building a `PUT` request for `url`. Shortcut for [`request`](Self::request).
458    pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
459        self.request(Method::PUT, url)
460    }
461
462    /// Start building a `DELETE` request for `url`. Shortcut for [`request`](Self::request).
463    pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
464        self.request(Method::DELETE, url)
465    }
466
467    /// Start building a `HEAD` request for `url`. Shortcut for [`request`](Self::request).
468    pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
469        self.request(Method::HEAD, url)
470    }
471
472    /// Start building a `PATCH` request for `url`. Shortcut for [`request`](Self::request).
473    pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
474        self.request(Method::PATCH, url)
475    }
476
477    /// Start building an `OPTIONS` request for `url`. Shortcut for [`request`](Self::request).
478    pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
479        self.request(Method::OPTIONS, url)
480    }
481
482    /// Start building a `TRACE` request for `url`. Shortcut for [`request`](Self::request).
483    pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
484        self.request(Method::TRACE, url)
485    }
486
487    /// Start building a `CONNECT` request for `url`. Shortcut for [`request`](Self::request).
488    pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
489        self.request(Method::CONNECT, url)
490    }
491
492    /// Enable request/response recording, writing each interaction to `out_dir`.
493    ///
494    /// Once set, every request sent through this client is captured to a
495    /// numbered JSON file (`0000.json`, `0001.json`, …) under `out_dir` for
496    /// later test replay. Sensitive response headers (`authorization`,
497    /// `x-amz-security-token`, `cookie`, and similar) are replaced with
498    /// `"<REDACTED>"` before anything is written to disk, so
499    /// recordings never persist bearer tokens or signing secrets. Request
500    /// headers are not recorded at all.
501    ///
502    /// `out_dir` is canonicalized eagerly, so it must already exist.
503    ///
504    /// Only available when the `recording` feature is enabled.
505    ///
506    /// # Errors
507    ///
508    /// Returns the [`io::Error`](std::io::Error) from canonicalizing `out_dir`,
509    /// for example if the directory does not exist or is not accessible.
510    #[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
521/// A builder for a single request issued through a [`CloudClient`].
522///
523/// Created by [`CloudClient::request`] and the per-verb shortcuts such as
524/// [`CloudClient::get`]. Configure the request with the builder methods, then
525/// call [`send`](Self::send) to sign and dispatch it.
526pub struct CloudRequestBuilder {
527    builder: RequestBuilder,
528    client: CloudClient,
529    #[cfg(feature = "recording")]
530    out_dir: Option<PathBuf>,
531}
532
533impl CloudRequestBuilder {
534    /// Add a `Header` to this Request.
535    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    /// Add a set of Headers to the existing ones on this Request.
547    ///
548    /// The headers will be merged in to any already set.
549    pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
550        self.builder = self.builder.headers(headers);
551        self
552    }
553
554    /// Set the request body.
555    pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
556        self.builder = self.builder.body(body);
557        self
558    }
559
560    /// Enables a request timeout.
561    ///
562    /// The timeout is applied from when the request starts connecting until the
563    /// response body has finished. It affects only this request and overrides
564    /// the timeout configured using `ClientBuilder::timeout()`.
565    pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
566        self.builder = self.builder.timeout(timeout);
567        self
568    }
569
570    /// Modify the query string of the URL.
571    ///
572    /// Modifies the URL of this request, adding the parameters provided.
573    /// This method appends and does not overwrite. This means that it can
574    /// be called multiple times and that existing query parameters are not
575    /// overwritten if the same key is used. The key will simply show up
576    /// twice in the query string.
577    /// Calling `.query(&[("foo", "a"), ("foo", "b")])` gives `"foo=a&foo=b"`.
578    ///
579    /// # Note
580    /// This method does not support serializing a single key-value
581    /// pair. Instead of using `.query(("key", "val"))`, use a sequence, such
582    /// as `.query(&[("key", "val")])`. It's also possible to serialize structs
583    /// and maps into a key-value pair.
584    ///
585    /// # Errors
586    /// This method will fail if the object you provide cannot be serialized
587    /// into a query string.
588    pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
589        self.builder = self.builder.query(query);
590        self
591    }
592
593    /// Send a JSON body.
594    ///
595    /// # Errors
596    ///
597    /// Serialization can fail if `T`'s implementation of `Serialize` decides to
598    /// fail, or if `T` contains a map with non-string keys.
599    pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
600        self.builder = self.builder.json(json);
601        self
602    }
603
604    /// Sign and send the request, returning the [`reqwest::Response`].
605    ///
606    /// The request is first passed to the client's [`RequestSigner`], which
607    /// attaches the provider-specific authentication (e.g. AWS SigV4 headers or
608    /// an `Authorization: Bearer` header), refreshing any cached credential as
609    /// needed. The signed request is then dispatched through the client's
610    /// [`HttpService`], retrying transient failures per the client's
611    /// [`RetryConfig`] and mapping a non-success status to the matching
612    /// [`Error`].
613    ///
614    /// When the `recording` feature is enabled and a recording directory has
615    /// been configured via `CloudClient::set_recording_dir`, the request and
616    /// response are instead captured to disk (with sensitive headers redacted)
617    /// in a single round-trip — this capture path does **not** retry or map
618    /// status codes to errors, so use the default build for production traffic.
619    ///
620    /// # Errors
621    ///
622    /// Returns an [`Error`] if signing fails, if the request cannot be built, or
623    /// if the underlying HTTP transport returns an error. A non-success HTTP
624    /// status code is not itself an error; inspect [`reqwest::Response::status`]
625    /// on the returned response.
626    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    /// Sign and send the request, preserving the classified non-success result.
644    ///
645    /// This behaves exactly like [`send`](Self::send) — same signing, same
646    /// [`RetryConfig`] semantics (5xx and transport failures retried, 4xx
647    /// returned immediately) — but a non-success outcome is surfaced as the
648    /// [`RetryError`] the retry layer already classified, rather than being
649    /// remapped through [`RetryError::error`] into an opaque [`Error`].
650    ///
651    /// Use this when the caller needs the HTTP **status** and **response body**
652    /// of a failed request — for example to parse a protocol-specific error
653    /// envelope. [`RetryError::status`] and [`RetryError::body`] expose them;
654    /// [`send`](Self::send) discards both by boxing the status into a coarse
655    /// [`Error`] variant (`NotFound`, `AlreadyExists`, …).
656    ///
657    /// When the `recording` feature is enabled the request is captured to disk
658    /// via the same single-shot path as [`send`](Self::send) and the recorded
659    /// [`reqwest::Response`] is returned for any status (no status→error
660    /// mapping), so a non-success status is `Ok(response)` in that build — the
661    /// caller inspects [`reqwest::Response::status`] itself.
662    ///
663    /// # Errors
664    ///
665    /// Returns [`Error`] if signing fails. Otherwise, on a non-success status
666    /// (in the default build) returns [`RetryError`] carrying the status and
667    /// response body; a transport failure surfaces as
668    /// [`RetryError::Reqwest`]/[`RetryError::Transport`].
669    #[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    /// See [`send_raw`](Self::send_raw). Under the `recording` feature this maps
679    /// to the single-shot capture path and returns the response for any status.
680    #[cfg(feature = "recording")]
681    pub async fn send_raw(self) -> Result<reqwest::Response, SendRawError> {
682        send_record(self).await
683    }
684}
685
686/// The error returned by [`CloudRequestBuilder::send_raw`].
687///
688/// Distinguishes a failure to *sign* the request (an [`Error`] from the signer,
689/// e.g. a credential-refresh failure) from a failure of the *request itself*
690/// (a [`RetryError`] carrying the HTTP status and response body). This lets a
691/// caller parse a protocol error envelope from [`RetryError::body`] while still
692/// propagating signing failures faithfully.
693#[cfg(not(feature = "recording"))]
694#[derive(Debug, thiserror::Error)]
695pub enum SendRawError {
696    /// The request could not be signed (e.g. credential resolution failed).
697    #[error(transparent)]
698    Sign(#[from] Error),
699    /// The request was sent but failed; carries the classified status + body.
700    #[error(transparent)]
701    Retry(RetryError),
702}
703
704/// Under the `recording` feature `send_raw` returns the recorded response for
705/// any status, so the only error it can produce is a signing/capture [`Error`].
706#[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/// Header names whose values must never appear in recording files.
733///
734/// These headers carry bearer tokens, signing secrets, or session tokens.
735/// They are replaced with `"<REDACTED>"` before any recording is written to disk.
736#[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    // Record the response
789    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    // Get response body while preserving it for the caller
797    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            // Redact sensitive headers before writing to disk
809            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    // Return a new response built from the recorded data, using the raw (unredacted) headers
832    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        // Create a temporary directory for recordings
852        let temp_dir = TempDir::new().unwrap();
853        let temp_path = temp_dir.path().to_path_buf();
854
855        // Set up a mock server
856        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        // Create a cloud client with recording enabled
866        let mut client = CloudClient::new_unauthenticated();
867        client.set_recording_dir(temp_path.clone()).unwrap();
868
869        // Make a request
870        let url = format!("{}/test", server.url());
871        let response = client.get(&url).send().await.unwrap();
872
873        // Verify the response is correct
874        assert_eq!(response.status(), 200);
875        let body = response.text().await.unwrap();
876        assert_eq!(body, r#"{"message": "Hello, World!"}"#);
877
878        // Verify that a recording file was created
879        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        // Read and verify the recording content
895        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
896        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
897
898        // Verify request information
899        assert_eq!(recording.request.method, "GET");
900        assert_eq!(recording.request.url_path, "/test");
901        assert_eq!(recording.request.body, None);
902
903        // Verify response information
904        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        // Create a temporary directory for recordings
920        let temp_dir = TempDir::new().unwrap();
921        let temp_path = temp_dir.path().to_path_buf();
922
923        // Set up a mock server
924        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        // Create a cloud client with recording enabled
934        let mut client = CloudClient::new_unauthenticated();
935        client.set_recording_dir(temp_path.clone()).unwrap();
936
937        // Make a POST request with body
938        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        // Verify the response
947        assert_eq!(response.status(), 201);
948
949        // Verify that a recording file was created
950        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        // Read and verify the recording content
966        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
967        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
968
969        // Verify request information
970        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        // Verify response information
976        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        // Create a temporary directory for recordings
989        let temp_dir = TempDir::new().unwrap();
990        let temp_path = temp_dir.path().to_path_buf();
991
992        // Set up a mock server
993        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        // Create a cloud client with recording enabled
1008        let mut client = CloudClient::new_unauthenticated();
1009        client.set_recording_dir(temp_path.clone()).unwrap();
1010
1011        // Make multiple requests
1012        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        // Verify that files are named with incrementing counter
1019        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        // Check that files are named 000000.json and 000001.json
1036        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        // Verify content matches the order of requests
1040        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        // Create a temporary directory for recordings
1063        let temp_dir = TempDir::new().unwrap();
1064        let temp_path = temp_dir.path().to_path_buf();
1065
1066        // Start a mock server
1067        let mut server = mockito::Server::new_async().await;
1068
1069        // Create a mock that expects query parameters
1070        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        // Create a client with recording enabled
1079        let mut client = CloudClient::new_unauthenticated();
1080        client.set_recording_dir(temp_path.clone()).unwrap();
1081
1082        // Make a request with query parameters
1083        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        // Verify that the recording file was created
1089        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        // Read and verify the recording content includes query parameters
1105        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
1106        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
1107
1108        // Verify request information includes query parameters
1109        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        // Verify response information
1117        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    /// Verify that the bearer token injected by a signed request does not appear
1127    /// in the recording file. Request headers are currently not recorded, so this
1128    /// test confirms the token does not leak via any other path (e.g. echoed back
1129    /// in a response header or response body).
1130    #[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            // Server does NOT echo the token back — simulates a well-behaved API
1141            .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        // The raw token must not appear in the file at all
1155        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    /// Verify that sensitive headers returned by the server (e.g. a reflected
1164    /// Authorization or AWS security token) are redacted before being written
1165    /// to the recording file.
1166    #[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        // Simulate a server that echoes a sensitive header back in its response
1173        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        // Both headers should be replaced with the redaction sentinel
1198        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    /// Verify that a recording produced with redacted headers can still be parsed.
1208    #[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        // Must parse cleanly as a RequestResponseInfo
1230        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// These assert the production send path's retry + error-mapping contract.
1237// The `recording` feature replaces that path with a single-shot capture (no
1238// retry, no status->error mapping — see `send_record`), so they are scoped to
1239// the non-recording build where the behavior under test actually applies.
1240#[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    // A 5xx is retried by CloudClient::send: a single 503 followed by a 200
1258    // should surface the 200, proving the user-request path now honors
1259    // retry_config (previously it did a bare service.call with no retry).
1260    #[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    // A 4xx is not retryable: it must surface immediately as an error without
1291    // consuming retries (a second mock would go unmatched).
1292    #[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    // send_raw preserves the classified status + body of a non-success
1314    // response instead of remapping it into an opaque Error. A 404 and a 409
1315    // must both surface as Err(RetryError) whose status()/body() are intact,
1316    // and a 5xx must still be retried (503 then 200 -> Ok(200)).
1317    #[tokio::test]
1318    async fn send_raw_preserves_status_and_body() {
1319        let mut server = mockito::Server::new_async().await;
1320
1321        // 404 with a body envelope: status + body must survive.
1322        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        // 409 conflict: same — non-retryable, body preserved.
1330        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        // 5xx is still retried under send_raw: one 503 then a 200.
1338        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    // sign_and_send takes a pre-built reqwest::Request, applies the signer
1401    // (here a bearer token), and retries transient failures just like send.
1402    #[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    /// A pre-configured builder is used as-is: its credential signs requests.
1442    #[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    /// The option-list constructor still parses keys (and ignores unknown ones).
1468    #[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    /// A tenant id selects Azure AD client credentials for the Azure
1518    /// Databricks application instead of Databricks OAuth M2M.
1519    #[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}