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