use anyhow::{Context, Result};
use url::Url;
use crate::datadog::client::DatadogClient;
use crate::datadog::types::{Dashboard, DashboardListResponse, DashboardSummary};
#[derive(Debug, Default, Clone)]
pub struct DashboardListFilter {
pub filter_shared: Option<bool>,
}
#[derive(Debug)]
pub struct DashboardsApi<'a> {
client: &'a DatadogClient,
}
impl<'a> DashboardsApi<'a> {
#[must_use]
pub fn new(client: &'a DatadogClient) -> Self {
Self { client }
}
pub async fn list(&self, filter: &DashboardListFilter) -> Result<Vec<DashboardSummary>> {
let url = build_list_url(self.client.base_url(), filter)?;
let response = self.client.get_json(url.as_str()).await?;
if !response.status().is_success() {
return Err(DatadogClient::response_to_error(response).await.into());
}
let parsed: DashboardListResponse = response
.json()
.await
.context("Failed to parse /api/v1/dashboard response")?;
Ok(parsed.dashboards)
}
pub async fn get(&self, id: &str) -> Result<Dashboard> {
let url = build_get_url(self.client.base_url(), id)?;
let response = self.client.get_json(url.as_str()).await?;
if !response.status().is_success() {
return Err(DatadogClient::response_to_error(response).await.into());
}
response
.json::<Dashboard>()
.await
.context("Failed to parse /api/v1/dashboard/<id> response")
}
}
fn build_list_url(base_url: &str, filter: &DashboardListFilter) -> Result<Url> {
let mut url =
Url::parse(&format!("{base_url}/api/v1/dashboard")).context("Invalid Datadog base URL")?;
if let Some(shared) = filter.filter_shared {
url.query_pairs_mut()
.append_pair("filter_shared", if shared { "true" } else { "false" });
}
Ok(url)
}
fn build_get_url(base_url: &str, id: &str) -> Result<Url> {
let mut url =
Url::parse(&format!("{base_url}/api/v1/dashboard")).context("Invalid Datadog base URL")?;
url.path_segments_mut()
.map_err(|()| anyhow::anyhow!("Invalid Datadog base URL: cannot append path segment"))?
.push(id);
Ok(url)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn build_list_url_omits_filter_when_unset() {
let url =
build_list_url("https://api.datadoghq.com", &DashboardListFilter::default()).unwrap();
assert_eq!(url.path(), "/api/v1/dashboard");
assert!(url.query().is_none());
}
#[test]
fn build_list_url_appends_filter_shared_true() {
let url = build_list_url(
"https://api.datadoghq.com",
&DashboardListFilter {
filter_shared: Some(true),
},
)
.unwrap();
assert_eq!(url.query(), Some("filter_shared=true"));
}
#[test]
fn build_list_url_appends_filter_shared_false() {
let url = build_list_url(
"https://api.datadoghq.com",
&DashboardListFilter {
filter_shared: Some(false),
},
)
.unwrap();
assert_eq!(url.query(), Some("filter_shared=false"));
}
#[test]
fn build_list_url_rejects_invalid_base() {
let err = build_list_url("not a url", &DashboardListFilter::default()).unwrap_err();
assert!(err.to_string().contains("Invalid Datadog base URL"));
}
#[test]
fn build_get_url_includes_id_path_segment() {
let url = build_get_url("https://api.datadoghq.com", "abc-def-ghi").unwrap();
assert_eq!(url.path(), "/api/v1/dashboard/abc-def-ghi");
}
#[test]
fn build_get_url_percent_encodes_reserved_chars_in_id() {
let url = build_get_url("https://api.datadoghq.com", "weird/id").unwrap();
assert_eq!(url.path(), "/api/v1/dashboard/weird%2Fid");
}
#[test]
fn build_get_url_rejects_invalid_base() {
let err = build_get_url("not a url", "id").unwrap_err();
assert!(err.to_string().contains("Invalid Datadog base URL"));
}
#[test]
fn build_get_url_rejects_cannot_be_a_base_scheme() {
let err = build_get_url("mailto:test@example.com", "id").unwrap_err();
assert!(err.to_string().contains("cannot append path segment"));
}
fn dashboard_summary_json(id: &str, title: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"title": title,
"author_handle": "alice@example.com",
"url": format!("/dashboard/{id}"),
"modified_at": "2024-02-01T00:00:00.000Z",
"is_shared": true
})
}
fn dashboard_full_json(id: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"title": "Service Overview",
"description": "Top-level service health.",
"url": format!("/dashboard/{id}"),
"author_handle": "alice@example.com",
"layout_type": "ordered",
"widgets": [
{"id": 1, "definition": {"type": "note", "content": "hello"}}
]
})
}
#[tokio::test]
async fn list_returns_parsed_dashboards() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/dashboard"))
.and(wiremock::matchers::header("DD-API-KEY", "api"))
.and(wiremock::matchers::header("DD-APPLICATION-KEY", "app"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"dashboards": [
dashboard_summary_json("abc", "Service A"),
dashboard_summary_json("def", "Service B")
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let dashboards = DashboardsApi::new(&client)
.list(&DashboardListFilter::default())
.await
.unwrap();
assert_eq!(dashboards.len(), 2);
assert_eq!(dashboards[0].id, "abc");
assert_eq!(dashboards[1].title, "Service B");
}
#[tokio::test]
async fn list_passes_filter_shared_query_param() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/dashboard"))
.and(wiremock::matchers::query_param("filter_shared", "true"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"dashboards": [dashboard_summary_json("abc", "Service A")]
})),
)
.expect(1)
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let dashboards = DashboardsApi::new(&client)
.list(&DashboardListFilter {
filter_shared: Some(true),
})
.await
.unwrap();
assert_eq!(dashboards.len(), 1);
}
#[tokio::test]
async fn list_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/dashboard"))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_string(r#"{"errors":["nope"]}"#),
)
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let err = DashboardsApi::new(&client)
.list(&DashboardListFilter::default())
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("403"));
assert!(msg.contains("nope"));
}
#[tokio::test]
async fn list_propagates_invalid_base_url_error() {
let client = DatadogClient::new("not a url", "api", "app").unwrap();
let err = DashboardsApi::new(&client)
.list(&DashboardListFilter::default())
.await
.unwrap_err();
assert!(err.to_string().contains("Invalid Datadog base URL"));
}
#[tokio::test]
async fn list_propagates_network_errors() {
let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
let err = DashboardsApi::new(&client)
.list(&DashboardListFilter::default())
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to send"));
}
#[tokio::test]
async fn list_errors_on_malformed_response() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/dashboard"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let err = DashboardsApi::new(&client)
.list(&DashboardListFilter::default())
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to parse"));
}
#[tokio::test]
async fn get_returns_parsed_dashboard() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/dashboard/abc-def-ghi"))
.and(wiremock::matchers::header("DD-API-KEY", "api"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(dashboard_full_json("abc-def-ghi")),
)
.expect(1)
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let d = DashboardsApi::new(&client)
.get("abc-def-ghi")
.await
.unwrap();
assert_eq!(d.id, "abc-def-ghi");
assert_eq!(d.title, "Service Overview");
assert!(d.widgets.is_some());
}
#[tokio::test]
async fn get_propagates_404() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/dashboard/missing"))
.respond_with(
wiremock::ResponseTemplate::new(404).set_body_string(r#"{"errors":["Not found"]}"#),
)
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let err = DashboardsApi::new(&client)
.get("missing")
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
assert!(err.to_string().contains("Not found"));
}
#[tokio::test]
async fn get_propagates_invalid_base_url_error() {
let client = DatadogClient::new("not a url", "api", "app").unwrap();
let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
assert!(err.to_string().contains("Invalid Datadog base URL"));
}
#[tokio::test]
async fn get_propagates_network_errors() {
let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
assert!(err.to_string().contains("Failed to send"));
}
#[tokio::test]
async fn get_errors_on_malformed_response() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/dashboard/x"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let err = DashboardsApi::new(&client).get("x").await.unwrap_err();
assert!(err.to_string().contains("Failed to parse"));
}
}