Skip to main content

azure_lite_rs/api/
resource_graph.rs

1//! Azure Resource Graph API client.
2//!
3//! Provides KQL query execution against the Azure Resource Graph, which indexes
4//! all ARM resources across subscriptions. Supports single-page and
5//! auto-paginating queries.
6//!
7//! Auth: standard Azure Bearer token (handled by `AzureHttpClient`).
8//! Rate limit: 15 requests/5 s per tenant — the existing `RateLimiter` applies.
9
10use crate::{
11    AzureHttpClient, Result,
12    ops::resource_graph::ResourceGraphOps,
13    types::resource_graph::{QueryOptions, ResourceGraphRequest, ResourceGraphResponse},
14};
15
16/// Client for the Azure Resource Graph API.
17///
18/// Wraps [`ResourceGraphOps`] with ergonomic, pagination-aware methods.
19pub struct ResourceGraphClient<'a> {
20    ops: ResourceGraphOps<'a>,
21    client: &'a AzureHttpClient,
22}
23
24impl<'a> ResourceGraphClient<'a> {
25    /// Create a new Azure Resource Graph API client.
26    pub(crate) fn new(client: &'a AzureHttpClient) -> Self {
27        Self {
28            ops: ResourceGraphOps::new(client),
29            client,
30        }
31    }
32
33    /// Execute a KQL query across subscriptions, auto-injecting the client's
34    /// subscription ID. Handles pagination internally — returns **all** results.
35    ///
36    /// # Example
37    /// ```no_run
38    /// # async fn run(client: &azure_lite::AzureHttpClient) -> azure_lite::Result<()> {
39    /// let results = client.resource_graph()
40    ///     .query("Resources | where type =~ 'microsoft.compute/disks' | project id, name", &[])
41    ///     .await?;
42    /// # Ok(())
43    /// # }
44    /// ```
45    ///
46    /// Pass an empty slice for `extra_subscriptions` to query only the client's
47    /// own subscription. Pass additional IDs to query across multiple subscriptions.
48    pub async fn query(
49        &self,
50        query: &str,
51        extra_subscriptions: &[&str],
52    ) -> Result<Vec<serde_json::Value>> {
53        let mut subscriptions = vec![self.client.subscription_id().to_string()];
54        subscriptions.extend(extra_subscriptions.iter().map(|s| s.to_string()));
55
56        let mut all_results: Vec<serde_json::Value> = Vec::new();
57        let mut skip_token: Option<String> = None;
58
59        loop {
60            let options = QueryOptions {
61                top: Some(1000),
62                result_format: Some("objectArray".to_string()),
63                skip_token: skip_token.clone(),
64            };
65            let req = ResourceGraphRequest {
66                subscriptions: subscriptions.clone(),
67                query: query.to_string(),
68                options: Some(options),
69            };
70            let page = self.ops.query_resources(&req).await?;
71            all_results.extend(page.data);
72
73            let truncated = page
74                .result_truncated
75                .as_deref()
76                .map(|s| s.eq_ignore_ascii_case("true"))
77                .unwrap_or(false);
78            skip_token = page.skip_token;
79
80            if !truncated || skip_token.is_none() {
81                break;
82            }
83        }
84
85        Ok(all_results)
86    }
87
88    /// Execute a KQL query and return a **single page** of results.
89    ///
90    /// Use `skip_token` from a previous [`ResourceGraphResponse`] to fetch
91    /// the next page. Pass `None` to start from the beginning.
92    ///
93    /// The client's subscription ID is always included; `extra_subscriptions`
94    /// adds more.
95    pub async fn query_page(
96        &self,
97        query: &str,
98        extra_subscriptions: &[&str],
99        options: Option<QueryOptions>,
100        skip_token: Option<&str>,
101    ) -> Result<ResourceGraphResponse> {
102        let mut subscriptions = vec![self.client.subscription_id().to_string()];
103        subscriptions.extend(extra_subscriptions.iter().map(|s| s.to_string()));
104
105        let opts = match (options, skip_token) {
106            (Some(mut o), Some(tok)) => {
107                o.skip_token = Some(tok.to_string());
108                Some(o)
109            }
110            (Some(o), None) => Some(o),
111            (None, Some(tok)) => Some(QueryOptions {
112                skip_token: Some(tok.to_string()),
113                ..Default::default()
114            }),
115            (None, None) => None,
116        };
117
118        let req = ResourceGraphRequest {
119            subscriptions,
120            query: query.to_string(),
121            options: opts,
122        };
123        self.ops.query_resources(&req).await
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::MockClient;
131
132    fn make_client(mock: MockClient) -> AzureHttpClient {
133        AzureHttpClient::from_mock(mock)
134    }
135
136    fn disk_json(name: &str) -> serde_json::Value {
137        serde_json::json!({
138            "id": format!("/subscriptions/test-subscription-id/resourceGroups/test-rg/providers/Microsoft.Compute/disks/{name}"),
139            "name": name,
140            "type": "microsoft.compute/disks",
141            "location": "eastus",
142            "resourceGroup": "test-rg",
143            "subscriptionId": "test-subscription-id"
144        })
145    }
146
147    #[tokio::test]
148    async fn query_returns_all_results_single_page() {
149        let mut mock = MockClient::new();
150        mock.expect_post("/providers/Microsoft.ResourceGraph/resources")
151            .returning_json(serde_json::json!({
152                "totalRecords": 2,
153                "count": 2,
154                "resultTruncated": "false",
155                "data": [disk_json("disk-1"), disk_json("disk-2")]
156            }));
157        let client = make_client(mock);
158        let results = client
159            .resource_graph()
160            .query("Resources | where type =~ 'microsoft.compute/disks'", &[])
161            .await
162            .expect("query failed");
163        assert_eq!(results.len(), 2);
164        assert_eq!(results[0]["name"], "disk-1");
165        assert_eq!(results[1]["name"], "disk-2");
166    }
167
168    #[tokio::test]
169    async fn query_paginates_across_multiple_pages() {
170        let mut mock = MockClient::new();
171        // Two sequential responses for the same endpoint: page 1 then page 2
172        mock.expect_post("/providers/Microsoft.ResourceGraph/resources")
173            .returning_json_sequence(vec![
174                serde_json::json!({
175                    "totalRecords": 2,
176                    "count": 1,
177                    "resultTruncated": "true",
178                    "$skipToken": "page2-token",
179                    "data": [disk_json("disk-1")]
180                }),
181                serde_json::json!({
182                    "totalRecords": 2,
183                    "count": 1,
184                    "resultTruncated": "false",
185                    "data": [disk_json("disk-2")]
186                }),
187            ])
188            .times(2);
189        let client = make_client(mock);
190        let results = client
191            .resource_graph()
192            .query("Resources | where type =~ 'microsoft.compute/disks'", &[])
193            .await
194            .expect("query failed");
195        assert_eq!(results.len(), 2);
196        assert_eq!(results[0]["name"], "disk-1");
197        assert_eq!(results[1]["name"], "disk-2");
198    }
199
200    #[tokio::test]
201    async fn query_page_returns_single_page_with_skip_token() {
202        let mut mock = MockClient::new();
203        mock.expect_post("/providers/Microsoft.ResourceGraph/resources")
204            .returning_json(serde_json::json!({
205                "totalRecords": 1000,
206                "count": 1,
207                "resultTruncated": "true",
208                "$skipToken": "next-page-token",
209                "data": [disk_json("disk-1")]
210            }));
211        let client = make_client(mock);
212        let page = client
213            .resource_graph()
214            .query_page(
215                "Resources | where type =~ 'microsoft.compute/disks'",
216                &[],
217                None,
218                None,
219            )
220            .await
221            .expect("query_page failed");
222        assert_eq!(page.count, Some(1));
223        assert_eq!(page.total_records, Some(1000));
224        assert_eq!(page.result_truncated.as_deref(), Some("true"));
225        assert_eq!(page.skip_token.as_deref(), Some("next-page-token"));
226        assert_eq!(page.data.len(), 1);
227        assert_eq!(page.data[0]["name"], "disk-1");
228    }
229
230    #[tokio::test]
231    async fn query_page_merges_skip_token_into_options() {
232        let mut mock = MockClient::new();
233        mock.expect_post("/providers/Microsoft.ResourceGraph/resources")
234            .returning_json(serde_json::json!({
235                "totalRecords": 1000,
236                "count": 1,
237                "resultTruncated": "false",
238                "data": [disk_json("disk-2")]
239            }));
240        let client = make_client(mock);
241        let page = client
242            .resource_graph()
243            .query_page(
244                "Resources | where type =~ 'microsoft.compute/disks'",
245                &[],
246                None,
247                Some("page2-token"),
248            )
249            .await
250            .expect("query_page failed");
251        assert_eq!(page.data.len(), 1);
252        assert_eq!(page.data[0]["name"], "disk-2");
253    }
254
255    #[tokio::test]
256    async fn query_returns_empty_for_no_results() {
257        let mut mock = MockClient::new();
258        mock.expect_post("/providers/Microsoft.ResourceGraph/resources")
259            .returning_json(serde_json::json!({
260                "totalRecords": 0,
261                "count": 0,
262                "resultTruncated": "false",
263                "data": []
264            }));
265        let client = make_client(mock);
266        let results = client
267            .resource_graph()
268            .query("Resources | where 1 == 0", &[])
269            .await
270            .expect("query failed");
271        assert_eq!(results.len(), 0);
272    }
273}