Skip to main content

omni_dev/datadog/
hosts_api.rs

1//! Datadog Hosts API wrapper.
2//!
3//! Exposes a thin façade over [`DatadogClient`] for the read-only hosts
4//! endpoint (`GET /api/v1/hosts`). Auto-paginates via `start` / `count`
5//! query parameters, capped at [`HARD_CAP`] hosts per invocation.
6
7use anyhow::Result;
8use url::Url;
9
10use crate::datadog::client::DatadogClient;
11use crate::datadog::types::{Host, HostsResponse};
12
13/// Per-call upper bound on the number of hosts returned.
14pub const HARD_CAP: usize = 10_000;
15
16/// Default page size. Datadog accepts up to 1000 per page; 100 keeps
17/// individual responses small while still being efficient for the
18/// auto-pagination loop.
19pub const LIST_PAGE_SIZE: usize = 100;
20
21/// Filters accepted by `GET /api/v1/hosts`.
22#[derive(Debug, Default, Clone)]
23pub struct HostsListFilter {
24    /// Free-text query (Datadog's `filter` parameter).
25    pub filter: Option<String>,
26    /// Cutoff in Unix epoch seconds; hosts last reporting before this
27    /// are filtered out.
28    pub from: Option<i64>,
29    /// `up` / `tags` — sort field (rarely used; kept for completeness).
30    pub sort_field: Option<String>,
31    /// `asc` / `desc`.
32    pub sort_dir: Option<String>,
33    /// Whether to include muted hosts (default: yes).
34    pub include_muted_hosts_data: Option<bool>,
35    /// Whether to include host metadata blob (default: yes).
36    pub include_hosts_metadata: Option<bool>,
37}
38
39/// Hosts API façade.
40#[derive(Debug)]
41pub struct HostsApi<'a> {
42    client: &'a DatadogClient,
43}
44
45impl<'a> HostsApi<'a> {
46    /// Wraps an existing [`DatadogClient`] for hosts operations.
47    #[must_use]
48    pub fn new(client: &'a DatadogClient) -> Self {
49        Self { client }
50    }
51
52    /// Lists hosts matching `filter`, auto-paginating as needed.
53    ///
54    /// `limit == 0` means "fetch every match up to [`HARD_CAP`]". The
55    /// `total_returned` and `total_matching` fields on the returned
56    /// envelope reflect the aggregate across all pages issued.
57    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    // ── effective_cap ──────────────────────────────────────────────
138
139    #[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    // ── URL builder ────────────────────────────────────────────────
155
156    #[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        // `build_list_url_appends_full_filter_set` covers
197        // `include_muted_hosts_data=false` and `include_hosts_metadata=true`;
198        // this case exercises the reciprocal arms of both ternaries.
199        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    // ── fixtures ───────────────────────────────────────────────────
217
218    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    // ── happy path / pagination ────────────────────────────────────
230
231    #[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}