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    fn url(&self, path: &str) -> String {
395        let mut base = self.config.server_url.clone();
396        if !base.ends_with('/') {
397            base.push('/');
398        }
399        format!("{}{}", base, path)
400    }
401
402    async fn execute_with_retry<F, Fut, T>(&self, mut operation: F) -> Result<T, ClientError>
403    where
404        F: FnMut() -> Fut,
405        Fut: std::future::Future<Output = Result<T, ClientError>>,
406    {
407        let mut attempts = 0;
408
409        loop {
410            match operation().await {
411                Ok(result) => return Ok(result),
412                Err(e) => {
413                    attempts += 1;
414                    let is_retryable = e.is_retryable();
415
416                    if !is_retryable || attempts > self.config.retry.max_retries {
417                        return Err(e);
418                    }
419
420                    debug!(error = %e, attempt = attempts, "Request failed, retrying");
421                    tokio::time::sleep(self.config.retry.delay_for_attempt(attempts)).await;
422                }
423            }
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use wiremock::matchers::{method, path};
432    use wiremock::{Mock, MockServer, ResponseTemplate};
433
434    fn test_config(url: &str) -> ClientConfig {
435        ClientConfig::new(url)
436    }
437
438    #[tokio::test]
439    async fn test_get_latest_baseline() {
440        let mock_server = MockServer::start().await;
441
442        Mock::given(method("GET"))
443            .and(path("/projects/my-project/baselines/my-bench/latest"))
444            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
445                "schema": "perfgate.baseline.v1",
446                "id": "bl_123",
447                "project": "my-project",
448                "benchmark": "my-bench",
449                "version": "v1.2.3",
450                "receipt": {
451                    "schema": "perfgate.run.v1",
452                    "tool": {"name": "test", "version": "0"},
453                    "run": {"id": "r1", "started_at": "2024-01-01T00:00:00Z", "ended_at": "2024-01-01T00:00:01Z", "host": {"os": "linux", "arch": "x86_64"}},
454                    "bench": {"name": "my-bench", "command": [], "repeat": 1, "warmup": 0},
455                    "samples": [],
456                    "stats": {"wall_ms": {"median": 100, "min": 100, "max": 100}}
457                },
458                "metadata": {},
459                "tags": [],
460                "created_at": "2024-01-01T00:00:00Z",
461                "updated_at": "2024-01-01T00:00:00Z",
462                "content_hash": "hash123",
463                "source": "upload",
464                "deleted": false
465            })))
466            .mount(&mock_server)
467            .await;
468
469        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
470        let result = client
471            .get_latest_baseline("my-project", "my-bench")
472            .await
473            .unwrap();
474
475        assert_eq!(result.id, "bl_123");
476        assert_eq!(result.version, "v1.2.3");
477    }
478
479    #[tokio::test]
480    async fn test_promote_baseline() {
481        let mock_server = MockServer::start().await;
482
483        Mock::given(method("POST"))
484            .and(path("/projects/my-project/baselines/my-bench/promote"))
485            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
486                "id": "bl_new",
487                "benchmark": "my-bench",
488                "version": "v2.0.0",
489                "promoted_from": "v1.0.0",
490                "promoted_at": "2024-01-01T00:00:00Z",
491                "created_at": "2024-01-01T00:00:00Z"
492            })))
493            .mount(&mock_server)
494            .await;
495
496        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
497        let request = PromoteBaselineRequest {
498            from_version: "v1.0.0".to_string(),
499            to_version: "v2.0.0".to_string(),
500            git_ref: None,
501            git_sha: None,
502            tags: vec![],
503            normalize: true,
504        };
505        let response = client
506            .promote_baseline("my-project", "my-bench", &request)
507            .await
508            .unwrap();
509
510        assert_eq!(response.version, "v2.0.0");
511        assert_eq!(response.promoted_from, "v1.0.0");
512    }
513}