use anyhow::{Context, Result};
use url::Url;
use crate::datadog::client::DatadogClient;
use crate::datadog::types::MetricQueryResponse;
#[derive(Debug)]
pub struct MetricsApi<'a> {
client: &'a DatadogClient,
}
impl<'a> MetricsApi<'a> {
#[must_use]
pub fn new(client: &'a DatadogClient) -> Self {
Self { client }
}
pub async fn point_query(
&self,
query: &str,
from: i64,
to: i64,
) -> Result<MetricQueryResponse> {
let url = build_query_url(self.client.base_url(), query, from, to)?;
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::<MetricQueryResponse>()
.await
.context("Failed to parse /api/v1/query response")
}
}
fn build_query_url(base_url: &str, query: &str, from: i64, to: i64) -> Result<Url> {
let mut url =
Url::parse(&format!("{base_url}/api/v1/query")).context("Invalid Datadog base URL")?;
url.query_pairs_mut()
.append_pair("from", &from.to_string())
.append_pair("to", &to.to_string())
.append_pair("query", query);
Ok(url)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn build_query_url_encodes_special_chars() {
let url = build_query_url(
"https://api.datadoghq.com",
"avg:system.cpu.user{host:web-01}",
100,
200,
)
.unwrap();
let qs = url.query().unwrap();
assert!(qs.contains("from=100"));
assert!(qs.contains("to=200"));
assert!(qs.contains("query=avg%3Asystem.cpu.user%7Bhost%3Aweb-01%7D"));
}
#[test]
fn build_query_url_strips_trailing_slash_on_base() {
let url = build_query_url("https://api.datadoghq.com", "m", 0, 1).unwrap();
assert_eq!(url.path(), "/api/v1/query");
}
#[test]
fn build_query_url_rejects_invalid_base() {
let err = build_query_url("not a url", "m", 0, 1).unwrap_err();
assert!(err.to_string().contains("Invalid Datadog base URL"));
}
fn sample_body() -> serde_json::Value {
serde_json::json!({
"status": "ok",
"from_date": 1_700_000_000_000_i64,
"to_date": 1_700_000_030_000_i64,
"series": [
{
"metric": "avg:system.cpu.user{*}",
"display_name": "avg:system.cpu.user{*}",
"scope": "host:*",
"expression": "avg:system.cpu.user{*}",
"pointlist": [
[1_700_000_000_000_i64, 0.5_f64],
[1_700_000_015_000_i64, null],
[1_700_000_030_000_i64, 0.6_f64]
]
}
]
})
}
#[tokio::test]
async fn point_query_returns_parsed_response() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/query"))
.and(wiremock::matchers::query_param("from", "100"))
.and(wiremock::matchers::query_param("to", "200"))
.and(wiremock::matchers::query_param(
"query",
"avg:system.cpu.user{*}",
))
.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(sample_body()))
.expect(1)
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let resp = MetricsApi::new(&client)
.point_query("avg:system.cpu.user{*}", 100, 200)
.await
.unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.series.len(), 1);
assert_eq!(resp.series[0].pointlist.len(), 3);
assert_eq!(resp.series[0].pointlist[1].1, None);
}
#[tokio::test]
async fn point_query_propagates_api_errors_with_body() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/query"))
.respond_with(
wiremock::ResponseTemplate::new(400).set_body_string(r#"{"errors":["Bad query"]}"#),
)
.mount(&server)
.await;
let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
let err = MetricsApi::new(&client)
.point_query("bad!!", 0, 1)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("400"));
assert!(msg.contains("Bad query"));
}
#[tokio::test]
async fn point_query_propagates_invalid_base_url_error() {
let client = DatadogClient::new("not a url", "api", "app").unwrap();
let err = MetricsApi::new(&client)
.point_query("m", 0, 1)
.await
.unwrap_err();
assert!(err.to_string().contains("Invalid Datadog base URL"));
}
#[tokio::test]
async fn point_query_propagates_network_errors() {
let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
let err = MetricsApi::new(&client)
.point_query("m", 0, 1)
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to send"));
}
#[tokio::test]
async fn point_query_errors_on_malformed_response() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/v1/query"))
.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 = MetricsApi::new(&client)
.point_query("m", 0, 1)
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to parse"));
}
}