1use crate::config::ClientConfig;
6use crate::error::ClientError;
7use crate::types::*;
8use reqwest::{Client, Response, StatusCode};
9use tracing::{debug, warn};
10use url::Url;
11
12#[derive(Debug)]
14pub struct BaselineClient {
15 config: ClientConfig,
16 http: Client,
17 base_url: Url,
18}
19
20impl BaselineClient {
21 pub fn new(config: ClientConfig) -> Result<Self, ClientError> {
23 config.validate().map_err(ClientError::ValidationError)?;
24
25 let http = Client::builder()
26 .timeout(config.timeout)
27 .user_agent(format!("perfgate-client/{}", env!("CARGO_PKG_VERSION")))
28 .build()
29 .map_err(ClientError::RequestError)?;
30
31 let mut base_url = Url::parse(&config.server_url)?;
32 if !base_url.path().ends_with('/') {
33 let mut path = base_url.path().to_string();
34 path.push('/');
35 base_url.set_path(&path);
36 }
37
38 Ok(Self {
39 config,
40 http,
41 base_url,
42 })
43 }
44
45 pub fn with_server_url(server_url: impl Into<String>) -> Result<Self, ClientError> {
47 Self::new(ClientConfig::new(server_url))
48 }
49
50 pub async fn upload_baseline(
56 &self,
57 project: &str,
58 request: &UploadBaselineRequest,
59 ) -> Result<UploadBaselineResponse, ClientError> {
60 let url = self.url(&format!("projects/{}/baselines", project));
61 debug!(url = %url, "Uploading baseline");
62
63 let response = self
64 .execute_with_retry(|| {
65 let mut builder = self.http.post(url.clone()).json(request);
66 if let Some(header) = self.config.auth.header_value() {
67 builder = builder.header("Authorization", header);
68 }
69 builder
70 })
71 .await?;
72
73 let status = response.status();
74 let body = response.text().await.map_err(ClientError::RequestError)?;
75
76 if status == StatusCode::CREATED {
77 serde_json::from_str(&body).map_err(ClientError::ParseError)
78 } else {
79 Err(ClientError::from_http(status.as_u16(), &body))
80 }
81 }
82
83 pub async fn get_latest_baseline(
89 &self,
90 project: &str,
91 benchmark: &str,
92 ) -> Result<BaselineRecord, ClientError> {
93 let url = self.url(&format!(
94 "projects/{}/baselines/{}/latest",
95 project, benchmark
96 ));
97 debug!(url = %url, "Getting latest baseline");
98
99 let response = self
100 .execute_with_retry(|| {
101 let mut builder = self.http.get(url.clone());
102 if let Some(header) = self.config.auth.header_value() {
103 builder = builder.header("Authorization", header);
104 }
105 builder
106 })
107 .await?;
108
109 let status = response.status();
110 let body = response.text().await.map_err(ClientError::RequestError)?;
111
112 if status == StatusCode::OK {
113 serde_json::from_str(&body).map_err(ClientError::ParseError)
114 } else {
115 Err(ClientError::from_http(status.as_u16(), &body))
116 }
117 }
118
119 pub async fn get_baseline_version(
126 &self,
127 project: &str,
128 benchmark: &str,
129 version: &str,
130 ) -> Result<BaselineRecord, ClientError> {
131 let url = self.url(&format!(
132 "projects/{}/baselines/{}/versions/{}",
133 project, benchmark, version
134 ));
135 debug!(url = %url, "Getting baseline version");
136
137 let response = self
138 .execute_with_retry(|| {
139 let mut builder = self.http.get(url.clone());
140 if let Some(header) = self.config.auth.header_value() {
141 builder = builder.header("Authorization", header);
142 }
143 builder
144 })
145 .await?;
146
147 let status = response.status();
148 let body = response.text().await.map_err(ClientError::RequestError)?;
149
150 if status == StatusCode::OK {
151 serde_json::from_str(&body).map_err(ClientError::ParseError)
152 } else {
153 Err(ClientError::from_http(status.as_u16(), &body))
154 }
155 }
156
157 pub async fn list_baselines(
163 &self,
164 project: &str,
165 query: &ListBaselinesQuery,
166 ) -> Result<ListBaselinesResponse, ClientError> {
167 let mut url = self.url(&format!("projects/{}/baselines", project));
168
169 {
171 let mut pairs = url.query_pairs_mut();
172 for (key, value) in query.to_query_params() {
173 pairs.append_pair(&key, &value);
174 }
175 }
176
177 debug!(url = %url, "Listing baselines");
178
179 let response = self
180 .execute_with_retry(|| {
181 let mut builder = self.http.get(url.clone());
182 if let Some(header) = self.config.auth.header_value() {
183 builder = builder.header("Authorization", header);
184 }
185 builder
186 })
187 .await?;
188
189 let status = response.status();
190 let body = response.text().await.map_err(ClientError::RequestError)?;
191
192 if status == StatusCode::OK {
193 serde_json::from_str(&body).map_err(ClientError::ParseError)
194 } else {
195 Err(ClientError::from_http(status.as_u16(), &body))
196 }
197 }
198
199 pub async fn delete_baseline(
206 &self,
207 project: &str,
208 benchmark: &str,
209 version: &str,
210 ) -> Result<(), ClientError> {
211 let url = self.url(&format!(
212 "projects/{}/baselines/{}/versions/{}",
213 project, benchmark, version
214 ));
215 debug!(url = %url, "Deleting baseline");
216
217 let response = self
218 .execute_with_retry(|| {
219 let mut builder = self.http.delete(url.clone());
220 if let Some(header) = self.config.auth.header_value() {
221 builder = builder.header("Authorization", header);
222 }
223 builder
224 })
225 .await?;
226
227 let status = response.status();
228 let body = response.text().await.map_err(ClientError::RequestError)?;
229
230 if status == StatusCode::OK {
231 Ok(())
232 } else {
233 Err(ClientError::from_http(status.as_u16(), &body))
234 }
235 }
236
237 pub async fn promote_baseline(
244 &self,
245 project: &str,
246 benchmark: &str,
247 request: &PromoteBaselineRequest,
248 ) -> Result<PromoteBaselineResponse, ClientError> {
249 let url = self.url(&format!(
250 "projects/{}/baselines/{}/promote",
251 project, benchmark
252 ));
253 debug!(url = %url, "Promoting baseline");
254
255 let response = self
256 .execute_with_retry(|| {
257 let mut builder = self.http.post(url.clone()).json(request);
258 if let Some(header) = self.config.auth.header_value() {
259 builder = builder.header("Authorization", header);
260 }
261 builder
262 })
263 .await?;
264
265 let status = response.status();
266 let body = response.text().await.map_err(ClientError::RequestError)?;
267
268 if status == StatusCode::CREATED {
269 serde_json::from_str(&body).map_err(ClientError::ParseError)
270 } else {
271 Err(ClientError::from_http(status.as_u16(), &body))
272 }
273 }
274
275 pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
277 let url = self.url("health");
278 debug!(url = %url, "Checking health");
279
280 let response = self
281 .execute_with_retry(|| self.http.get(url.clone()))
282 .await?;
283
284 let status = response.status();
285 let body = response.text().await.map_err(ClientError::RequestError)?;
286
287 if status == StatusCode::OK {
288 serde_json::from_str(&body).map_err(ClientError::ParseError)
289 } else {
290 Err(ClientError::from_http(status.as_u16(), &body))
291 }
292 }
293
294 pub async fn is_healthy(&self) -> bool {
296 self.health_check().await.is_ok()
297 }
298
299 fn url(&self, path: &str) -> Url {
301 self.base_url.join(path).expect("Invalid URL path")
302 }
303
304 async fn execute_with_retry<F>(&self, request_fn: F) -> Result<Response, ClientError>
306 where
307 F: Fn() -> reqwest::RequestBuilder,
308 {
309 let retry_config = &self.config.retry;
310 let mut last_error = None;
311
312 for attempt in 0..=retry_config.max_retries {
313 if attempt > 0 {
314 let delay = retry_config.delay_for_attempt(attempt - 1);
315 debug!(attempt, delay_ms = delay.as_millis(), "Retrying request");
316 tokio::time::sleep(delay).await;
317 }
318
319 let builder = request_fn();
320 match builder.send().await {
321 Ok(response) => {
322 let status = response.status();
323
324 if retry_config.retry_status_codes.contains(&status.as_u16())
326 && attempt < retry_config.max_retries
327 {
328 warn!(
329 attempt,
330 status = status.as_u16(),
331 "Request failed with retryable status"
332 );
333 last_error = Some(ClientError::from_http(
334 status.as_u16(),
335 &format!("HTTP {}", status),
336 ));
337 continue;
338 }
339
340 return Ok(response);
341 }
342 Err(e) => {
343 let is_connect_error = e.is_connect() || e.is_timeout() || e.is_request();
344
345 if is_connect_error {
346 if attempt < retry_config.max_retries {
347 warn!(attempt, error = %e, "Request failed, will retry");
348 }
349 last_error = Some(ClientError::ConnectionError(e.to_string()));
350 if attempt < retry_config.max_retries {
351 continue;
352 }
353 return Err(ClientError::ConnectionError(e.to_string()));
355 }
356
357 return Err(ClientError::RequestError(e));
358 }
359 }
360 }
361
362 Err(ClientError::RetryExhausted {
363 retries: retry_config.max_retries,
364 message: last_error
365 .map(|e| e.to_string())
366 .unwrap_or_else(|| "Unknown error".to_string()),
367 })
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::config::RetryConfig;
375 use wiremock::matchers::{method, path};
376 use wiremock::{Mock, MockServer, ResponseTemplate};
377
378 fn test_config(server_url: &str) -> ClientConfig {
379 ClientConfig::new(server_url)
380 .with_api_key("test-key")
381 .with_retry(RetryConfig {
382 max_retries: 0, ..Default::default()
384 })
385 }
386
387 #[tokio::test]
388 async fn test_health_check() {
389 let mock_server = MockServer::start().await;
390
391 Mock::given(method("GET"))
392 .and(path("/health"))
393 .respond_with(ResponseTemplate::new(200).set_body_json(HealthResponse {
394 status: "healthy".to_string(),
395 version: "2.0.0".to_string(),
396 storage: StorageHealth {
397 backend: "memory".to_string(),
398 status: "connected".to_string(),
399 },
400 }))
401 .mount(&mock_server)
402 .await;
403
404 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
405 let health = client.health_check().await.unwrap();
406
407 assert_eq!(health.status, "healthy");
408 assert_eq!(health.version, "2.0.0");
409 }
410
411 #[tokio::test]
412 async fn test_is_healthy() {
413 let mock_server = MockServer::start().await;
414
415 Mock::given(method("GET"))
416 .and(path("/health"))
417 .respond_with(ResponseTemplate::new(200).set_body_json(HealthResponse {
418 status: "healthy".to_string(),
419 version: "2.0.0".to_string(),
420 storage: StorageHealth {
421 backend: "memory".to_string(),
422 status: "connected".to_string(),
423 },
424 }))
425 .mount(&mock_server)
426 .await;
427
428 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
429 assert!(client.is_healthy().await);
430 }
431
432 #[tokio::test]
433 async fn test_get_latest_baseline_not_found() {
434 let mock_server = MockServer::start().await;
435
436 Mock::given(method("GET"))
437 .and(path("/projects/my-project/baselines/my-bench/latest"))
438 .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
439 "error": {
440 "code": "NOT_FOUND",
441 "message": "Baseline 'my-bench/latest' not found"
442 }
443 })))
444 .mount(&mock_server)
445 .await;
446
447 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
448 let result = client.get_latest_baseline("my-project", "my-bench").await;
449
450 assert!(matches!(result, Err(ClientError::NotFoundError(_))));
451 }
452
453 #[tokio::test]
454 async fn test_upload_baseline_success() {
455 let mock_server = MockServer::start().await;
456
457 Mock::given(method("POST"))
458 .and(path("/projects/my-project/baselines"))
459 .respond_with(
460 ResponseTemplate::new(201).set_body_json(UploadBaselineResponse {
461 id: "bl_123".to_string(),
462 benchmark: "my-bench".to_string(),
463 version: "v1.0.0".to_string(),
464 created_at: chrono::Utc::now(),
465 etag: "\"sha256:abc123\"".to_string(),
466 }),
467 )
468 .mount(&mock_server)
469 .await;
470
471 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
472
473 let request = UploadBaselineRequest {
475 benchmark: "my-bench".to_string(),
476 version: Some("v1.0.0".to_string()),
477 git_ref: None,
478 git_sha: None,
479 receipt: perfgate_types::RunReceipt {
480 schema: "perfgate.run.v1".to_string(),
481 tool: perfgate_types::ToolInfo {
482 name: "perfgate".to_string(),
483 version: "0.1.0".to_string(),
484 },
485 run: perfgate_types::RunMeta {
486 id: "test".to_string(),
487 started_at: "2026-01-01T00:00:00Z".to_string(),
488 ended_at: "2026-01-01T00:01:00Z".to_string(),
489 host: perfgate_types::HostInfo {
490 os: "linux".to_string(),
491 arch: "x86_64".to_string(),
492 cpu_count: Some(8),
493 memory_bytes: Some(16000000000),
494 hostname_hash: None,
495 },
496 },
497 bench: perfgate_types::BenchMeta {
498 name: "my-bench".to_string(),
499 cwd: None,
500 command: vec!["./bench.sh".to_string()],
501 repeat: 5,
502 warmup: 1,
503 work_units: None,
504 timeout_ms: None,
505 },
506 samples: vec![],
507 stats: perfgate_types::Stats {
508 wall_ms: perfgate_types::U64Summary {
509 median: 100,
510 min: 90,
511 max: 110,
512 },
513 cpu_ms: None,
514 page_faults: None,
515 ctx_switches: None,
516 max_rss_kb: None,
517 binary_bytes: None,
518 throughput_per_s: None,
519 },
520 },
521 metadata: Default::default(),
522 tags: vec![],
523 normalize: false,
524 };
525
526 let response = client
527 .upload_baseline("my-project", &request)
528 .await
529 .unwrap();
530 assert_eq!(response.id, "bl_123");
531 assert_eq!(response.benchmark, "my-bench");
532 }
533
534 #[tokio::test]
535 async fn test_connection_error() {
536 let client = BaselineClient::new(test_config("http://localhost:59999")).unwrap();
537
538 let result = client.health_check().await;
539 assert!(matches!(result, Err(ClientError::ConnectionError(_))));
540 }
541
542 #[tokio::test]
543 async fn test_get_baseline_version() {
544 let mock_server = MockServer::start().await;
545
546 Mock::given(method("GET"))
547 .and(path(
548 "/projects/my-project/baselines/my-bench/versions/v1.2.3",
549 ))
550 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
551 "schema": "perfgate.baseline.v1",
552 "id": "bl_123",
553 "project": "my-project",
554 "benchmark": "my-bench",
555 "version": "v1.2.3",
556 "receipt": {
557 "schema": "perfgate.run.v1",
558 "tool": { "name": "perfgate", "version": "0.1.0" },
559 "run": {
560 "id": "test",
561 "started_at": "2026-01-01T00:00:00Z",
562 "ended_at": "2026-01-01T00:01:00Z",
563 "host": { "os": "linux", "arch": "x86_64" }
564 },
565 "bench": { "name": "my-bench", "command": ["echo"], "repeat": 1, "warmup": 0 },
566 "samples": [],
567 "stats": { "wall_ms": { "median": 100, "min": 100, "max": 100 } }
568 },
569 "metadata": {},
570 "tags": [],
571 "source": "upload",
572 "content_hash": "abc",
573 "deleted": false,
574 "created_at": "2026-01-01T00:00:00Z",
575 "updated_at": "2026-01-01T00:00:00Z"
576 })))
577 .mount(&mock_server)
578 .await;
579
580 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
581 let result = client
582 .get_baseline_version("my-project", "my-bench", "v1.2.3")
583 .await
584 .unwrap();
585
586 assert_eq!(result.id, "bl_123");
587 assert_eq!(result.version, "v1.2.3");
588 }
589
590 #[tokio::test]
591 async fn test_delete_baseline() {
592 let mock_server = MockServer::start().await;
593
594 Mock::given(method("DELETE"))
595 .and(path(
596 "/projects/my-project/baselines/my-bench/versions/v1.2.3",
597 ))
598 .respond_with(ResponseTemplate::new(200))
599 .mount(&mock_server)
600 .await;
601
602 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
603 client
604 .delete_baseline("my-project", "my-bench", "v1.2.3")
605 .await
606 .unwrap();
607 }
608
609 #[tokio::test]
610 async fn test_promote_baseline() {
611 let mock_server = MockServer::start().await;
612
613 Mock::given(method("POST"))
614 .and(path("/projects/my-project/baselines/my-bench/promote"))
615 .respond_with(
616 ResponseTemplate::new(201).set_body_json(PromoteBaselineResponse {
617 id: "bl_new".to_string(),
618 benchmark: "my-bench".to_string(),
619 version: "v2.0.0".to_string(),
620 promoted_from: "v1.0.0".to_string(),
621 created_at: chrono::Utc::now(),
622 }),
623 )
624 .mount(&mock_server)
625 .await;
626
627 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
628 let request = PromoteBaselineRequest {
629 to_version: "v2.0.0".to_string(),
630 from_version: "v1.0.0".to_string(),
631 git_ref: None,
632 git_sha: None,
633 normalize: true,
634 };
635 let response = client
636 .promote_baseline("my-project", "my-bench", &request)
637 .await
638 .unwrap();
639
640 assert_eq!(response.version, "v2.0.0");
641 assert_eq!(response.promoted_from, "v1.0.0");
642 }
643
644 #[tokio::test]
645 async fn test_retry_on_503() {
646 let mock_server = MockServer::start().await;
647
648 struct RetryResponder {
650 count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
651 }
652
653 impl wiremock::Respond for RetryResponder {
654 fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate {
655 let current = self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
656 if current < 2 {
657 ResponseTemplate::new(503)
658 } else {
659 ResponseTemplate::new(200).set_body_json(HealthResponse {
660 status: "healthy".to_string(),
661 version: "2.0.0".to_string(),
662 storage: StorageHealth {
663 backend: "memory".to_string(),
664 status: "connected".to_string(),
665 },
666 })
667 }
668 }
669 }
670
671 let responder = RetryResponder {
672 count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
673 };
674
675 Mock::given(method("GET"))
676 .and(path("/health"))
677 .respond_with(responder)
678 .mount(&mock_server)
679 .await;
680
681 let config = ClientConfig::new(mock_server.uri()).with_retry(RetryConfig {
682 max_retries: 3,
683 retry_status_codes: vec![503],
684 base_delay: std::time::Duration::from_millis(1),
685 max_delay: std::time::Duration::from_millis(10),
686 });
687
688 let client = BaselineClient::new(config).unwrap();
689 let health = client.health_check().await.unwrap();
690 assert_eq!(health.status, "healthy");
691 }
692
693 #[tokio::test]
694 async fn test_auth_error() {
695 let mock_server = MockServer::start().await;
696
697 Mock::given(method("GET"))
698 .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
699 "error": {
700 "code": "UNAUTHORIZED",
701 "message": "Invalid API key"
702 }
703 })))
704 .mount(&mock_server)
705 .await;
706
707 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
708 let result = client.health_check().await;
709
710 assert!(matches!(result, Err(ClientError::AuthError(_))));
711 }
712}