Skip to main content

perfgate_client/
client.rs

1//! Client for the perfgate baseline service.
2
3use crate::config::ClientConfig;
4use crate::error::ClientError;
5use crate::types::*;
6use reqwest::header::{self, HeaderMap, HeaderValue};
7use tracing::debug;
8
9/// High-level client for the perfgate baseline service.
10#[derive(Clone, Debug)]
11pub struct BaselineClient {
12    config: ClientConfig,
13    inner: reqwest::Client,
14}
15
16impl BaselineClient {
17    /// Creates a new BaselineClient from the given configuration.
18    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    /// Uploads a new baseline to the server.
40    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    /// Gets the latest baseline for a benchmark.
76    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    /// Gets a specific version of a baseline.
117    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    /// Promotes a baseline to a new version.
159    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    /// Lists baselines for a project.
194    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    /// Deletes a baseline from the server.
245    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    /// Submits a benchmark verdict to the server.
281    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    /// Lists verdicts for a project.
317    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    /// Checks the health of the baseline service.
353    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    /// Returns true if the service is reachable and healthy.
387    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    // -----------------------------------------------------------------------
395    // Fleet-wide dependency regression detection
396    // -----------------------------------------------------------------------
397
398    /// Records dependency change events with their performance impact.
399    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    /// Lists fleet-wide dependency regression alerts.
434    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    /// Gets the impact of a specific dependency across all projects.
469    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}