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::{Context, 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 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    // ── effective_cap ──────────────────────────────────────────────
143
144    #[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    // ── URL builder ────────────────────────────────────────────────
160
161    #[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        // `build_list_url_appends_full_filter_set` covers
202        // `include_muted_hosts_data=false` and `include_hosts_metadata=true`;
203        // this case exercises the reciprocal arms of both ternaries.
204        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    // ── fixtures ───────────────────────────────────────────────────
222
223    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    // ── happy path / pagination ────────────────────────────────────
235
236    #[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}