Skip to main content

omni_dev/datadog/
slo_api.rs

1//! Datadog SLO API wrapper.
2//!
3//! Exposes a thin façade over [`DatadogClient`] for the read-only Service
4//! Level Objective endpoints needed by the CLI: list and get. List
5//! auto-paginates when called with `limit == 0`, capped at [`HARD_CAP`].
6
7use anyhow::{Context, Result};
8use url::Url;
9
10use crate::datadog::client::DatadogClient;
11use crate::datadog::types::{Slo, SloGetResponse, SloListResponse};
12
13/// Per-call upper bound on the number of SLOs returned.
14pub const HARD_CAP: usize = 10_000;
15
16/// Default page size. Datadog's `/api/v1/slo` accepts up to 1000 per
17/// page; 50 keeps payloads small and matches the UI's default.
18pub const LIST_PAGE_SIZE: usize = 50;
19
20/// Filters accepted by `GET /api/v1/slo`.
21#[derive(Debug, Default, Clone)]
22pub struct SloListFilter {
23    /// Comma-separated list of `key:value` tags applied to the SLO.
24    pub tags: Option<String>,
25    /// Free-text query (Datadog's `query` parameter — substring match).
26    pub query: Option<String>,
27    /// Comma-separated list of SLO ids.
28    pub ids: Option<String>,
29    /// Comma-separated list of metric names referenced by the SLO.
30    pub metrics: Option<String>,
31}
32
33/// SLO API façade.
34#[derive(Debug)]
35pub struct SloApi<'a> {
36    client: &'a DatadogClient,
37}
38
39impl<'a> SloApi<'a> {
40    /// Wraps an existing [`DatadogClient`] for SLO operations.
41    #[must_use]
42    pub fn new(client: &'a DatadogClient) -> Self {
43        Self { client }
44    }
45
46    /// Lists SLOs matching `filter`, auto-paginating as needed.
47    ///
48    /// `limit == 0` means "fetch every match up to [`HARD_CAP`]".
49    pub async fn list(&self, filter: &SloListFilter, limit: usize) -> Result<Vec<Slo>> {
50        let cap = effective_cap(limit);
51        let mut out: Vec<Slo> = Vec::new();
52        let mut offset: usize = 0;
53        loop {
54            let remaining = cap - out.len();
55            let page_size = remaining.min(LIST_PAGE_SIZE);
56            let url = build_list_url(self.client.base_url(), filter, offset, page_size)?;
57            let response = self.client.get_json(url.as_str()).await?;
58            if !response.status().is_success() {
59                return Err(DatadogClient::response_to_error(response).await.into());
60            }
61            let parsed: SloListResponse = response
62                .json()
63                .await
64                .context("Failed to parse /api/v1/slo response")?;
65            let exhausted = parsed.data.len() < page_size;
66            let batch_len = parsed.data.len();
67            out.extend(parsed.data);
68            if out.len() >= cap || exhausted || batch_len == 0 {
69                break;
70            }
71            offset += batch_len;
72        }
73        out.truncate(cap);
74        Ok(out)
75    }
76
77    /// Fetches a single SLO definition by id.
78    pub async fn get(&self, id: &str) -> Result<Slo> {
79        let url = build_get_url(self.client.base_url(), id)?;
80        let response = self.client.get_json(url.as_str()).await?;
81        if !response.status().is_success() {
82            return Err(DatadogClient::response_to_error(response).await.into());
83        }
84        let parsed: SloGetResponse = response
85            .json()
86            .await
87            .context("Failed to parse /api/v1/slo/<id> response")?;
88        Ok(parsed.data)
89    }
90}
91
92/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as
93/// "fetch as many as the cap allows".
94fn effective_cap(limit: usize) -> usize {
95    if limit == 0 {
96        HARD_CAP
97    } else {
98        limit.min(HARD_CAP)
99    }
100}
101
102fn build_list_url(
103    base_url: &str,
104    filter: &SloListFilter,
105    offset: usize,
106    limit: usize,
107) -> Result<Url> {
108    let mut url =
109        Url::parse(&format!("{base_url}/api/v1/slo")).context("Invalid Datadog base URL")?;
110    {
111        let mut q = url.query_pairs_mut();
112        if let Some(tags) = filter.tags.as_deref() {
113            q.append_pair("tags_query", tags);
114        }
115        if let Some(query) = filter.query.as_deref() {
116            q.append_pair("query", query);
117        }
118        if let Some(ids) = filter.ids.as_deref() {
119            q.append_pair("ids", ids);
120        }
121        if let Some(metrics) = filter.metrics.as_deref() {
122            q.append_pair("metrics_query", metrics);
123        }
124        q.append_pair("offset", &offset.to_string());
125        q.append_pair("limit", &limit.to_string());
126    }
127    Ok(url)
128}
129
130fn build_get_url(base_url: &str, id: &str) -> Result<Url> {
131    let mut url =
132        Url::parse(&format!("{base_url}/api/v1/slo")).context("Invalid Datadog base URL")?;
133    url.path_segments_mut()
134        .map_err(|()| anyhow::anyhow!("Invalid Datadog base URL: cannot append path segment"))?
135        .push(id);
136    Ok(url)
137}
138
139#[cfg(test)]
140#[allow(clippy::unwrap_used, clippy::expect_used)]
141mod tests {
142    use super::*;
143
144    // ── effective_cap ──────────────────────────────────────────────
145
146    #[test]
147    fn effective_cap_zero_means_hard_cap() {
148        assert_eq!(effective_cap(0), HARD_CAP);
149    }
150
151    #[test]
152    fn effective_cap_clamps_to_hard_cap() {
153        assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
154    }
155
156    #[test]
157    fn effective_cap_passes_through_small_limits() {
158        assert_eq!(effective_cap(7), 7);
159    }
160
161    // ── URL builders ───────────────────────────────────────────────
162
163    #[test]
164    fn build_list_url_appends_only_provided_filters() {
165        let filter = SloListFilter {
166            tags: Some("team:sre".into()),
167            query: None,
168            ids: None,
169            metrics: None,
170        };
171        let url = build_list_url("https://api.datadoghq.com", &filter, 0, 50).unwrap();
172        let qs = url.query().unwrap();
173        assert!(qs.contains("tags_query=team%3Asre"));
174        assert!(qs.contains("offset=0"));
175        assert!(qs.contains("limit=50"));
176        // Param keys absent when their `Option` is `None`. Use anchored
177        // substrings so `tags_query` doesn't satisfy a bare `query=` match.
178        let keys: Vec<String> = url.query_pairs().map(|(k, _)| k.into_owned()).collect();
179        assert!(!keys.iter().any(|k| k == "query"));
180        assert!(!keys.iter().any(|k| k == "ids"));
181        assert!(!keys.iter().any(|k| k == "metrics_query"));
182    }
183
184    #[test]
185    fn build_list_url_appends_all_filters_when_present() {
186        let filter = SloListFilter {
187            tags: Some("env:prod".into()),
188            query: Some("latency".into()),
189            ids: Some("a,b".into()),
190            metrics: Some("system.cpu".into()),
191        };
192        let url = build_list_url("https://api.datadoghq.com", &filter, 25, 10).unwrap();
193        let qs = url.query().unwrap();
194        assert!(qs.contains("tags_query=env%3Aprod"));
195        assert!(qs.contains("query=latency"));
196        assert!(qs.contains("ids=a%2Cb"));
197        assert!(qs.contains("metrics_query=system.cpu"));
198        assert!(qs.contains("offset=25"));
199        assert!(qs.contains("limit=10"));
200    }
201
202    #[test]
203    fn build_list_url_rejects_invalid_base() {
204        let err = build_list_url("not a url", &SloListFilter::default(), 0, 50).unwrap_err();
205        assert!(err.to_string().contains("Invalid Datadog base URL"));
206    }
207
208    #[test]
209    fn build_get_url_includes_id_path_segment() {
210        let url = build_get_url("https://api.datadoghq.com", "abc-def").unwrap();
211        assert_eq!(url.path(), "/api/v1/slo/abc-def");
212    }
213
214    #[test]
215    fn build_get_url_percent_encodes_reserved_chars_in_id() {
216        let url = build_get_url("https://api.datadoghq.com", "weird/id").unwrap();
217        assert_eq!(url.path(), "/api/v1/slo/weird%2Fid");
218    }
219
220    #[test]
221    fn build_get_url_rejects_invalid_base() {
222        let err = build_get_url("not a url", "id").unwrap_err();
223        assert!(err.to_string().contains("Invalid Datadog base URL"));
224    }
225
226    #[test]
227    fn build_get_url_rejects_cannot_be_a_base_scheme() {
228        let err = build_get_url("mailto:test@example.com", "id").unwrap_err();
229        assert!(err.to_string().contains("cannot append path segment"));
230    }
231
232    // ── fixtures ───────────────────────────────────────────────────
233
234    fn slo_json(id: &str, name: &str) -> serde_json::Value {
235        serde_json::json!({
236            "id": id,
237            "name": name,
238            "type": "metric",
239            "tags": ["team:sre"],
240            "monitor_ids": []
241        })
242    }
243
244    // ── list happy / pagination ────────────────────────────────────
245
246    #[tokio::test]
247    async fn list_single_page_returns_parsed_slos() {
248        let server = wiremock::MockServer::start().await;
249        wiremock::Mock::given(wiremock::matchers::method("GET"))
250            .and(wiremock::matchers::path("/api/v1/slo"))
251            .and(wiremock::matchers::query_param("tags_query", "team:sre"))
252            .and(wiremock::matchers::query_param("offset", "0"))
253            .and(wiremock::matchers::query_param("limit", "5"))
254            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
255                serde_json::json!({"data": [slo_json("a", "A"), slo_json("b", "B")]}),
256            ))
257            .expect(1)
258            .mount(&server)
259            .await;
260
261        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
262        let slos = SloApi::new(&client)
263            .list(
264                &SloListFilter {
265                    tags: Some("team:sre".into()),
266                    query: None,
267                    ids: None,
268                    metrics: None,
269                },
270                5,
271            )
272            .await
273            .unwrap();
274        assert_eq!(slos.len(), 2);
275        assert_eq!(slos[0].id, "a");
276    }
277
278    #[tokio::test]
279    async fn list_auto_paginates_across_pages() {
280        let server = wiremock::MockServer::start().await;
281        // Page 0 returns LIST_PAGE_SIZE (full page).
282        let body0: Vec<serde_json::Value> = (0..LIST_PAGE_SIZE)
283            .map(|i| slo_json(&format!("p0-{i}"), "x"))
284            .collect();
285        wiremock::Mock::given(wiremock::matchers::method("GET"))
286            .and(wiremock::matchers::path("/api/v1/slo"))
287            .and(wiremock::matchers::query_param("offset", "0"))
288            .respond_with(
289                wiremock::ResponseTemplate::new(200)
290                    .set_body_json(serde_json::json!({"data": body0})),
291            )
292            .expect(1)
293            .mount(&server)
294            .await;
295        // Page 1 returns a short page → loop stops.
296        let body1: Vec<serde_json::Value> =
297            (0..7).map(|i| slo_json(&format!("p1-{i}"), "y")).collect();
298        wiremock::Mock::given(wiremock::matchers::method("GET"))
299            .and(wiremock::matchers::path("/api/v1/slo"))
300            .and(wiremock::matchers::query_param(
301                "offset",
302                LIST_PAGE_SIZE.to_string(),
303            ))
304            .respond_with(
305                wiremock::ResponseTemplate::new(200)
306                    .set_body_json(serde_json::json!({"data": body1})),
307            )
308            .expect(1)
309            .mount(&server)
310            .await;
311
312        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
313        let slos = SloApi::new(&client)
314            .list(&SloListFilter::default(), 0)
315            .await
316            .unwrap();
317        assert_eq!(slos.len(), LIST_PAGE_SIZE + 7);
318        assert_eq!(slos[0].id, "p0-0");
319    }
320
321    #[tokio::test]
322    async fn list_caps_at_explicit_limit() {
323        let server = wiremock::MockServer::start().await;
324        wiremock::Mock::given(wiremock::matchers::method("GET"))
325            .and(wiremock::matchers::path("/api/v1/slo"))
326            .and(wiremock::matchers::query_param("limit", "3"))
327            .respond_with(
328                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
329                    "data": [slo_json("a", "A"), slo_json("b", "B"), slo_json("c", "C")]
330                })),
331            )
332            .expect(1)
333            .mount(&server)
334            .await;
335
336        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
337        let slos = SloApi::new(&client)
338            .list(&SloListFilter::default(), 3)
339            .await
340            .unwrap();
341        assert_eq!(slos.len(), 3);
342    }
343
344    #[tokio::test]
345    async fn list_stops_on_empty_page() {
346        let server = wiremock::MockServer::start().await;
347        wiremock::Mock::given(wiremock::matchers::method("GET"))
348            .and(wiremock::matchers::path("/api/v1/slo"))
349            .and(wiremock::matchers::query_param("offset", "0"))
350            .respond_with(
351                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []})),
352            )
353            .expect(1)
354            .mount(&server)
355            .await;
356
357        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
358        let slos = SloApi::new(&client)
359            .list(&SloListFilter::default(), 0)
360            .await
361            .unwrap();
362        assert!(slos.is_empty());
363    }
364
365    #[tokio::test]
366    async fn list_propagates_api_errors() {
367        let server = wiremock::MockServer::start().await;
368        wiremock::Mock::given(wiremock::matchers::method("GET"))
369            .and(wiremock::matchers::path("/api/v1/slo"))
370            .respond_with(
371                wiremock::ResponseTemplate::new(403).set_body_string(r#"{"errors":["nope"]}"#),
372            )
373            .mount(&server)
374            .await;
375
376        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
377        let err = SloApi::new(&client)
378            .list(&SloListFilter::default(), 5)
379            .await
380            .unwrap_err();
381        let msg = err.to_string();
382        assert!(msg.contains("403"));
383        assert!(msg.contains("nope"));
384    }
385
386    #[tokio::test]
387    async fn list_propagates_invalid_base_url_error() {
388        let client = DatadogClient::new("not a url", "api", "app").unwrap();
389        let err = SloApi::new(&client)
390            .list(&SloListFilter::default(), 5)
391            .await
392            .unwrap_err();
393        assert!(err.to_string().contains("Invalid Datadog base URL"));
394    }
395
396    #[tokio::test]
397    async fn list_propagates_network_errors() {
398        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
399        let err = SloApi::new(&client)
400            .list(&SloListFilter::default(), 5)
401            .await
402            .unwrap_err();
403        assert!(err.to_string().contains("Failed to send"));
404    }
405
406    #[tokio::test]
407    async fn list_errors_on_malformed_response() {
408        let server = wiremock::MockServer::start().await;
409        wiremock::Mock::given(wiremock::matchers::method("GET"))
410            .and(wiremock::matchers::path("/api/v1/slo"))
411            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
412            .mount(&server)
413            .await;
414
415        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
416        let err = SloApi::new(&client)
417            .list(&SloListFilter::default(), 5)
418            .await
419            .unwrap_err();
420        assert!(err.to_string().contains("Failed to parse"));
421    }
422
423    // ── get happy / errors ─────────────────────────────────────────
424
425    #[tokio::test]
426    async fn get_returns_unwrapped_slo() {
427        let server = wiremock::MockServer::start().await;
428        wiremock::Mock::given(wiremock::matchers::method("GET"))
429            .and(wiremock::matchers::path("/api/v1/slo/abc-def"))
430            .and(wiremock::matchers::header("DD-API-KEY", "api"))
431            .respond_with(
432                wiremock::ResponseTemplate::new(200)
433                    .set_body_json(serde_json::json!({"data": slo_json("abc-def", "Latency")})),
434            )
435            .expect(1)
436            .mount(&server)
437            .await;
438
439        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
440        let s = SloApi::new(&client).get("abc-def").await.unwrap();
441        assert_eq!(s.id, "abc-def");
442        assert_eq!(s.name, "Latency");
443    }
444
445    #[tokio::test]
446    async fn get_propagates_404() {
447        let server = wiremock::MockServer::start().await;
448        wiremock::Mock::given(wiremock::matchers::method("GET"))
449            .and(wiremock::matchers::path("/api/v1/slo/missing"))
450            .respond_with(
451                wiremock::ResponseTemplate::new(404).set_body_string(r#"{"errors":["nope"]}"#),
452            )
453            .mount(&server)
454            .await;
455
456        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
457        let err = SloApi::new(&client).get("missing").await.unwrap_err();
458        let msg = err.to_string();
459        assert!(msg.contains("404"));
460        assert!(msg.contains("nope"));
461    }
462
463    #[tokio::test]
464    async fn get_propagates_invalid_base_url_error() {
465        let client = DatadogClient::new("not a url", "api", "app").unwrap();
466        let err = SloApi::new(&client).get("x").await.unwrap_err();
467        assert!(err.to_string().contains("Invalid Datadog base URL"));
468    }
469
470    #[tokio::test]
471    async fn get_propagates_network_errors() {
472        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
473        let err = SloApi::new(&client).get("x").await.unwrap_err();
474        assert!(err.to_string().contains("Failed to send"));
475    }
476
477    #[tokio::test]
478    async fn get_errors_on_malformed_response() {
479        let server = wiremock::MockServer::start().await;
480        wiremock::Mock::given(wiremock::matchers::method("GET"))
481            .and(wiremock::matchers::path("/api/v1/slo/x"))
482            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
483            .mount(&server)
484            .await;
485
486        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
487        let err = SloApi::new(&client).get("x").await.unwrap_err();
488        assert!(err.to_string().contains("Failed to parse"));
489    }
490}