Skip to main content

omni_dev/datadog/
dashboards_api.rs

1//! Datadog Dashboards API wrapper.
2//!
3//! Exposes a thin façade over [`DatadogClient`] for the read-only dashboard
4//! endpoints needed by the CLI: list and get.
5//!
6//! Unlike the monitor endpoints, `GET /api/v1/dashboard` returns *all*
7//! dashboards in a single response — no server-side pagination — so the
8//! list façade does not loop. Any client-side `--limit` truncation belongs
9//! in the CLI layer.
10
11use anyhow::{Context, Result};
12use url::Url;
13
14use crate::datadog::client::DatadogClient;
15use crate::datadog::types::{Dashboard, DashboardListResponse, DashboardSummary};
16
17/// Filters accepted by `GET /api/v1/dashboard`.
18///
19/// Datadog accepts `filter_shared` as a boolean query parameter; the
20/// builder appends it only when the field is `Some(_)` so callers can
21/// distinguish "unset" from "explicitly set to false".
22#[derive(Debug, Default, Clone)]
23pub struct DashboardListFilter {
24    /// When `Some`, restricts the response to shared (or non-shared)
25    /// dashboards depending on the boolean value.
26    pub filter_shared: Option<bool>,
27}
28
29/// Dashboards API façade.
30#[derive(Debug)]
31pub struct DashboardsApi<'a> {
32    client: &'a DatadogClient,
33}
34
35impl<'a> DashboardsApi<'a> {
36    /// Wraps an existing [`DatadogClient`] for dashboard operations.
37    #[must_use]
38    pub fn new(client: &'a DatadogClient) -> Self {
39        Self { client }
40    }
41
42    /// Lists dashboards matching `filter`.
43    ///
44    /// Datadog returns every dashboard in one response; this method
45    /// makes a single HTTP call and returns the parsed `dashboards`
46    /// array. There is no auto-pagination because the API does not
47    /// page this endpoint.
48    pub async fn list(&self, filter: &DashboardListFilter) -> Result<Vec<DashboardSummary>> {
49        let url = build_list_url(self.client.base_url(), filter)?;
50        let response = self.client.get_json(url.as_str()).await?;
51        if !response.status().is_success() {
52            return Err(DatadogClient::response_to_error(response).await.into());
53        }
54        let parsed: DashboardListResponse = response
55            .json()
56            .await
57            .context("Failed to parse /api/v1/dashboard response")?;
58        Ok(parsed.dashboards)
59    }
60
61    /// Fetches a single dashboard definition by id.
62    pub async fn get(&self, id: &str) -> Result<Dashboard> {
63        let url = build_get_url(self.client.base_url(), id)?;
64        let response = self.client.get_json(url.as_str()).await?;
65        if !response.status().is_success() {
66            return Err(DatadogClient::response_to_error(response).await.into());
67        }
68        response
69            .json::<Dashboard>()
70            .await
71            .context("Failed to parse /api/v1/dashboard/<id> response")
72    }
73}
74
75/// Builds `{base_url}/api/v1/dashboard?{filters}`.
76fn build_list_url(base_url: &str, filter: &DashboardListFilter) -> Result<Url> {
77    let mut url =
78        Url::parse(&format!("{base_url}/api/v1/dashboard")).context("Invalid Datadog base URL")?;
79    if let Some(shared) = filter.filter_shared {
80        url.query_pairs_mut()
81            .append_pair("filter_shared", if shared { "true" } else { "false" });
82    }
83    Ok(url)
84}
85
86/// Builds `{base_url}/api/v1/dashboard/{id}`.
87///
88/// `id` is percent-encoded as a path segment so dashboard ids that
89/// contain reserved characters round-trip correctly.
90fn build_get_url(base_url: &str, id: &str) -> Result<Url> {
91    let mut url =
92        Url::parse(&format!("{base_url}/api/v1/dashboard")).context("Invalid Datadog base URL")?;
93    url.path_segments_mut()
94        .map_err(|()| anyhow::anyhow!("Invalid Datadog base URL: cannot append path segment"))?
95        .push(id);
96    Ok(url)
97}
98
99#[cfg(test)]
100#[allow(clippy::unwrap_used, clippy::expect_used)]
101mod tests {
102    use super::*;
103
104    // ── URL builders ───────────────────────────────────────────────
105
106    #[test]
107    fn build_list_url_omits_filter_when_unset() {
108        let url =
109            build_list_url("https://api.datadoghq.com", &DashboardListFilter::default()).unwrap();
110        assert_eq!(url.path(), "/api/v1/dashboard");
111        assert!(url.query().is_none());
112    }
113
114    #[test]
115    fn build_list_url_appends_filter_shared_true() {
116        let url = build_list_url(
117            "https://api.datadoghq.com",
118            &DashboardListFilter {
119                filter_shared: Some(true),
120            },
121        )
122        .unwrap();
123        assert_eq!(url.query(), Some("filter_shared=true"));
124    }
125
126    #[test]
127    fn build_list_url_appends_filter_shared_false() {
128        let url = build_list_url(
129            "https://api.datadoghq.com",
130            &DashboardListFilter {
131                filter_shared: Some(false),
132            },
133        )
134        .unwrap();
135        assert_eq!(url.query(), Some("filter_shared=false"));
136    }
137
138    #[test]
139    fn build_list_url_rejects_invalid_base() {
140        let err = build_list_url("not a url", &DashboardListFilter::default()).unwrap_err();
141        assert!(err.to_string().contains("Invalid Datadog base URL"));
142    }
143
144    #[test]
145    fn build_get_url_includes_id_path_segment() {
146        let url = build_get_url("https://api.datadoghq.com", "abc-def-ghi").unwrap();
147        assert_eq!(url.path(), "/api/v1/dashboard/abc-def-ghi");
148    }
149
150    #[test]
151    fn build_get_url_percent_encodes_reserved_chars_in_id() {
152        let url = build_get_url("https://api.datadoghq.com", "weird/id").unwrap();
153        // `/` in a single path segment is percent-encoded; the resulting
154        // path therefore stays under /api/v1/dashboard with one segment.
155        assert_eq!(url.path(), "/api/v1/dashboard/weird%2Fid");
156    }
157
158    #[test]
159    fn build_get_url_rejects_invalid_base() {
160        let err = build_get_url("not a url", "id").unwrap_err();
161        assert!(err.to_string().contains("Invalid Datadog base URL"));
162    }
163
164    #[test]
165    fn build_get_url_rejects_cannot_be_a_base_scheme() {
166        // `mailto:` parses successfully via `Url::parse` but is a
167        // cannot-be-a-base URL, so `path_segments_mut` returns Err(()).
168        // This exercises the `map_err` arm that's otherwise unreachable
169        // from the production base-URL inputs.
170        let err = build_get_url("mailto:test@example.com", "id").unwrap_err();
171        assert!(err.to_string().contains("cannot append path segment"));
172    }
173
174    // ── fixtures ───────────────────────────────────────────────────
175
176    fn dashboard_summary_json(id: &str, title: &str) -> serde_json::Value {
177        serde_json::json!({
178            "id": id,
179            "title": title,
180            "author_handle": "alice@example.com",
181            "url": format!("/dashboard/{id}"),
182            "modified_at": "2024-02-01T00:00:00.000Z",
183            "is_shared": true
184        })
185    }
186
187    fn dashboard_full_json(id: &str) -> serde_json::Value {
188        serde_json::json!({
189            "id": id,
190            "title": "Service Overview",
191            "description": "Top-level service health.",
192            "url": format!("/dashboard/{id}"),
193            "author_handle": "alice@example.com",
194            "layout_type": "ordered",
195            "widgets": [
196                {"id": 1, "definition": {"type": "note", "content": "hello"}}
197            ]
198        })
199    }
200
201    // ── list happy path / errors ───────────────────────────────────
202
203    #[tokio::test]
204    async fn list_returns_parsed_dashboards() {
205        let server = wiremock::MockServer::start().await;
206        wiremock::Mock::given(wiremock::matchers::method("GET"))
207            .and(wiremock::matchers::path("/api/v1/dashboard"))
208            .and(wiremock::matchers::header("DD-API-KEY", "api"))
209            .and(wiremock::matchers::header("DD-APPLICATION-KEY", "app"))
210            .respond_with(
211                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
212                    "dashboards": [
213                        dashboard_summary_json("abc", "Service A"),
214                        dashboard_summary_json("def", "Service B")
215                    ]
216                })),
217            )
218            .expect(1)
219            .mount(&server)
220            .await;
221
222        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
223        let dashboards = DashboardsApi::new(&client)
224            .list(&DashboardListFilter::default())
225            .await
226            .unwrap();
227        assert_eq!(dashboards.len(), 2);
228        assert_eq!(dashboards[0].id, "abc");
229        assert_eq!(dashboards[1].title, "Service B");
230    }
231
232    #[tokio::test]
233    async fn list_passes_filter_shared_query_param() {
234        let server = wiremock::MockServer::start().await;
235        wiremock::Mock::given(wiremock::matchers::method("GET"))
236            .and(wiremock::matchers::path("/api/v1/dashboard"))
237            .and(wiremock::matchers::query_param("filter_shared", "true"))
238            .respond_with(
239                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
240                    "dashboards": [dashboard_summary_json("abc", "Service A")]
241                })),
242            )
243            .expect(1)
244            .mount(&server)
245            .await;
246
247        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
248        let dashboards = DashboardsApi::new(&client)
249            .list(&DashboardListFilter {
250                filter_shared: Some(true),
251            })
252            .await
253            .unwrap();
254        assert_eq!(dashboards.len(), 1);
255    }
256
257    #[tokio::test]
258    async fn list_propagates_api_errors() {
259        let server = wiremock::MockServer::start().await;
260        wiremock::Mock::given(wiremock::matchers::method("GET"))
261            .and(wiremock::matchers::path("/api/v1/dashboard"))
262            .respond_with(
263                wiremock::ResponseTemplate::new(403).set_body_string(r#"{"errors":["nope"]}"#),
264            )
265            .mount(&server)
266            .await;
267
268        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
269        let err = DashboardsApi::new(&client)
270            .list(&DashboardListFilter::default())
271            .await
272            .unwrap_err();
273        let msg = err.to_string();
274        assert!(msg.contains("403"));
275        assert!(msg.contains("nope"));
276    }
277
278    #[tokio::test]
279    async fn list_propagates_invalid_base_url_error() {
280        let client = DatadogClient::new("not a url", "api", "app").unwrap();
281        let err = DashboardsApi::new(&client)
282            .list(&DashboardListFilter::default())
283            .await
284            .unwrap_err();
285        assert!(err.to_string().contains("Invalid Datadog base URL"));
286    }
287
288    #[tokio::test]
289    async fn list_propagates_network_errors() {
290        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
291        let err = DashboardsApi::new(&client)
292            .list(&DashboardListFilter::default())
293            .await
294            .unwrap_err();
295        assert!(err.to_string().contains("Failed to send"));
296    }
297
298    #[tokio::test]
299    async fn list_errors_on_malformed_response() {
300        let server = wiremock::MockServer::start().await;
301        wiremock::Mock::given(wiremock::matchers::method("GET"))
302            .and(wiremock::matchers::path("/api/v1/dashboard"))
303            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
304            .mount(&server)
305            .await;
306
307        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
308        let err = DashboardsApi::new(&client)
309            .list(&DashboardListFilter::default())
310            .await
311            .unwrap_err();
312        assert!(err.to_string().contains("Failed to parse"));
313    }
314
315    // ── get happy path / errors ────────────────────────────────────
316
317    #[tokio::test]
318    async fn get_returns_parsed_dashboard() {
319        let server = wiremock::MockServer::start().await;
320        wiremock::Mock::given(wiremock::matchers::method("GET"))
321            .and(wiremock::matchers::path("/api/v1/dashboard/abc-def-ghi"))
322            .and(wiremock::matchers::header("DD-API-KEY", "api"))
323            .respond_with(
324                wiremock::ResponseTemplate::new(200)
325                    .set_body_json(dashboard_full_json("abc-def-ghi")),
326            )
327            .expect(1)
328            .mount(&server)
329            .await;
330
331        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
332        let d = DashboardsApi::new(&client)
333            .get("abc-def-ghi")
334            .await
335            .unwrap();
336        assert_eq!(d.id, "abc-def-ghi");
337        assert_eq!(d.title, "Service Overview");
338        assert!(d.widgets.is_some());
339    }
340
341    #[tokio::test]
342    async fn get_propagates_404() {
343        let server = wiremock::MockServer::start().await;
344        wiremock::Mock::given(wiremock::matchers::method("GET"))
345            .and(wiremock::matchers::path("/api/v1/dashboard/missing"))
346            .respond_with(
347                wiremock::ResponseTemplate::new(404).set_body_string(r#"{"errors":["Not found"]}"#),
348            )
349            .mount(&server)
350            .await;
351
352        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
353        let err = DashboardsApi::new(&client)
354            .get("missing")
355            .await
356            .unwrap_err();
357        assert!(err.to_string().contains("404"));
358        assert!(err.to_string().contains("Not found"));
359    }
360
361    #[tokio::test]
362    async fn get_propagates_invalid_base_url_error() {
363        let client = DatadogClient::new("not a url", "api", "app").unwrap();
364        let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
365        assert!(err.to_string().contains("Invalid Datadog base URL"));
366    }
367
368    #[tokio::test]
369    async fn get_propagates_network_errors() {
370        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
371        let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
372        assert!(err.to_string().contains("Failed to send"));
373    }
374
375    #[tokio::test]
376    async fn get_errors_on_malformed_response() {
377        let server = wiremock::MockServer::start().await;
378        wiremock::Mock::given(wiremock::matchers::method("GET"))
379            .and(wiremock::matchers::path("/api/v1/dashboard/x"))
380            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
381            .mount(&server)
382            .await;
383
384        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
385        let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
386        assert!(err.to_string().contains("Failed to parse"));
387    }
388}