1use anyhow::{Context, Result};
8use url::Url;
9
10use crate::datadog::client::DatadogClient;
11use crate::datadog::types::{Slo, SloGetResponse, SloListResponse};
12
13pub const HARD_CAP: usize = 10_000;
15
16pub const LIST_PAGE_SIZE: usize = 50;
19
20#[derive(Debug, Default, Clone)]
22pub struct SloListFilter {
23 pub tags: Option<String>,
25 pub query: Option<String>,
27 pub ids: Option<String>,
29 pub metrics: Option<String>,
31}
32
33#[derive(Debug)]
35pub struct SloApi<'a> {
36 client: &'a DatadogClient,
37}
38
39impl<'a> SloApi<'a> {
40 #[must_use]
42 pub fn new(client: &'a DatadogClient) -> Self {
43 Self { client }
44 }
45
46 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 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
92fn 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 #[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 #[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 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 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 #[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 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 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 #[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}