1use anyhow::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 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 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
69fn 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
79fn 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 #[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 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 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 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 #[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 #[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}