use crate::config::ClientConfig;
use crate::error::ClientError;
use crate::types::*;
use reqwest::header::{self, HeaderMap, HeaderValue};
use tracing::debug;
#[derive(Clone, Debug)]
pub struct BaselineClient {
config: ClientConfig,
inner: reqwest::Client,
}
impl BaselineClient {
pub fn new(config: ClientConfig) -> Result<Self, ClientError> {
config.validate().map_err(ClientError::ValidationError)?;
let mut headers = HeaderMap::new();
if let Some(auth_val) = config.auth.header_value() {
let mut auth_value = HeaderValue::from_str(&auth_val)
.map_err(|e| ClientError::ValidationError(format!("Invalid auth header: {}", e)))?;
auth_value.set_sensitive(true);
headers.insert(header::AUTHORIZATION, auth_value);
}
let inner = reqwest::Client::builder()
.default_headers(headers)
.timeout(config.timeout)
.build()
.map_err(|e| ClientError::ConnectionError(e.to_string()))?;
Ok(Self { config, inner })
}
pub async fn upload_baseline(
&self,
project: &str,
request: &UploadBaselineRequest,
) -> Result<UploadBaselineResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/baselines", project));
debug!(url = %url, benchmark = %request.benchmark, "Uploading baseline");
let client = self.inner.clone();
let request = request.clone();
async move {
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<UploadBaselineResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn get_latest_baseline(
&self,
project: &str,
benchmark: &str,
) -> Result<BaselineRecord, ClientError> {
let url = self.url(&format!(
"projects/{}/baselines/{}/latest",
project, benchmark
));
debug!(url = %url, "Getting latest baseline");
let response = self
.execute_with_retry(|| {
let client = self.inner.clone();
let url = url.clone();
async move {
let resp = client
.get(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = resp
.json::<BaselineRecord>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await?;
Ok(response)
}
pub async fn get_baseline_version(
&self,
project: &str,
benchmark: &str,
version: &str,
) -> Result<BaselineRecord, ClientError> {
let url = self.url(&format!(
"projects/{}/baselines/{}/versions/{}",
project, benchmark, version
));
debug!(url = %url, version = %version, "Getting baseline version");
let response = self
.execute_with_retry(|| {
let client = self.inner.clone();
let url = url.clone();
async move {
let resp = client
.get(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = resp
.json::<BaselineRecord>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await?;
Ok(response)
}
pub async fn promote_baseline(
&self,
project: &str,
benchmark: &str,
request: &PromoteBaselineRequest,
) -> Result<PromoteBaselineResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/baselines/{}/promote", project, benchmark));
debug!(url = %url, from = %request.from_version, to = %request.to_version, "Promoting baseline");
let client = self.inner.clone();
let request = request.clone();
async move {
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response.json::<PromoteBaselineResponse>().await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn list_baselines(
&self,
project: &str,
query: &ListBaselinesQuery,
) -> Result<ListBaselinesResponse, ClientError> {
let mut url = self.url(&format!("projects/{}/baselines", project));
let params = query.to_query_params();
if !params.is_empty() {
let mut url_obj = url::Url::parse(&url).map_err(ClientError::UrlError)?;
{
let mut query_pairs = url_obj.query_pairs_mut();
for (k, v) in params {
query_pairs.append_pair(&k, &v);
}
}
url = url_obj.to_string();
}
debug!(url = %url, "Listing baselines");
let response = self
.execute_with_retry(|| {
let client = self.inner.clone();
let url = url.clone();
async move {
let resp = client
.get(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = resp
.json::<ListBaselinesResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await?;
Ok(response)
}
pub async fn delete_baseline(
&self,
project: &str,
benchmark: &str,
version: &str,
) -> Result<(), ClientError> {
let url = self.url(&format!(
"projects/{}/baselines/{}/versions/{}",
project, benchmark, version
));
debug!(url = %url, version = %version, "Deleting baseline version");
self.execute_with_retry(|| {
let client = self.inner.clone();
let url = url.clone();
async move {
let resp = client
.delete(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
Ok(())
}
})
.await?;
Ok(())
}
pub async fn submit_verdict(
&self,
project: &str,
request: &SubmitVerdictRequest,
) -> Result<VerdictRecord, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/verdicts", project));
debug!(url = %url, benchmark = %request.benchmark, "Submitting verdict");
let client = self.inner.clone();
let request = request.clone();
async move {
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<VerdictRecord>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn list_verdicts(
&self,
project: &str,
query: &ListVerdictsQuery,
) -> Result<ListVerdictsResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/verdicts", project));
debug!(url = %url, "Listing verdicts");
let client = self.inner.clone();
let query = query.clone();
async move {
let response = client
.get(url)
.query(&query)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<ListVerdictsResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn upload_decision(
&self,
project: &str,
request: &UploadDecisionRequest,
) -> Result<DecisionRecord, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/decisions", project));
debug!(url = %url, "Uploading performance decision");
let client = self.inner.clone();
let request = request.clone();
async move {
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<DecisionRecord>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn list_decisions(
&self,
project: &str,
query: &ListDecisionsQuery,
) -> Result<ListDecisionsResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/decisions", project));
debug!(url = %url, "Listing performance decisions");
let client = self.inner.clone();
let query = query.clone();
async move {
let response = client
.get(url)
.query(&query)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<ListDecisionsResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn latest_decision(&self, project: &str) -> Result<DecisionRecord, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/decisions/latest", project));
debug!(url = %url, "Getting latest performance decision");
let client = self.inner.clone();
async move {
let response = client
.get(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<DecisionRecord>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn prune_decisions(
&self,
project: &str,
request: &PruneDecisionsRequest,
) -> Result<PruneDecisionsResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("projects/{}/decisions/prune", project));
debug!(url = %url, "Pruning performance decisions");
let client = self.inner.clone();
let request = request.clone();
async move {
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<PruneDecisionsResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn list_audit_events(
&self,
query: &ListAuditEventsQuery,
) -> Result<ListAuditEventsResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url("audit");
debug!(url = %url, "Listing audit events");
let client = self.inner.clone();
let query = query.clone();
async move {
let response = client
.get(url)
.query(&query)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<ListAuditEventsResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
let url = self.url("health");
debug!(url = %url, "Checking health");
let response = self
.execute_with_retry(|| {
let client = self.inner.clone();
let url = url.clone();
async move {
let resp = client
.get(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = resp
.json::<HealthResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await?;
Ok(response)
}
pub async fn is_healthy(&self) -> bool {
match self.health_check().await {
Ok(h) => h.status == "healthy",
Err(_) => false,
}
}
pub async fn create_key(
&self,
request: &CreateKeyRequest,
) -> Result<CreateKeyResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url("keys");
debug!(url = %url, role = %request.role, project = %request.project, "Creating API key");
let client = self.inner.clone();
let request = request.clone();
async move {
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<CreateKeyResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn list_keys(&self) -> Result<ListKeysResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url("keys");
debug!(url = %url, "Listing API keys");
let client = self.inner.clone();
async move {
let response = client
.get(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<ListKeysResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn revoke_key(&self, id: &str) -> Result<RevokeKeyResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("keys/{}", id));
debug!(url = %url, key_id = %id, "Revoking API key");
let client = self.inner.clone();
async move {
let response = client
.delete(url)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<RevokeKeyResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn record_dependency_event(
&self,
request: &RecordDependencyEventRequest,
) -> Result<RecordDependencyEventResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url("fleet/dependency-event");
debug!(url = %url, project = %request.project, "Recording dependency event");
let client = self.inner.clone();
let request = request.clone();
async move {
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<RecordDependencyEventResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn list_fleet_alerts(
&self,
query: &ListFleetAlertsQuery,
) -> Result<ListFleetAlertsResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url("fleet/alerts");
debug!(url = %url, "Listing fleet alerts");
let client = self.inner.clone();
let query = query.clone();
async move {
let response = client
.get(url)
.query(&query)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<ListFleetAlertsResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
pub async fn dependency_impact(
&self,
dep_name: &str,
query: &DependencyImpactQuery,
) -> Result<DependencyImpactResponse, ClientError> {
self.execute_with_retry(|| {
let url = self.url(&format!("fleet/dependency/{}/impact", dep_name));
debug!(url = %url, dep = %dep_name, "Getting dependency impact");
let client = self.inner.clone();
let query = query.clone();
async move {
let response = client
.get(url)
.query(&query)
.send()
.await
.map_err(ClientError::RequestError)?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(ClientError::from_http(status, &body));
}
let body = response
.json::<DependencyImpactResponse>()
.await
.map_err(ClientError::RequestError)?;
Ok(body)
}
})
.await
}
fn url(&self, path: &str) -> String {
let mut base = self.config.server_url.clone();
if !base.ends_with('/') {
base.push('/');
}
format!("{}{}", base, path)
}
async fn execute_with_retry<F, Fut, T>(&self, mut operation: F) -> Result<T, ClientError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, ClientError>>,
{
let mut attempts = 0;
loop {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
attempts += 1;
let is_retryable = e.is_retryable();
if !is_retryable || attempts > self.config.retry.max_retries {
return Err(e);
}
debug!(error = %e, attempt = attempts, "Request failed, retrying");
tokio::time::sleep(self.config.retry.delay_for_attempt(attempts)).await;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_config(url: &str) -> ClientConfig {
ClientConfig::new(url)
}
#[tokio::test]
async fn test_get_latest_baseline() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/my-project/baselines/my-bench/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"schema": "perfgate.baseline.v1",
"id": "bl_123",
"project": "my-project",
"benchmark": "my-bench",
"version": "v1.2.3",
"receipt": {
"schema": "perfgate.run.v1",
"tool": {"name": "test", "version": "0"},
"run": {"id": "r1", "started_at": "2024-01-01T00:00:00Z", "ended_at": "2024-01-01T00:00:01Z", "host": {"os": "linux", "arch": "x86_64"}},
"bench": {"name": "my-bench", "command": [], "repeat": 1, "warmup": 0},
"samples": [],
"stats": {"wall_ms": {"median": 100, "min": 100, "max": 100}}
},
"metadata": {},
"tags": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"content_hash": "hash123",
"source": "upload",
"deleted": false
})))
.mount(&mock_server)
.await;
let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
let result = client
.get_latest_baseline("my-project", "my-bench")
.await
.unwrap();
assert_eq!(result.id, "bl_123");
assert_eq!(result.version, "v1.2.3");
}
#[tokio::test]
async fn test_promote_baseline() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/projects/my-project/baselines/my-bench/promote"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "bl_new",
"benchmark": "my-bench",
"version": "v2.0.0",
"promoted_from": "v1.0.0",
"promoted_at": "2024-01-01T00:00:00Z",
"created_at": "2024-01-01T00:00:00Z"
})))
.mount(&mock_server)
.await;
let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
let request = PromoteBaselineRequest {
from_version: "v1.0.0".to_string(),
to_version: "v2.0.0".to_string(),
git_ref: None,
git_sha: None,
tags: vec![],
normalize: true,
};
let response = client
.promote_baseline("my-project", "my-bench", &request)
.await
.unwrap();
assert_eq!(response.version, "v2.0.0");
assert_eq!(response.promoted_from, "v1.0.0");
}
#[tokio::test]
async fn test_list_audit_events() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/audit"))
.and(query_param("project", "my-project"))
.and(query_param("action", "create"))
.and(query_param("resource_type", "baseline"))
.and(query_param("limit", "25"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"events": [{
"id": "audit_123",
"timestamp": "2026-01-01T00:00:00Z",
"actor": "key-admin",
"action": "create",
"resource_type": "baseline",
"resource_id": "bl_123",
"project": "my-project",
"metadata": {"benchmark": "parser"}
}],
"pagination": {
"limit": 25,
"offset": 0,
"total": 1,
"has_more": false
}
})))
.mount(&mock_server)
.await;
let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
let response = client
.list_audit_events(&ListAuditEventsQuery {
project: Some("my-project".to_string()),
action: Some("create".to_string()),
resource_type: Some("baseline".to_string()),
limit: 25,
..ListAuditEventsQuery::default()
})
.await
.unwrap();
assert_eq!(response.events.len(), 1);
assert_eq!(response.events[0].id, "audit_123");
assert_eq!(response.events[0].project, "my-project");
}
#[tokio::test]
async fn test_prune_decisions() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/projects/my-project/decisions/prune"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"project": "my-project",
"older_than": "2026-01-01T00:00:00Z",
"dry_run": true,
"matched": 1,
"deleted": 0,
"decision_ids": ["decision-1"]
})))
.mount(&mock_server)
.await;
let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
let response = client
.prune_decisions(
"my-project",
&PruneDecisionsRequest {
older_than: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
dry_run: true,
},
)
.await
.unwrap();
assert_eq!(response.project, "my-project");
assert!(response.dry_run);
assert_eq!(response.matched, 1);
assert_eq!(response.deleted, 0);
assert_eq!(response.decision_ids, vec!["decision-1".to_string()]);
}
}