Skip to main content

azure_lite_rs/
client.rs

1//! Core HTTP client for Azure API access.
2
3use crate::auth::AzureCredential;
4use crate::error::AzureError;
5use cloud_lite_core::rate_limit::{RateLimitConfig, RateLimiter};
6use cloud_lite_core::retry::RetryConfig;
7
8/// HTTP client for Azure API operations.
9///
10/// Provides automatic Bearer token injection, retry, and rate limiting.
11pub struct AzureHttpClient {
12    http: reqwest::Client,
13    credential: AzureCredential,
14    subscription_id: String,
15    retry_config: RetryConfig,
16    rate_limiter: RateLimiter,
17    /// Override base URL for testing (e.g. mock server).
18    #[cfg(any(test, feature = "test-support"))]
19    pub(crate) base_url: Option<String>,
20    /// Mock client for testing.
21    #[cfg(any(test, feature = "test-support"))]
22    pub(crate) mock: Option<std::sync::Arc<crate::mock_client::MockClient>>,
23}
24
25/// Response wrapper that abstracts over real and mock responses.
26pub struct AzureResponse {
27    data: ResponseData,
28}
29
30enum ResponseData {
31    Real(reqwest::Response),
32    #[cfg(any(test, feature = "test-support"))]
33    Mock(Vec<u8>),
34}
35
36impl AzureResponse {
37    /// Get the HTTP status code of the response.
38    pub fn status(&self) -> u16 {
39        match &self.data {
40            ResponseData::Real(response) => response.status().as_u16(),
41            #[cfg(any(test, feature = "test-support"))]
42            ResponseData::Mock(_) => 200,
43        }
44    }
45
46    /// Check for HTTP error status and parse the error body.
47    pub async fn error_for_status(self) -> Result<Self, AzureError> {
48        let status = self.status();
49        if status < 400 {
50            return Ok(self);
51        }
52
53        let body_bytes = self
54            .bytes()
55            .await
56            .unwrap_or_else(|_| bytes::Bytes::from_static(b""));
57        let body_text = std::str::from_utf8(&body_bytes).unwrap_or("");
58
59        Err(crate::error::parse_json_error(status, body_text))
60    }
61
62    /// Get the `Location` response header value, if present.
63    pub fn location(&self) -> Option<String> {
64        match &self.data {
65            ResponseData::Real(response) => response
66                .headers()
67                .get("location")
68                .and_then(|v| v.to_str().ok())
69                .map(|s| s.to_string()),
70            #[cfg(any(test, feature = "test-support"))]
71            ResponseData::Mock(_) => None,
72        }
73    }
74
75    /// Read the response body as bytes.
76    pub async fn bytes(self) -> Result<bytes::Bytes, AzureError> {
77        match self.data {
78            ResponseData::Real(response) => response
79                .bytes()
80                .await
81                .map_err(|e| AzureError::Network(e.to_string())),
82            #[cfg(any(test, feature = "test-support"))]
83            ResponseData::Mock(data) => Ok(bytes::Bytes::from(data)),
84        }
85    }
86}
87
88/// Builder for [`AzureHttpClient`].
89pub struct AzureHttpClientBuilder {
90    pub(crate) subscription_id: Option<String>,
91    pub(crate) retry_config: RetryConfig,
92    pub(crate) rate_limit: RateLimitConfig,
93    credential: Option<AzureCredential>,
94}
95
96impl Default for AzureHttpClientBuilder {
97    fn default() -> Self {
98        Self {
99            subscription_id: None,
100            retry_config: RetryConfig::default(),
101            rate_limit: RateLimitConfig::new(20),
102            credential: None,
103        }
104    }
105}
106
107impl AzureHttpClientBuilder {
108    /// Set the Azure subscription ID.
109    pub fn subscription_id(mut self, id: impl Into<String>) -> Self {
110        self.subscription_id = Some(id.into());
111        self
112    }
113
114    /// Set an explicit credential (overrides default chain).
115    pub fn credential(mut self, cred: AzureCredential) -> Self {
116        self.credential = Some(cred);
117        self
118    }
119
120    /// Set retry configuration.
121    pub fn retry_config(mut self, config: RetryConfig) -> Self {
122        self.retry_config = config;
123        self
124    }
125
126    /// Set rate limiting configuration.
127    pub fn rate_limit(mut self, config: RateLimitConfig) -> Self {
128        self.rate_limit = config;
129        self
130    }
131
132    /// Build the client (sync — requires credential to be supplied explicitly).
133    ///
134    /// For default credential chain resolution, use [`AzureHttpClient::from_env()`] which is async.
135    pub fn build(self) -> Result<AzureHttpClient, AzureError> {
136        let subscription_id = self
137            .subscription_id
138            .or_else(|| std::env::var("AZURE_SUBSCRIPTION_ID").ok())
139            .ok_or_else(|| AzureError::Auth {
140                message:
141                    "subscription_id required (set AZURE_SUBSCRIPTION_ID or call .subscription_id())"
142                        .into(),
143            })?;
144
145        let credential = self.credential.ok_or_else(|| AzureError::Auth {
146            message:
147                "credential required — use AzureHttpClient::from_env().await for the default chain"
148                    .into(),
149        })?;
150
151        let http = reqwest::Client::builder()
152            .build()
153            .map_err(|e| AzureError::Network(e.to_string()))?;
154
155        Ok(AzureHttpClient {
156            http,
157            credential,
158            subscription_id,
159            retry_config: self.retry_config,
160            rate_limiter: RateLimiter::new(self.rate_limit),
161            #[cfg(any(test, feature = "test-support"))]
162            base_url: None,
163            #[cfg(any(test, feature = "test-support"))]
164            mock: None,
165        })
166    }
167}
168
169impl AzureHttpClient {
170    /// Create a new builder.
171    pub fn builder() -> AzureHttpClientBuilder {
172        AzureHttpClientBuilder::default()
173    }
174
175    /// Create a client using the default credential chain.
176    ///
177    /// Resolves credentials in order:
178    /// 1. Service principal env vars
179    /// 2. Managed identity IMDS
180    /// 3. Azure CLI
181    pub async fn from_env() -> Result<Self, AzureError> {
182        let credential = crate::auth::default_credential().await?;
183        let subscription_id =
184            std::env::var("AZURE_SUBSCRIPTION_ID").map_err(|_| AzureError::Auth {
185                message: "AZURE_SUBSCRIPTION_ID environment variable not set".into(),
186            })?;
187
188        let http = reqwest::Client::builder()
189            .build()
190            .map_err(|e| AzureError::Network(e.to_string()))?;
191
192        Ok(Self {
193            http,
194            credential,
195            subscription_id,
196            retry_config: RetryConfig::default(),
197            rate_limiter: RateLimiter::new(RateLimitConfig::new(20)),
198            #[cfg(any(test, feature = "test-support"))]
199            base_url: None,
200            #[cfg(any(test, feature = "test-support"))]
201            mock: None,
202        })
203    }
204
205    /// Create a client from a mock for testing.
206    #[cfg(any(test, feature = "test-support"))]
207    pub fn from_mock(mock: crate::mock_client::MockClient) -> Self {
208        use crate::auth::cli::AzureCliCredential;
209        Self {
210            http: reqwest::Client::new(),
211            credential: AzureCredential::AzureCli(AzureCliCredential::new()),
212            subscription_id: "test-subscription-id".into(),
213            retry_config: RetryConfig::default(),
214            rate_limiter: RateLimiter::new(RateLimitConfig::disabled()),
215            base_url: None,
216            mock: Some(std::sync::Arc::new(mock)),
217        }
218    }
219
220    /// Get the configured subscription ID.
221    pub fn subscription_id(&self) -> &str {
222        &self.subscription_id
223    }
224
225    /// Acquire a token from the credential chain.
226    pub async fn token(&self) -> Result<String, AzureError> {
227        Ok(self.credential.get_token().await?.token)
228    }
229
230    // === Generated API Accessors (do not edit) ===
231
232    /// Access the Azure Container Registry API
233    pub fn acr(&self) -> crate::api::AcrClient<'_> {
234        crate::api::AcrClient::new(self)
235    }
236
237    /// Access the Azure Kubernetes Service API
238    pub fn aks(&self) -> crate::api::AksClient<'_> {
239        crate::api::AksClient::new(self)
240    }
241
242    /// Access the Azure Compute API
243    pub fn compute(&self) -> crate::api::ComputeClient<'_> {
244        crate::api::ComputeClient::new(self)
245    }
246
247    /// Access the Azure CosmosDB API
248    pub fn cosmosdb(&self) -> crate::api::CosmosDbClient<'_> {
249        crate::api::CosmosDbClient::new(self)
250    }
251
252    /// Access the Azure Cost Management API
253    pub fn cost(&self) -> crate::api::CostClient<'_> {
254        crate::api::CostClient::new(self)
255    }
256
257    /// Access the Azure DNS API
258    pub fn dns(&self) -> crate::api::DnsClient<'_> {
259        crate::api::DnsClient::new(self)
260    }
261
262    /// Access the Azure Functions API
263    pub fn functions(&self) -> crate::api::FunctionsClient<'_> {
264        crate::api::FunctionsClient::new(self)
265    }
266
267    /// Access the Microsoft Graph API
268    pub fn graph(&self) -> crate::api::GraphClient<'_> {
269        crate::api::GraphClient::new(self)
270    }
271
272    /// Access the Azure Managed Identities API
273    pub fn identity(&self) -> crate::api::IdentityClient<'_> {
274        crate::api::IdentityClient::new(self)
275    }
276
277    /// Access the Azure Key Vault API
278    pub fn keyvault(&self) -> crate::api::KeyVaultClient<'_> {
279        crate::api::KeyVaultClient::new(self)
280    }
281
282    /// Access the Azure Log Analytics API
283    pub fn log_analytics(&self) -> crate::api::LogAnalyticsClient<'_> {
284        crate::api::LogAnalyticsClient::new(self)
285    }
286
287    /// Access the Azure Monitor API
288    pub fn monitor(&self) -> crate::api::MonitorClient<'_> {
289        crate::api::MonitorClient::new(self)
290    }
291
292    /// Access the Azure Networking API
293    pub fn networking(&self) -> crate::api::NetworkingClient<'_> {
294        crate::api::NetworkingClient::new(self)
295    }
296
297    /// Access the Azure RBAC API
298    pub fn rbac(&self) -> crate::api::RbacClient<'_> {
299        crate::api::RbacClient::new(self)
300    }
301
302    /// Access the Azure Redis Cache API
303    pub fn redis(&self) -> crate::api::RedisClient<'_> {
304        crate::api::RedisClient::new(self)
305    }
306
307    /// Access the Azure Resource Graph API
308    pub fn resource_graph(&self) -> crate::api::ResourceGraphClient<'_> {
309        crate::api::ResourceGraphClient::new(self)
310    }
311
312    /// Access the Azure Defender for Cloud API
313    pub fn security(&self) -> crate::api::SecurityClient<'_> {
314        crate::api::SecurityClient::new(self)
315    }
316
317    /// Access the Azure SQL API
318    pub fn sql(&self) -> crate::api::SqlClient<'_> {
319        crate::api::SqlClient::new(self)
320    }
321
322    /// Access the Azure Storage API
323    pub fn storage(&self) -> crate::api::StorageClient<'_> {
324        crate::api::StorageClient::new(self)
325    }
326
327    /// Access the Azure Subscriptions API
328    pub fn subscriptions(&self) -> crate::api::SubscriptionsClient<'_> {
329        crate::api::SubscriptionsClient::new(self)
330    }
331    // === End Generated API Accessors ===
332
333    // =========================================================================
334    // Microsoft Graph API — uses a separate OAuth2 scope
335    // =========================================================================
336
337    const GRAPH_SCOPE: &str = "https://graph.microsoft.com/.default";
338
339    /// Make a GET request to the Microsoft Graph API.
340    ///
341    /// Acquires a Graph-scoped token (`https://graph.microsoft.com/.default`)
342    /// rather than the ARM scope used by the standard `get()` method.
343    pub(crate) async fn graph_get(&self, url: &str) -> Result<AzureResponse, AzureError> {
344        #[cfg(any(test, feature = "test-support"))]
345        if let Some(ref mock) = self.mock {
346            let result = mock.execute("GET", url, None).await?;
347            return Ok(AzureResponse {
348                data: ResponseData::Mock(result),
349            });
350        }
351
352        let token = self
353            .credential
354            .get_token_for_scope(Self::GRAPH_SCOPE)
355            .await?
356            .token;
357        let response = self.bearer_request("GET", url, &token, b"", None).await?;
358        Ok(AzureResponse {
359            data: ResponseData::Real(response),
360        })
361    }
362
363    /// Make a POST request to the Microsoft Graph API with a JSON body.
364    ///
365    /// Acquires a Graph-scoped token (`https://graph.microsoft.com/.default`)
366    /// rather than the ARM scope used by the standard `post()` method.
367    pub(crate) async fn graph_post(
368        &self,
369        url: &str,
370        body: &[u8],
371    ) -> Result<AzureResponse, AzureError> {
372        #[cfg(any(test, feature = "test-support"))]
373        if let Some(ref mock) = self.mock {
374            let result = mock.execute("POST", url, None).await?;
375            return Ok(AzureResponse {
376                data: ResponseData::Mock(result),
377            });
378        }
379
380        let token = self
381            .credential
382            .get_token_for_scope(Self::GRAPH_SCOPE)
383            .await?
384            .token;
385        let response = self
386            .bearer_request("POST", url, &token, body, Some("application/json"))
387            .await?;
388        Ok(AzureResponse {
389            data: ResponseData::Real(response),
390        })
391    }
392
393    /// Make a GET request with automatic Bearer token injection and retry.
394    pub async fn get(&self, url: &str) -> Result<AzureResponse, AzureError> {
395        #[cfg(any(test, feature = "test-support"))]
396        if let Some(ref mock) = self.mock {
397            let result = mock.execute("GET", url, None).await?;
398            return Ok(AzureResponse {
399                data: ResponseData::Mock(result),
400            });
401        }
402
403        let token = self.credential.get_token().await?.token;
404        let response = self.bearer_request("GET", url, &token, b"", None).await?;
405        Ok(AzureResponse {
406            data: ResponseData::Real(response),
407        })
408    }
409
410    /// Make a PUT request with a JSON body.
411    pub async fn put(&self, url: &str, body: &[u8]) -> Result<AzureResponse, AzureError> {
412        #[cfg(any(test, feature = "test-support"))]
413        if let Some(ref mock) = self.mock {
414            let result = mock.execute("PUT", url, None).await?;
415            return Ok(AzureResponse {
416                data: ResponseData::Mock(result),
417            });
418        }
419
420        let token = self.credential.get_token().await?.token;
421        let response = self
422            .bearer_request("PUT", url, &token, body, Some("application/json"))
423            .await?;
424        Ok(AzureResponse {
425            data: ResponseData::Real(response),
426        })
427    }
428
429    /// Make a POST request with a JSON body.
430    pub async fn post(&self, url: &str, body: &[u8]) -> Result<AzureResponse, AzureError> {
431        #[cfg(any(test, feature = "test-support"))]
432        if let Some(ref mock) = self.mock {
433            let result = mock.execute("POST", url, None).await?;
434            return Ok(AzureResponse {
435                data: ResponseData::Mock(result),
436            });
437        }
438
439        let token = self.credential.get_token().await?.token;
440        let response = self
441            .bearer_request("POST", url, &token, body, Some("application/json"))
442            .await?;
443        Ok(AzureResponse {
444            data: ResponseData::Real(response),
445        })
446    }
447
448    /// Make a DELETE request.
449    pub async fn delete(&self, url: &str) -> Result<AzureResponse, AzureError> {
450        #[cfg(any(test, feature = "test-support"))]
451        if let Some(ref mock) = self.mock {
452            let result = mock.execute("DELETE", url, None).await?;
453            return Ok(AzureResponse {
454                data: ResponseData::Mock(result),
455            });
456        }
457
458        let token = self.credential.get_token().await?.token;
459        let response = self
460            .bearer_request("DELETE", url, &token, b"", None)
461            .await?;
462        Ok(AzureResponse {
463            data: ResponseData::Real(response),
464        })
465    }
466
467    /// Make a PATCH request with a JSON body.
468    pub async fn patch(&self, url: &str, body: &[u8]) -> Result<AzureResponse, AzureError> {
469        #[cfg(any(test, feature = "test-support"))]
470        if let Some(ref mock) = self.mock {
471            let result = mock.execute("PATCH", url, None).await?;
472            return Ok(AzureResponse {
473                data: ResponseData::Mock(result),
474            });
475        }
476
477        let token = self.credential.get_token().await?.token;
478        let response = self
479            .bearer_request("PATCH", url, &token, body, Some("application/json"))
480            .await?;
481        Ok(AzureResponse {
482            data: ResponseData::Real(response),
483        })
484    }
485
486    /// Internal: request with Bearer auth, content-type, and retry.
487    async fn bearer_request(
488        &self,
489        method: &str,
490        url: &str,
491        token: &str,
492        body: &[u8],
493        content_type: Option<&str>,
494    ) -> Result<reqwest::Response, AzureError> {
495        let _permit = self.rate_limiter.acquire(url).await;
496
497        let mut attempt = 0u32;
498        let mut backoff = self.retry_config.initial_backoff;
499        let body_bytes = if body.is_empty() {
500            None
501        } else {
502            Some(bytes::Bytes::copy_from_slice(body))
503        };
504
505        loop {
506            let mut request = self
507                .http
508                .request(method.parse().expect("invalid HTTP method"), url)
509                .header("Authorization", format!("Bearer {token}"));
510
511            if let Some(ct) = content_type {
512                request = request.header("Content-Type", ct);
513            }
514            if let Some(ref b) = body_bytes {
515                request = request.body(b.clone());
516            } else {
517                // Azure ARM requires Content-Length: 0 for POST with no body (returns 411 otherwise)
518                request = request.header("Content-Length", "0");
519            }
520
521            let result = match request.send().await {
522                Ok(response) => Self::classify_response(response).await,
523                Err(e) => Err(AzureError::from(e)),
524            };
525
526            match result {
527                Ok(response) => return Ok(response),
528                Err(azure_err) => {
529                    if azure_err.is_retryable() && attempt < self.retry_config.max_retries {
530                        let delay = self
531                            .retry_config
532                            .compute_backoff(backoff, azure_err.retry_after());
533                        tokio::time::sleep(delay).await;
534                        backoff = std::time::Duration::from_secs_f64(
535                            backoff.as_secs_f64() * self.retry_config.backoff_multiplier,
536                        );
537                        attempt += 1;
538                        continue;
539                    }
540                    return Err(azure_err);
541                }
542            }
543        }
544    }
545
546    /// Classify an HTTP response: return Ok for 2xx, parse and return Err for 4xx/5xx.
547    async fn classify_response(
548        response: reqwest::Response,
549    ) -> Result<reqwest::Response, AzureError> {
550        let status = response.status().as_u16();
551        if status < 400 {
552            return Ok(response);
553        }
554
555        // Extract Retry-After header before consuming body
556        let retry_after_secs: Option<u64> = response
557            .headers()
558            .get("retry-after")
559            .and_then(|v| v.to_str().ok())
560            .and_then(|s| s.parse().ok());
561
562        let body_text = response.text().await.unwrap_or_default();
563
564        let mut err = crate::error::parse_json_error(status, &body_text);
565
566        if let Some(secs) = retry_after_secs
567            && let AzureError::Throttled { retry_after, .. } = &mut err
568        {
569            *retry_after = Some(std::time::Duration::from_secs(secs));
570        }
571
572        Err(err)
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn builder_succeeds_with_explicit_subscription_id_and_credential() {
582        use crate::auth::cli::AzureCliCredential;
583        let cred = AzureCredential::AzureCli(AzureCliCredential::new());
584        let client = AzureHttpClient::builder()
585            .subscription_id("test-sub-123")
586            .credential(cred)
587            .build();
588        assert!(client.is_ok());
589        assert_eq!(client.unwrap().subscription_id(), "test-sub-123");
590    }
591
592    #[test]
593    fn builder_requires_subscription_id_without_env() {
594        use crate::auth::cli::AzureCliCredential;
595        let cred = AzureCredential::AzureCli(AzureCliCredential::new());
596        let result = AzureHttpClientBuilder {
597            subscription_id: None,
598            retry_config: RetryConfig::default(),
599            rate_limit: RateLimitConfig::disabled(),
600            credential: Some(cred),
601        }
602        .build();
603        if std::env::var("AZURE_SUBSCRIPTION_ID").is_err() {
604            assert!(
605                matches!(result, Err(AzureError::Auth { .. })),
606                "expected Auth error, got: {:?}",
607                result.err()
608            );
609        }
610    }
611}