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 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}