1use crate::auth::AzureCredential;
4use crate::error::AzureError;
5use cloud_lite_core::rate_limit::{RateLimitConfig, RateLimiter};
6use cloud_lite_core::retry::RetryConfig;
7
8pub struct AzureHttpClient {
12 http: reqwest::Client,
13 credential: AzureCredential,
14 subscription_id: String,
15 retry_config: RetryConfig,
16 rate_limiter: RateLimiter,
17 #[cfg(any(test, feature = "test-support"))]
19 pub(crate) base_url: Option<String>,
20 #[cfg(any(test, feature = "test-support"))]
22 pub(crate) mock: Option<std::sync::Arc<crate::mock_client::MockClient>>,
23}
24
25pub 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 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 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 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 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
88pub 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 pub fn subscription_id(mut self, id: impl Into<String>) -> Self {
110 self.subscription_id = Some(id.into());
111 self
112 }
113
114 pub fn credential(mut self, cred: AzureCredential) -> Self {
116 self.credential = Some(cred);
117 self
118 }
119
120 pub fn retry_config(mut self, config: RetryConfig) -> Self {
122 self.retry_config = config;
123 self
124 }
125
126 pub fn rate_limit(mut self, config: RateLimitConfig) -> Self {
128 self.rate_limit = config;
129 self
130 }
131
132 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 pub fn builder() -> AzureHttpClientBuilder {
172 AzureHttpClientBuilder::default()
173 }
174
175 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 #[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 pub fn subscription_id(&self) -> &str {
222 &self.subscription_id
223 }
224
225 pub async fn token(&self) -> Result<String, AzureError> {
227 Ok(self.credential.get_token().await?.token)
228 }
229
230 pub fn acr(&self) -> crate::api::AcrClient<'_> {
234 crate::api::AcrClient::new(self)
235 }
236
237 pub fn aks(&self) -> crate::api::AksClient<'_> {
239 crate::api::AksClient::new(self)
240 }
241
242 pub fn compute(&self) -> crate::api::ComputeClient<'_> {
244 crate::api::ComputeClient::new(self)
245 }
246
247 pub fn cosmosdb(&self) -> crate::api::CosmosDbClient<'_> {
249 crate::api::CosmosDbClient::new(self)
250 }
251
252 pub fn cost(&self) -> crate::api::CostClient<'_> {
254 crate::api::CostClient::new(self)
255 }
256
257 pub fn dns(&self) -> crate::api::DnsClient<'_> {
259 crate::api::DnsClient::new(self)
260 }
261
262 pub fn functions(&self) -> crate::api::FunctionsClient<'_> {
264 crate::api::FunctionsClient::new(self)
265 }
266
267 pub fn graph(&self) -> crate::api::GraphClient<'_> {
269 crate::api::GraphClient::new(self)
270 }
271
272 pub fn identity(&self) -> crate::api::IdentityClient<'_> {
274 crate::api::IdentityClient::new(self)
275 }
276
277 pub fn keyvault(&self) -> crate::api::KeyVaultClient<'_> {
279 crate::api::KeyVaultClient::new(self)
280 }
281
282 pub fn log_analytics(&self) -> crate::api::LogAnalyticsClient<'_> {
284 crate::api::LogAnalyticsClient::new(self)
285 }
286
287 pub fn monitor(&self) -> crate::api::MonitorClient<'_> {
289 crate::api::MonitorClient::new(self)
290 }
291
292 pub fn networking(&self) -> crate::api::NetworkingClient<'_> {
294 crate::api::NetworkingClient::new(self)
295 }
296
297 pub fn rbac(&self) -> crate::api::RbacClient<'_> {
299 crate::api::RbacClient::new(self)
300 }
301
302 pub fn redis(&self) -> crate::api::RedisClient<'_> {
304 crate::api::RedisClient::new(self)
305 }
306
307 pub fn resource_graph(&self) -> crate::api::ResourceGraphClient<'_> {
309 crate::api::ResourceGraphClient::new(self)
310 }
311
312 pub fn security(&self) -> crate::api::SecurityClient<'_> {
314 crate::api::SecurityClient::new(self)
315 }
316
317 pub fn sql(&self) -> crate::api::SqlClient<'_> {
319 crate::api::SqlClient::new(self)
320 }
321
322 pub fn storage(&self) -> crate::api::StorageClient<'_> {
324 crate::api::StorageClient::new(self)
325 }
326
327 pub fn subscriptions(&self) -> crate::api::SubscriptionsClient<'_> {
329 crate::api::SubscriptionsClient::new(self)
330 }
331 const GRAPH_SCOPE: &str = "https://graph.microsoft.com/.default";
338
339 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 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 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 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 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 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 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 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 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 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 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}