1use anyhow::Result;
8use url::Url;
9
10use crate::datadog::client::DatadogClient;
11use crate::datadog::types::{Host, HostsResponse};
12
13pub const HARD_CAP: usize = 10_000;
15
16pub const LIST_PAGE_SIZE: usize = 100;
20
21#[derive(Debug, Default, Clone)]
23pub struct HostsListFilter {
24 pub filter: Option<String>,
26 pub from: Option<i64>,
29 pub sort_field: Option<String>,
31 pub sort_dir: Option<String>,
33 pub include_muted_hosts_data: Option<bool>,
35 pub include_hosts_metadata: Option<bool>,
37}
38
39#[derive(Debug)]
41pub struct HostsApi<'a> {
42 client: &'a DatadogClient,
43}
44
45impl<'a> HostsApi<'a> {
46 #[must_use]
48 pub fn new(client: &'a DatadogClient) -> Self {
49 Self { client }
50 }
51
52 pub async fn list(&self, filter: &HostsListFilter, limit: usize) -> Result<HostsResponse> {
58 let cap = effective_cap(limit);
59 let mut hosts: Vec<Host> = Vec::new();
60 let mut start: usize = 0;
61 let mut total_matching: Option<i64> = None;
62 loop {
63 let remaining = cap - hosts.len();
64 let count = remaining.min(LIST_PAGE_SIZE);
65 let url = build_list_url(self.client.base_url(), filter, start, count)?;
66 let parsed: HostsResponse = self
67 .client
68 .get_parsed(url.as_str(), "Failed to parse /api/v1/hosts response")
69 .await?;
70 let batch_len = parsed.host_list.len();
71 let exhausted = batch_len < count;
72 if total_matching.is_none() {
73 total_matching = parsed.total_matching;
74 }
75 hosts.extend(parsed.host_list);
76 if hosts.len() >= cap || exhausted || batch_len == 0 {
77 break;
78 }
79 start += batch_len;
80 }
81 hosts.truncate(cap);
82 let returned = i64::try_from(hosts.len()).unwrap_or(i64::MAX);
83 Ok(HostsResponse {
84 host_list: hosts,
85 total_returned: Some(returned),
86 total_matching,
87 })
88 }
89}
90
91fn effective_cap(limit: usize) -> usize {
92 if limit == 0 {
93 HARD_CAP
94 } else {
95 limit.min(HARD_CAP)
96 }
97}
98
99fn build_list_url(
100 base_url: &str,
101 filter: &HostsListFilter,
102 start: usize,
103 count: usize,
104) -> Result<Url> {
105 let mut url = DatadogClient::api_url(base_url, "/api/v1/hosts")?;
106 {
107 let mut q = url.query_pairs_mut();
108 if let Some(f) = filter.filter.as_deref() {
109 q.append_pair("filter", f);
110 }
111 if let Some(from) = filter.from {
112 q.append_pair("from", &from.to_string());
113 }
114 if let Some(field) = filter.sort_field.as_deref() {
115 q.append_pair("sort_field", field);
116 }
117 if let Some(dir) = filter.sort_dir.as_deref() {
118 q.append_pair("sort_dir", dir);
119 }
120 if let Some(b) = filter.include_muted_hosts_data {
121 q.append_pair("include_muted_hosts_data", if b { "true" } else { "false" });
122 }
123 if let Some(b) = filter.include_hosts_metadata {
124 q.append_pair("include_hosts_metadata", if b { "true" } else { "false" });
125 }
126 q.append_pair("start", &start.to_string());
127 q.append_pair("count", &count.to_string());
128 }
129 Ok(url)
130}
131
132#[cfg(test)]
133#[allow(clippy::unwrap_used, clippy::expect_used)]
134mod tests {
135 use super::*;
136
137 #[test]
140 fn effective_cap_zero_means_hard_cap() {
141 assert_eq!(effective_cap(0), HARD_CAP);
142 }
143
144 #[test]
145 fn effective_cap_clamps_to_hard_cap() {
146 assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
147 }
148
149 #[test]
150 fn effective_cap_passes_through_small_limits() {
151 assert_eq!(effective_cap(13), 13);
152 }
153
154 #[test]
157 fn build_list_url_appends_only_provided_filters() {
158 let filter = HostsListFilter {
159 filter: Some("env:prod".into()),
160 ..HostsListFilter::default()
161 };
162 let url = build_list_url("https://api.datadoghq.com", &filter, 0, 100).unwrap();
163 let qs = url.query().unwrap();
164 assert!(qs.contains("filter=env%3Aprod"));
165 assert!(qs.contains("start=0"));
166 assert!(qs.contains("count=100"));
167 assert!(!qs.contains("from="));
168 assert!(!qs.contains("sort_field="));
169 assert!(!qs.contains("include_muted_hosts_data="));
170 }
171
172 #[test]
173 fn build_list_url_appends_full_filter_set() {
174 let filter = HostsListFilter {
175 filter: Some("apps:nginx".into()),
176 from: Some(1_700_000_000),
177 sort_field: Some("up".into()),
178 sort_dir: Some("desc".into()),
179 include_muted_hosts_data: Some(false),
180 include_hosts_metadata: Some(true),
181 };
182 let url = build_list_url("https://api.datadoghq.com", &filter, 100, 50).unwrap();
183 let qs = url.query().unwrap();
184 assert!(qs.contains("filter=apps%3Anginx"));
185 assert!(qs.contains("from=1700000000"));
186 assert!(qs.contains("sort_field=up"));
187 assert!(qs.contains("sort_dir=desc"));
188 assert!(qs.contains("include_muted_hosts_data=false"));
189 assert!(qs.contains("include_hosts_metadata=true"));
190 assert!(qs.contains("start=100"));
191 assert!(qs.contains("count=50"));
192 }
193
194 #[test]
195 fn build_list_url_inverted_booleans_take_other_arms() {
196 let filter = HostsListFilter {
200 include_muted_hosts_data: Some(true),
201 include_hosts_metadata: Some(false),
202 ..HostsListFilter::default()
203 };
204 let url = build_list_url("https://api.datadoghq.com", &filter, 0, 10).unwrap();
205 let qs = url.query().unwrap();
206 assert!(qs.contains("include_muted_hosts_data=true"));
207 assert!(qs.contains("include_hosts_metadata=false"));
208 }
209
210 #[test]
211 fn build_list_url_rejects_invalid_base() {
212 let err = build_list_url("not a url", &HostsListFilter::default(), 0, 100).unwrap_err();
213 assert!(err.to_string().contains("Invalid Datadog base URL"));
214 }
215
216 fn host_json(name: &str) -> serde_json::Value {
219 serde_json::json!({
220 "name": name,
221 "aliases": [],
222 "apps": ["nginx"],
223 "up": true,
224 "last_reported_time": 1_700_000_000_i64,
225 "sources": ["agent"]
226 })
227 }
228
229 #[tokio::test]
232 async fn list_single_page_returns_envelope() {
233 let server = wiremock::MockServer::start().await;
234 wiremock::Mock::given(wiremock::matchers::method("GET"))
235 .and(wiremock::matchers::path("/api/v1/hosts"))
236 .and(wiremock::matchers::query_param("filter", "env:prod"))
237 .and(wiremock::matchers::query_param("start", "0"))
238 .and(wiremock::matchers::query_param("count", "5"))
239 .respond_with(
240 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
241 "host_list": [host_json("web-01"), host_json("web-02")],
242 "total_returned": 2_i64,
243 "total_matching": 5_i64
244 })),
245 )
246 .expect(1)
247 .mount(&server)
248 .await;
249
250 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
251 let result = HostsApi::new(&client)
252 .list(
253 &HostsListFilter {
254 filter: Some("env:prod".into()),
255 ..HostsListFilter::default()
256 },
257 5,
258 )
259 .await
260 .unwrap();
261 assert_eq!(result.host_list.len(), 2);
262 assert_eq!(result.total_returned, Some(2));
263 assert_eq!(result.total_matching, Some(5));
264 }
265
266 #[tokio::test]
267 async fn list_auto_paginates_until_short_page() {
268 let server = wiremock::MockServer::start().await;
269 let body0: Vec<serde_json::Value> = (0..LIST_PAGE_SIZE)
270 .map(|i| host_json(&format!("h{i}")))
271 .collect();
272 wiremock::Mock::given(wiremock::matchers::method("GET"))
273 .and(wiremock::matchers::path("/api/v1/hosts"))
274 .and(wiremock::matchers::query_param("start", "0"))
275 .respond_with(
276 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
277 "host_list": body0,
278 "total_matching": 137_i64
279 })),
280 )
281 .expect(1)
282 .mount(&server)
283 .await;
284 let body1: Vec<serde_json::Value> =
285 (0..37).map(|i| host_json(&format!("h-late-{i}"))).collect();
286 wiremock::Mock::given(wiremock::matchers::method("GET"))
287 .and(wiremock::matchers::path("/api/v1/hosts"))
288 .and(wiremock::matchers::query_param(
289 "start",
290 LIST_PAGE_SIZE.to_string(),
291 ))
292 .respond_with(
293 wiremock::ResponseTemplate::new(200)
294 .set_body_json(serde_json::json!({"host_list": body1})),
295 )
296 .expect(1)
297 .mount(&server)
298 .await;
299
300 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
301 let result = HostsApi::new(&client)
302 .list(&HostsListFilter::default(), 0)
303 .await
304 .unwrap();
305 assert_eq!(result.host_list.len(), LIST_PAGE_SIZE + 37);
306 assert_eq!(result.total_matching, Some(137));
307 assert_eq!(result.total_returned, Some((LIST_PAGE_SIZE + 37) as i64));
308 }
309
310 #[tokio::test]
311 async fn list_caps_at_explicit_limit() {
312 let server = wiremock::MockServer::start().await;
313 wiremock::Mock::given(wiremock::matchers::method("GET"))
314 .and(wiremock::matchers::path("/api/v1/hosts"))
315 .and(wiremock::matchers::query_param("count", "3"))
316 .respond_with(
317 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
318 "host_list": [host_json("a"), host_json("b"), host_json("c")]
319 })),
320 )
321 .expect(1)
322 .mount(&server)
323 .await;
324
325 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
326 let result = HostsApi::new(&client)
327 .list(&HostsListFilter::default(), 3)
328 .await
329 .unwrap();
330 assert_eq!(result.host_list.len(), 3);
331 }
332
333 #[tokio::test]
334 async fn list_stops_on_empty_page() {
335 let server = wiremock::MockServer::start().await;
336 wiremock::Mock::given(wiremock::matchers::method("GET"))
337 .and(wiremock::matchers::path("/api/v1/hosts"))
338 .respond_with(
339 wiremock::ResponseTemplate::new(200)
340 .set_body_json(serde_json::json!({"host_list": []})),
341 )
342 .expect(1)
343 .mount(&server)
344 .await;
345
346 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
347 let result = HostsApi::new(&client)
348 .list(&HostsListFilter::default(), 0)
349 .await
350 .unwrap();
351 assert!(result.host_list.is_empty());
352 assert_eq!(result.total_returned, Some(0));
353 }
354
355 #[tokio::test]
356 async fn list_propagates_api_errors() {
357 let server = wiremock::MockServer::start().await;
358 wiremock::Mock::given(wiremock::matchers::method("GET"))
359 .and(wiremock::matchers::path("/api/v1/hosts"))
360 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
361 .mount(&server)
362 .await;
363
364 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
365 let err = HostsApi::new(&client)
366 .list(&HostsListFilter::default(), 5)
367 .await
368 .unwrap_err();
369 assert!(err.to_string().contains("500"));
370 }
371
372 #[tokio::test]
373 async fn list_propagates_invalid_base_url_error() {
374 let client = DatadogClient::new("not a url", "api", "app").unwrap();
375 let err = HostsApi::new(&client)
376 .list(&HostsListFilter::default(), 5)
377 .await
378 .unwrap_err();
379 assert!(err.to_string().contains("Invalid Datadog base URL"));
380 }
381
382 #[tokio::test]
383 async fn list_propagates_network_errors() {
384 let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
385 let err = HostsApi::new(&client)
386 .list(&HostsListFilter::default(), 5)
387 .await
388 .unwrap_err();
389 assert!(err.to_string().contains("Failed to send"));
390 }
391
392 #[tokio::test]
393 async fn list_errors_on_malformed_response() {
394 let server = wiremock::MockServer::start().await;
395 wiremock::Mock::given(wiremock::matchers::method("GET"))
396 .and(wiremock::matchers::path("/api/v1/hosts"))
397 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
398 .mount(&server)
399 .await;
400
401 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
402 let err = HostsApi::new(&client)
403 .list(&HostsListFilter::default(), 5)
404 .await
405 .unwrap_err();
406 assert!(err.to_string().contains("Failed to parse"));
407 }
408}