1use crate::{
11 AzureHttpClient, Result,
12 ops::resource_graph::ResourceGraphOps,
13 types::resource_graph::{QueryOptions, ResourceGraphRequest, ResourceGraphResponse},
14};
15
16pub struct ResourceGraphClient<'a> {
20 ops: ResourceGraphOps<'a>,
21 client: &'a AzureHttpClient,
22}
23
24impl<'a> ResourceGraphClient<'a> {
25 pub(crate) fn new(client: &'a AzureHttpClient) -> Self {
27 Self {
28 ops: ResourceGraphOps::new(client),
29 client,
30 }
31 }
32
33 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 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 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}