1use anyhow::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 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 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
84fn 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 #[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 #[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 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 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 #[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 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 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 #[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}