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