1use crate::config::ClientConfig;
4use crate::error::ClientError;
5use crate::types::*;
6use reqwest::header::{self, HeaderMap, HeaderValue};
7use tracing::debug;
8
9#[derive(Clone, Debug)]
11pub struct BaselineClient {
12 config: ClientConfig,
13 inner: reqwest::Client,
14}
15
16impl BaselineClient {
17 pub fn new(config: ClientConfig) -> Result<Self, ClientError> {
19 config.validate().map_err(ClientError::ValidationError)?;
20
21 let mut headers = HeaderMap::new();
22
23 if let Some(auth_val) = config.auth.header_value() {
24 let mut auth_value = HeaderValue::from_str(&auth_val)
25 .map_err(|e| ClientError::ValidationError(format!("Invalid auth header: {}", e)))?;
26 auth_value.set_sensitive(true);
27 headers.insert(header::AUTHORIZATION, auth_value);
28 }
29
30 let inner = reqwest::Client::builder()
31 .default_headers(headers)
32 .timeout(config.timeout)
33 .build()
34 .map_err(|e| ClientError::ConnectionError(e.to_string()))?;
35
36 Ok(Self { config, inner })
37 }
38
39 pub async fn upload_baseline(
41 &self,
42 project: &str,
43 request: &UploadBaselineRequest,
44 ) -> Result<UploadBaselineResponse, ClientError> {
45 self.execute_with_retry(|| {
46 let url = self.url(&format!("projects/{}/baselines", project));
47 debug!(url = %url, benchmark = %request.benchmark, "Uploading baseline");
48
49 let client = self.inner.clone();
50 let request = request.clone();
51 async move {
52 let response = client
53 .post(url)
54 .json(&request)
55 .send()
56 .await
57 .map_err(ClientError::RequestError)?;
58
59 if !response.status().is_success() {
60 let status = response.status().as_u16();
61 let body = response.text().await.unwrap_or_default();
62 return Err(ClientError::from_http(status, &body));
63 }
64
65 let body = response
66 .json::<UploadBaselineResponse>()
67 .await
68 .map_err(ClientError::RequestError)?;
69 Ok(body)
70 }
71 })
72 .await
73 }
74
75 pub async fn get_latest_baseline(
77 &self,
78 project: &str,
79 benchmark: &str,
80 ) -> Result<BaselineRecord, ClientError> {
81 let url = self.url(&format!(
82 "projects/{}/baselines/{}/latest",
83 project, benchmark
84 ));
85 debug!(url = %url, "Getting latest baseline");
86
87 let response = self
88 .execute_with_retry(|| {
89 let client = self.inner.clone();
90 let url = url.clone();
91 async move {
92 let resp = client
93 .get(url)
94 .send()
95 .await
96 .map_err(ClientError::RequestError)?;
97
98 if !resp.status().is_success() {
99 let status = resp.status().as_u16();
100 let body = resp.text().await.unwrap_or_default();
101 return Err(ClientError::from_http(status, &body));
102 }
103
104 let body = resp
105 .json::<BaselineRecord>()
106 .await
107 .map_err(ClientError::RequestError)?;
108 Ok(body)
109 }
110 })
111 .await?;
112
113 Ok(response)
114 }
115
116 pub async fn get_baseline_version(
118 &self,
119 project: &str,
120 benchmark: &str,
121 version: &str,
122 ) -> Result<BaselineRecord, ClientError> {
123 let url = self.url(&format!(
124 "projects/{}/baselines/{}/versions/{}",
125 project, benchmark, version
126 ));
127 debug!(url = %url, version = %version, "Getting baseline version");
128
129 let response = self
130 .execute_with_retry(|| {
131 let client = self.inner.clone();
132 let url = url.clone();
133 async move {
134 let resp = client
135 .get(url)
136 .send()
137 .await
138 .map_err(ClientError::RequestError)?;
139
140 if !resp.status().is_success() {
141 let status = resp.status().as_u16();
142 let body = resp.text().await.unwrap_or_default();
143 return Err(ClientError::from_http(status, &body));
144 }
145
146 let body = resp
147 .json::<BaselineRecord>()
148 .await
149 .map_err(ClientError::RequestError)?;
150 Ok(body)
151 }
152 })
153 .await?;
154
155 Ok(response)
156 }
157
158 pub async fn promote_baseline(
160 &self,
161 project: &str,
162 benchmark: &str,
163 request: &PromoteBaselineRequest,
164 ) -> Result<PromoteBaselineResponse, ClientError> {
165 self.execute_with_retry(|| {
166 let url = self.url(&format!("projects/{}/baselines/{}/promote", project, benchmark));
167 debug!(url = %url, from = %request.from_version, to = %request.to_version, "Promoting baseline");
168
169 let client = self.inner.clone();
170 let request = request.clone();
171 async move {
172 let response = client
173 .post(url)
174 .json(&request)
175 .send()
176 .await
177 .map_err(ClientError::RequestError)?;
178
179 if !response.status().is_success() {
180 let status = response.status().as_u16();
181 let body = response.text().await.unwrap_or_default();
182 return Err(ClientError::from_http(status, &body));
183 }
184
185 let body = response.json::<PromoteBaselineResponse>().await
186 .map_err(ClientError::RequestError)?;
187 Ok(body)
188 }
189 })
190 .await
191 }
192
193 pub async fn list_baselines(
195 &self,
196 project: &str,
197 query: &ListBaselinesQuery,
198 ) -> Result<ListBaselinesResponse, ClientError> {
199 let mut url = self.url(&format!("projects/{}/baselines", project));
200
201 let params = query.to_query_params();
202 if !params.is_empty() {
203 let mut url_obj = url::Url::parse(&url).map_err(ClientError::UrlError)?;
204 {
205 let mut query_pairs = url_obj.query_pairs_mut();
206 for (k, v) in params {
207 query_pairs.append_pair(&k, &v);
208 }
209 }
210 url = url_obj.to_string();
211 }
212
213 debug!(url = %url, "Listing baselines");
214
215 let response = self
216 .execute_with_retry(|| {
217 let client = self.inner.clone();
218 let url = url.clone();
219 async move {
220 let resp = client
221 .get(url)
222 .send()
223 .await
224 .map_err(ClientError::RequestError)?;
225
226 if !resp.status().is_success() {
227 let status = resp.status().as_u16();
228 let body = resp.text().await.unwrap_or_default();
229 return Err(ClientError::from_http(status, &body));
230 }
231
232 let body = resp
233 .json::<ListBaselinesResponse>()
234 .await
235 .map_err(ClientError::RequestError)?;
236 Ok(body)
237 }
238 })
239 .await?;
240
241 Ok(response)
242 }
243
244 pub async fn delete_baseline(
246 &self,
247 project: &str,
248 benchmark: &str,
249 version: &str,
250 ) -> Result<(), ClientError> {
251 let url = self.url(&format!(
252 "projects/{}/baselines/{}/versions/{}",
253 project, benchmark, version
254 ));
255 debug!(url = %url, version = %version, "Deleting baseline version");
256
257 self.execute_with_retry(|| {
258 let client = self.inner.clone();
259 let url = url.clone();
260 async move {
261 let resp = client
262 .delete(url)
263 .send()
264 .await
265 .map_err(ClientError::RequestError)?;
266
267 if !resp.status().is_success() {
268 let status = resp.status().as_u16();
269 let body = resp.text().await.unwrap_or_default();
270 return Err(ClientError::from_http(status, &body));
271 }
272 Ok(())
273 }
274 })
275 .await?;
276
277 Ok(())
278 }
279
280 pub async fn submit_verdict(
282 &self,
283 project: &str,
284 request: &SubmitVerdictRequest,
285 ) -> Result<VerdictRecord, ClientError> {
286 self.execute_with_retry(|| {
287 let url = self.url(&format!("projects/{}/verdicts", project));
288 debug!(url = %url, benchmark = %request.benchmark, "Submitting verdict");
289
290 let client = self.inner.clone();
291 let request = request.clone();
292 async move {
293 let response = client
294 .post(url)
295 .json(&request)
296 .send()
297 .await
298 .map_err(ClientError::RequestError)?;
299
300 if !response.status().is_success() {
301 let status = response.status().as_u16();
302 let body = response.text().await.unwrap_or_default();
303 return Err(ClientError::from_http(status, &body));
304 }
305
306 let body = response
307 .json::<VerdictRecord>()
308 .await
309 .map_err(ClientError::RequestError)?;
310 Ok(body)
311 }
312 })
313 .await
314 }
315
316 pub async fn list_verdicts(
318 &self,
319 project: &str,
320 query: &ListVerdictsQuery,
321 ) -> Result<ListVerdictsResponse, ClientError> {
322 self.execute_with_retry(|| {
323 let url = self.url(&format!("projects/{}/verdicts", project));
324 debug!(url = %url, "Listing verdicts");
325
326 let client = self.inner.clone();
327 let query = query.clone();
328 async move {
329 let response = client
330 .get(url)
331 .query(&query)
332 .send()
333 .await
334 .map_err(ClientError::RequestError)?;
335
336 if !response.status().is_success() {
337 let status = response.status().as_u16();
338 let body = response.text().await.unwrap_or_default();
339 return Err(ClientError::from_http(status, &body));
340 }
341
342 let body = response
343 .json::<ListVerdictsResponse>()
344 .await
345 .map_err(ClientError::RequestError)?;
346 Ok(body)
347 }
348 })
349 .await
350 }
351
352 pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
354 let url = self.url("health");
355 debug!(url = %url, "Checking health");
356
357 let response = self
358 .execute_with_retry(|| {
359 let client = self.inner.clone();
360 let url = url.clone();
361 async move {
362 let resp = client
363 .get(url)
364 .send()
365 .await
366 .map_err(ClientError::RequestError)?;
367
368 if !resp.status().is_success() {
369 let status = resp.status().as_u16();
370 let body = resp.text().await.unwrap_or_default();
371 return Err(ClientError::from_http(status, &body));
372 }
373
374 let body = resp
375 .json::<HealthResponse>()
376 .await
377 .map_err(ClientError::RequestError)?;
378 Ok(body)
379 }
380 })
381 .await?;
382
383 Ok(response)
384 }
385
386 pub async fn is_healthy(&self) -> bool {
388 match self.health_check().await {
389 Ok(h) => h.status == "healthy",
390 Err(_) => false,
391 }
392 }
393
394 pub async fn record_dependency_event(
400 &self,
401 request: &RecordDependencyEventRequest,
402 ) -> Result<RecordDependencyEventResponse, ClientError> {
403 self.execute_with_retry(|| {
404 let url = self.url("fleet/dependency-event");
405 debug!(url = %url, project = %request.project, "Recording dependency event");
406
407 let client = self.inner.clone();
408 let request = request.clone();
409 async move {
410 let response = client
411 .post(url)
412 .json(&request)
413 .send()
414 .await
415 .map_err(ClientError::RequestError)?;
416
417 if !response.status().is_success() {
418 let status = response.status().as_u16();
419 let body = response.text().await.unwrap_or_default();
420 return Err(ClientError::from_http(status, &body));
421 }
422
423 let body = response
424 .json::<RecordDependencyEventResponse>()
425 .await
426 .map_err(ClientError::RequestError)?;
427 Ok(body)
428 }
429 })
430 .await
431 }
432
433 pub async fn list_fleet_alerts(
435 &self,
436 query: &ListFleetAlertsQuery,
437 ) -> Result<ListFleetAlertsResponse, ClientError> {
438 self.execute_with_retry(|| {
439 let url = self.url("fleet/alerts");
440 debug!(url = %url, "Listing fleet alerts");
441
442 let client = self.inner.clone();
443 let query = query.clone();
444 async move {
445 let response = client
446 .get(url)
447 .query(&query)
448 .send()
449 .await
450 .map_err(ClientError::RequestError)?;
451
452 if !response.status().is_success() {
453 let status = response.status().as_u16();
454 let body = response.text().await.unwrap_or_default();
455 return Err(ClientError::from_http(status, &body));
456 }
457
458 let body = response
459 .json::<ListFleetAlertsResponse>()
460 .await
461 .map_err(ClientError::RequestError)?;
462 Ok(body)
463 }
464 })
465 .await
466 }
467
468 pub async fn dependency_impact(
470 &self,
471 dep_name: &str,
472 query: &DependencyImpactQuery,
473 ) -> Result<DependencyImpactResponse, ClientError> {
474 self.execute_with_retry(|| {
475 let url = self.url(&format!("fleet/dependency/{}/impact", dep_name));
476 debug!(url = %url, dep = %dep_name, "Getting dependency impact");
477
478 let client = self.inner.clone();
479 let query = query.clone();
480 async move {
481 let response = client
482 .get(url)
483 .query(&query)
484 .send()
485 .await
486 .map_err(ClientError::RequestError)?;
487
488 if !response.status().is_success() {
489 let status = response.status().as_u16();
490 let body = response.text().await.unwrap_or_default();
491 return Err(ClientError::from_http(status, &body));
492 }
493
494 let body = response
495 .json::<DependencyImpactResponse>()
496 .await
497 .map_err(ClientError::RequestError)?;
498 Ok(body)
499 }
500 })
501 .await
502 }
503
504 fn url(&self, path: &str) -> String {
505 let mut base = self.config.server_url.clone();
506 if !base.ends_with('/') {
507 base.push('/');
508 }
509 format!("{}{}", base, path)
510 }
511
512 async fn execute_with_retry<F, Fut, T>(&self, mut operation: F) -> Result<T, ClientError>
513 where
514 F: FnMut() -> Fut,
515 Fut: std::future::Future<Output = Result<T, ClientError>>,
516 {
517 let mut attempts = 0;
518
519 loop {
520 match operation().await {
521 Ok(result) => return Ok(result),
522 Err(e) => {
523 attempts += 1;
524 let is_retryable = e.is_retryable();
525
526 if !is_retryable || attempts > self.config.retry.max_retries {
527 return Err(e);
528 }
529
530 debug!(error = %e, attempt = attempts, "Request failed, retrying");
531 tokio::time::sleep(self.config.retry.delay_for_attempt(attempts)).await;
532 }
533 }
534 }
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use wiremock::matchers::{method, path};
542 use wiremock::{Mock, MockServer, ResponseTemplate};
543
544 fn test_config(url: &str) -> ClientConfig {
545 ClientConfig::new(url)
546 }
547
548 #[tokio::test]
549 async fn test_get_latest_baseline() {
550 let mock_server = MockServer::start().await;
551
552 Mock::given(method("GET"))
553 .and(path("/projects/my-project/baselines/my-bench/latest"))
554 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
555 "schema": "perfgate.baseline.v1",
556 "id": "bl_123",
557 "project": "my-project",
558 "benchmark": "my-bench",
559 "version": "v1.2.3",
560 "receipt": {
561 "schema": "perfgate.run.v1",
562 "tool": {"name": "test", "version": "0"},
563 "run": {"id": "r1", "started_at": "2024-01-01T00:00:00Z", "ended_at": "2024-01-01T00:00:01Z", "host": {"os": "linux", "arch": "x86_64"}},
564 "bench": {"name": "my-bench", "command": [], "repeat": 1, "warmup": 0},
565 "samples": [],
566 "stats": {"wall_ms": {"median": 100, "min": 100, "max": 100}}
567 },
568 "metadata": {},
569 "tags": [],
570 "created_at": "2024-01-01T00:00:00Z",
571 "updated_at": "2024-01-01T00:00:00Z",
572 "content_hash": "hash123",
573 "source": "upload",
574 "deleted": false
575 })))
576 .mount(&mock_server)
577 .await;
578
579 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
580 let result = client
581 .get_latest_baseline("my-project", "my-bench")
582 .await
583 .unwrap();
584
585 assert_eq!(result.id, "bl_123");
586 assert_eq!(result.version, "v1.2.3");
587 }
588
589 #[tokio::test]
590 async fn test_promote_baseline() {
591 let mock_server = MockServer::start().await;
592
593 Mock::given(method("POST"))
594 .and(path("/projects/my-project/baselines/my-bench/promote"))
595 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
596 "id": "bl_new",
597 "benchmark": "my-bench",
598 "version": "v2.0.0",
599 "promoted_from": "v1.0.0",
600 "promoted_at": "2024-01-01T00:00:00Z",
601 "created_at": "2024-01-01T00:00:00Z"
602 })))
603 .mount(&mock_server)
604 .await;
605
606 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
607 let request = PromoteBaselineRequest {
608 from_version: "v1.0.0".to_string(),
609 to_version: "v2.0.0".to_string(),
610 git_ref: None,
611 git_sha: None,
612 tags: vec![],
613 normalize: true,
614 };
615 let response = client
616 .promote_baseline("my-project", "my-bench", &request)
617 .await
618 .unwrap();
619
620 assert_eq!(response.version, "v2.0.0");
621 assert_eq!(response.promoted_from, "v1.0.0");
622 }
623}