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::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 parsed: DashboardListResponse = self
51            .client
52            .get_parsed(url.as_str(), "Failed to parse /api/v1/dashboard response")
53            .await?;
54        Ok(parsed.dashboards)
55    }
56
57    /// Fetches a single dashboard definition by id.
58    pub async fn get(&self, id: &str) -> Result<Dashboard> {
59        let url = build_get_url(self.client.base_url(), id)?;
60        self.client
61            .get_parsed(
62                url.as_str(),
63                "Failed to parse /api/v1/dashboard/<id> response",
64            )
65            .await
66    }
67}
68
69/// Builds `{base_url}/api/v1/dashboard?{filters}`.
70fn build_list_url(base_url: &str, filter: &DashboardListFilter) -> Result<Url> {
71    let mut url = DatadogClient::api_url(base_url, "/api/v1/dashboard")?;
72    if let Some(shared) = filter.filter_shared {
73        url.query_pairs_mut()
74            .append_pair("filter_shared", if shared { "true" } else { "false" });
75    }
76    Ok(url)
77}
78
79/// Builds `{base_url}/api/v1/dashboard/{id}`.
80///
81/// `id` is percent-encoded as a path segment so dashboard ids that
82/// contain reserved characters round-trip correctly.
83fn build_get_url(base_url: &str, id: &str) -> Result<Url> {
84    let mut url = DatadogClient::api_url(base_url, "/api/v1/dashboard")?;
85    url.path_segments_mut()
86        .map_err(|()| anyhow::anyhow!("Invalid Datadog base URL: cannot append path segment"))?
87        .push(id);
88    Ok(url)
89}
90
91#[cfg(test)]
92#[allow(clippy::unwrap_used, clippy::expect_used)]
93mod tests {
94    use super::*;
95
96    // ── URL builders ───────────────────────────────────────────────
97
98    #[test]
99    fn build_list_url_omits_filter_when_unset() {
100        let url =
101            build_list_url("https://api.datadoghq.com", &DashboardListFilter::default()).unwrap();
102        assert_eq!(url.path(), "/api/v1/dashboard");
103        assert!(url.query().is_none());
104    }
105
106    #[test]
107    fn build_list_url_appends_filter_shared_true() {
108        let url = build_list_url(
109            "https://api.datadoghq.com",
110            &DashboardListFilter {
111                filter_shared: Some(true),
112            },
113        )
114        .unwrap();
115        assert_eq!(url.query(), Some("filter_shared=true"));
116    }
117
118    #[test]
119    fn build_list_url_appends_filter_shared_false() {
120        let url = build_list_url(
121            "https://api.datadoghq.com",
122            &DashboardListFilter {
123                filter_shared: Some(false),
124            },
125        )
126        .unwrap();
127        assert_eq!(url.query(), Some("filter_shared=false"));
128    }
129
130    #[test]
131    fn build_list_url_rejects_invalid_base() {
132        let err = build_list_url("not a url", &DashboardListFilter::default()).unwrap_err();
133        assert!(err.to_string().contains("Invalid Datadog base URL"));
134    }
135
136    #[test]
137    fn build_get_url_includes_id_path_segment() {
138        let url = build_get_url("https://api.datadoghq.com", "abc-def-ghi").unwrap();
139        assert_eq!(url.path(), "/api/v1/dashboard/abc-def-ghi");
140    }
141
142    #[test]
143    fn build_get_url_percent_encodes_reserved_chars_in_id() {
144        let url = build_get_url("https://api.datadoghq.com", "weird/id").unwrap();
145        // `/` in a single path segment is percent-encoded; the resulting
146        // path therefore stays under /api/v1/dashboard with one segment.
147        assert_eq!(url.path(), "/api/v1/dashboard/weird%2Fid");
148    }
149
150    #[test]
151    fn build_get_url_rejects_invalid_base() {
152        let err = build_get_url("not a url", "id").unwrap_err();
153        assert!(err.to_string().contains("Invalid Datadog base URL"));
154    }
155
156    #[test]
157    fn build_get_url_rejects_cannot_be_a_base_scheme() {
158        // `mailto:` parses successfully via `Url::parse` but is a
159        // cannot-be-a-base URL, so `path_segments_mut` returns Err(()).
160        // This exercises the `map_err` arm that's otherwise unreachable
161        // from the production base-URL inputs.
162        let err = build_get_url("mailto:test@example.com", "id").unwrap_err();
163        assert!(err.to_string().contains("cannot append path segment"));
164    }
165
166    // ── fixtures ───────────────────────────────────────────────────
167
168    fn dashboard_summary_json(id: &str, title: &str) -> serde_json::Value {
169        serde_json::json!({
170            "id": id,
171            "title": title,
172            "author_handle": "alice@example.com",
173            "url": format!("/dashboard/{id}"),
174            "modified_at": "2024-02-01T00:00:00.000Z",
175            "is_shared": true
176        })
177    }
178
179    fn dashboard_full_json(id: &str) -> serde_json::Value {
180        serde_json::json!({
181            "id": id,
182            "title": "Service Overview",
183            "description": "Top-level service health.",
184            "url": format!("/dashboard/{id}"),
185            "author_handle": "alice@example.com",
186            "layout_type": "ordered",
187            "widgets": [
188                {"id": 1, "definition": {"type": "note", "content": "hello"}}
189            ]
190        })
191    }
192
193    // ── list happy path / errors ───────────────────────────────────
194
195    #[tokio::test]
196    async fn list_returns_parsed_dashboards() {
197        let server = wiremock::MockServer::start().await;
198        wiremock::Mock::given(wiremock::matchers::method("GET"))
199            .and(wiremock::matchers::path("/api/v1/dashboard"))
200            .and(wiremock::matchers::header("DD-API-KEY", "api"))
201            .and(wiremock::matchers::header("DD-APPLICATION-KEY", "app"))
202            .respond_with(
203                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
204                    "dashboards": [
205                        dashboard_summary_json("abc", "Service A"),
206                        dashboard_summary_json("def", "Service B")
207                    ]
208                })),
209            )
210            .expect(1)
211            .mount(&server)
212            .await;
213
214        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
215        let dashboards = DashboardsApi::new(&client)
216            .list(&DashboardListFilter::default())
217            .await
218            .unwrap();
219        assert_eq!(dashboards.len(), 2);
220        assert_eq!(dashboards[0].id, "abc");
221        assert_eq!(dashboards[1].title, "Service B");
222    }
223
224    #[tokio::test]
225    async fn list_passes_filter_shared_query_param() {
226        let server = wiremock::MockServer::start().await;
227        wiremock::Mock::given(wiremock::matchers::method("GET"))
228            .and(wiremock::matchers::path("/api/v1/dashboard"))
229            .and(wiremock::matchers::query_param("filter_shared", "true"))
230            .respond_with(
231                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
232                    "dashboards": [dashboard_summary_json("abc", "Service A")]
233                })),
234            )
235            .expect(1)
236            .mount(&server)
237            .await;
238
239        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
240        let dashboards = DashboardsApi::new(&client)
241            .list(&DashboardListFilter {
242                filter_shared: Some(true),
243            })
244            .await
245            .unwrap();
246        assert_eq!(dashboards.len(), 1);
247    }
248
249    #[tokio::test]
250    async fn list_propagates_api_errors() {
251        let server = wiremock::MockServer::start().await;
252        wiremock::Mock::given(wiremock::matchers::method("GET"))
253            .and(wiremock::matchers::path("/api/v1/dashboard"))
254            .respond_with(
255                wiremock::ResponseTemplate::new(403).set_body_string(r#"{"errors":["nope"]}"#),
256            )
257            .mount(&server)
258            .await;
259
260        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
261        let err = DashboardsApi::new(&client)
262            .list(&DashboardListFilter::default())
263            .await
264            .unwrap_err();
265        let msg = err.to_string();
266        assert!(msg.contains("403"));
267        assert!(msg.contains("nope"));
268    }
269
270    #[tokio::test]
271    async fn list_propagates_invalid_base_url_error() {
272        let client = DatadogClient::new("not a url", "api", "app").unwrap();
273        let err = DashboardsApi::new(&client)
274            .list(&DashboardListFilter::default())
275            .await
276            .unwrap_err();
277        assert!(err.to_string().contains("Invalid Datadog base URL"));
278    }
279
280    #[tokio::test]
281    async fn list_propagates_network_errors() {
282        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
283        let err = DashboardsApi::new(&client)
284            .list(&DashboardListFilter::default())
285            .await
286            .unwrap_err();
287        assert!(err.to_string().contains("Failed to send"));
288    }
289
290    #[tokio::test]
291    async fn list_errors_on_malformed_response() {
292        let server = wiremock::MockServer::start().await;
293        wiremock::Mock::given(wiremock::matchers::method("GET"))
294            .and(wiremock::matchers::path("/api/v1/dashboard"))
295            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
296            .mount(&server)
297            .await;
298
299        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
300        let err = DashboardsApi::new(&client)
301            .list(&DashboardListFilter::default())
302            .await
303            .unwrap_err();
304        assert!(err.to_string().contains("Failed to parse"));
305    }
306
307    // ── get happy path / errors ────────────────────────────────────
308
309    #[tokio::test]
310    async fn get_returns_parsed_dashboard() {
311        let server = wiremock::MockServer::start().await;
312        wiremock::Mock::given(wiremock::matchers::method("GET"))
313            .and(wiremock::matchers::path("/api/v1/dashboard/abc-def-ghi"))
314            .and(wiremock::matchers::header("DD-API-KEY", "api"))
315            .respond_with(
316                wiremock::ResponseTemplate::new(200)
317                    .set_body_json(dashboard_full_json("abc-def-ghi")),
318            )
319            .expect(1)
320            .mount(&server)
321            .await;
322
323        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
324        let d = DashboardsApi::new(&client)
325            .get("abc-def-ghi")
326            .await
327            .unwrap();
328        assert_eq!(d.id, "abc-def-ghi");
329        assert_eq!(d.title, "Service Overview");
330        assert!(d.widgets.is_some());
331    }
332
333    #[tokio::test]
334    async fn get_propagates_404() {
335        let server = wiremock::MockServer::start().await;
336        wiremock::Mock::given(wiremock::matchers::method("GET"))
337            .and(wiremock::matchers::path("/api/v1/dashboard/missing"))
338            .respond_with(
339                wiremock::ResponseTemplate::new(404).set_body_string(r#"{"errors":["Not found"]}"#),
340            )
341            .mount(&server)
342            .await;
343
344        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
345        let err = DashboardsApi::new(&client)
346            .get("missing")
347            .await
348            .unwrap_err();
349        assert!(err.to_string().contains("404"));
350        assert!(err.to_string().contains("Not found"));
351    }
352
353    #[tokio::test]
354    async fn get_propagates_invalid_base_url_error() {
355        let client = DatadogClient::new("not a url", "api", "app").unwrap();
356        let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
357        assert!(err.to_string().contains("Invalid Datadog base URL"));
358    }
359
360    #[tokio::test]
361    async fn get_propagates_network_errors() {
362        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
363        let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
364        assert!(err.to_string().contains("Failed to send"));
365    }
366
367    #[tokio::test]
368    async fn get_errors_on_malformed_response() {
369        let server = wiremock::MockServer::start().await;
370        wiremock::Mock::given(wiremock::matchers::method("GET"))
371            .and(wiremock::matchers::path("/api/v1/dashboard/x"))
372            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
373            .mount(&server)
374            .await;
375
376        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
377        let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
378        assert!(err.to_string().contains("Failed to parse"));
379    }
380}