1use anyhow::{Context, Result};
12use url::Url;
13
14use crate::datadog::client::DatadogClient;
15use crate::datadog::types::{Dashboard, DashboardListResponse, DashboardSummary};
16
17#[derive(Debug, Default, Clone)]
23pub struct DashboardListFilter {
24 pub filter_shared: Option<bool>,
27}
28
29#[derive(Debug)]
31pub struct DashboardsApi<'a> {
32 client: &'a DatadogClient,
33}
34
35impl<'a> DashboardsApi<'a> {
36 #[must_use]
38 pub fn new(client: &'a DatadogClient) -> Self {
39 Self { client }
40 }
41
42 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 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
75fn 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
86fn 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 #[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 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 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 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 #[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 #[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}