1use chrono::{DateTime, Utc};
4use perfgate_types::{RunReceipt, VerdictCounts, VerdictStatus};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
11
12pub const PROJECT_SCHEMA_V1: &str = "perfgate.project.v1";
14
15pub const VERDICT_SCHEMA_V1: &str = "perfgate.verdict.v1";
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
20#[serde(rename_all = "snake_case")]
21pub enum BaselineSource {
22 #[default]
24 Upload,
25 Promote,
27 Migrate,
29 Rollback,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
35pub struct BaselineRecord {
36 pub schema: String,
38 pub id: String,
40 pub project: String,
42 pub benchmark: String,
44 pub version: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub git_ref: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub git_sha: Option<String>,
52 pub receipt: RunReceipt,
54 #[serde(default)]
56 pub metadata: BTreeMap<String, String>,
57 #[serde(default)]
59 pub tags: Vec<String>,
60 pub created_at: DateTime<Utc>,
62 pub updated_at: DateTime<Utc>,
64 pub content_hash: String,
66 pub source: BaselineSource,
68 #[serde(default)]
70 pub deleted: bool,
71}
72
73impl BaselineRecord {
74 pub fn etag(&self) -> String {
76 format!("\"sha256:{}\"", self.content_hash)
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
82pub struct VerdictRecord {
83 pub schema: String,
85 pub id: String,
87 pub project: String,
89 pub benchmark: String,
91 pub run_id: String,
93 pub status: VerdictStatus,
95 pub counts: VerdictCounts,
97 pub reasons: Vec<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub git_ref: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub git_sha: Option<String>,
105 pub created_at: DateTime<Utc>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct SubmitVerdictRequest {
112 pub benchmark: String,
113 pub run_id: String,
114 pub status: VerdictStatus,
115 pub counts: VerdictCounts,
116 pub reasons: Vec<String>,
117 pub git_ref: Option<String>,
118 pub git_sha: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct ListVerdictsQuery {
124 pub benchmark: Option<String>,
126 pub status: Option<VerdictStatus>,
128 pub since: Option<DateTime<Utc>>,
130 pub until: Option<DateTime<Utc>>,
132 #[serde(default = "default_limit")]
134 pub limit: u32,
135 #[serde(default)]
137 pub offset: u64,
138}
139
140impl Default for ListVerdictsQuery {
141 fn default() -> Self {
142 Self {
143 benchmark: None,
144 status: None,
145 since: None,
146 until: None,
147 limit: default_limit(),
148 offset: 0,
149 }
150 }
151}
152
153impl ListVerdictsQuery {
154 pub fn new() -> Self {
155 Self::default()
156 }
157 pub fn with_benchmark(mut self, b: impl Into<String>) -> Self {
158 self.benchmark = Some(b.into());
159 self
160 }
161 pub fn with_status(mut self, s: VerdictStatus) -> Self {
162 self.status = Some(s);
163 self
164 }
165 pub fn with_limit(mut self, l: u32) -> Self {
166 self.limit = l;
167 self
168 }
169 pub fn with_offset(mut self, o: u64) -> Self {
170 self.offset = o;
171 self
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177pub struct ListVerdictsResponse {
178 pub verdicts: Vec<VerdictRecord>,
179 pub pagination: PaginationInfo,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
184pub struct BaselineVersion {
185 pub version: String,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub git_ref: Option<String>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub git_sha: Option<String>,
193 pub created_at: DateTime<Utc>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub created_by: Option<String>,
198 pub is_current: bool,
200 pub source: BaselineSource,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
206pub struct RetentionPolicy {
207 pub max_versions: Option<u32>,
209 pub max_age_days: Option<u32>,
211 pub preserve_tags: Vec<String>,
213}
214
215impl Default for RetentionPolicy {
216 fn default() -> Self {
217 Self {
218 max_versions: Some(50),
219 max_age_days: Some(365),
220 preserve_tags: vec!["production".to_string(), "stable".to_string()],
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
227#[serde(rename_all = "snake_case")]
228pub enum VersioningStrategy {
229 #[default]
231 RunId,
232 Timestamp,
234 GitSha,
236 Manual,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
242pub struct Project {
243 pub schema: String,
245 pub id: String,
247 pub name: String,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub description: Option<String>,
252 pub created_at: DateTime<Utc>,
254 pub retention: RetentionPolicy,
256 pub versioning: VersioningStrategy,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
262pub struct ListBaselinesQuery {
263 pub benchmark: Option<String>,
265 pub benchmark_prefix: Option<String>,
267 pub git_ref: Option<String>,
269 pub git_sha: Option<String>,
271 pub tags: Option<String>,
273 pub since: Option<DateTime<Utc>>,
275 pub until: Option<DateTime<Utc>>,
277 #[serde(default)]
279 pub include_receipt: bool,
280 #[serde(default = "default_limit")]
282 pub limit: u32,
283 #[serde(default)]
285 pub offset: u64,
286}
287
288impl Default for ListBaselinesQuery {
289 fn default() -> Self {
290 Self {
291 benchmark: None,
292 benchmark_prefix: None,
293 git_ref: None,
294 git_sha: None,
295 tags: None,
296 since: None,
297 until: None,
298 include_receipt: false,
299 limit: default_limit(),
300 offset: 0,
301 }
302 }
303}
304
305fn default_limit() -> u32 {
306 50
307}
308
309impl ListBaselinesQuery {
310 pub fn new() -> Self {
311 Self::default()
312 }
313 pub fn with_benchmark(mut self, b: impl Into<String>) -> Self {
314 self.benchmark = Some(b.into());
315 self
316 }
317 pub fn with_benchmark_prefix(mut self, p: impl Into<String>) -> Self {
318 self.benchmark_prefix = Some(p.into());
319 self
320 }
321 pub fn with_offset(mut self, o: u64) -> Self {
322 self.offset = o;
323 self
324 }
325 pub fn with_limit(mut self, l: u32) -> Self {
326 self.limit = l;
327 self
328 }
329 pub fn with_receipts(mut self) -> Self {
330 self.include_receipt = true;
331 self
332 }
333 pub fn parsed_tags(&self) -> Vec<String> {
334 self.tags
335 .as_ref()
336 .map(|t| {
337 t.split(',')
338 .map(|s| s.trim().to_string())
339 .filter(|s| !s.is_empty())
340 .collect()
341 })
342 .unwrap_or_default()
343 }
344 pub fn to_query_params(&self) -> Vec<(String, String)> {
345 let mut params = Vec::new();
346 if let Some(b) = &self.benchmark {
347 params.push(("benchmark".to_string(), b.clone()));
348 }
349 if let Some(p) = &self.benchmark_prefix {
350 params.push(("benchmark_prefix".to_string(), p.clone()));
351 }
352 if let Some(r) = &self.git_ref {
353 params.push(("git_ref".to_string(), r.clone()));
354 }
355 if let Some(s) = &self.git_sha {
356 params.push(("git_sha".to_string(), s.clone()));
357 }
358 if let Some(t) = &self.tags {
359 params.push(("tags".to_string(), t.clone()));
360 }
361 if let Some(s) = &self.since {
362 params.push(("since".to_string(), s.to_rfc3339()));
363 }
364 if let Some(u) = &self.until {
365 params.push(("until".to_string(), u.to_rfc3339()));
366 }
367 params.push(("limit".to_string(), self.limit.to_string()));
368 params.push(("offset".to_string(), self.offset.to_string()));
369 if self.include_receipt {
370 params.push(("include_receipt".to_string(), "true".to_string()));
371 }
372 params
373 }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
378pub struct PaginationInfo {
379 pub total: u64,
381 pub offset: u64,
383 pub limit: u32,
385 pub has_more: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
391pub struct ListBaselinesResponse {
392 pub baselines: Vec<BaselineSummary>,
394 pub pagination: PaginationInfo,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
400pub struct BaselineSummary {
401 pub id: String,
402 pub benchmark: String,
403 pub version: String,
404 pub created_at: DateTime<Utc>,
405 pub git_ref: Option<String>,
406 pub git_sha: Option<String>,
407 pub tags: Vec<String>,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 pub receipt: Option<RunReceipt>,
410}
411
412impl From<BaselineRecord> for BaselineSummary {
413 fn from(record: BaselineRecord) -> Self {
414 Self {
415 id: record.id,
416 benchmark: record.benchmark,
417 version: record.version,
418 created_at: record.created_at,
419 git_ref: record.git_ref,
420 git_sha: record.git_sha,
421 tags: record.tags,
422 receipt: Some(record.receipt),
423 }
424 }
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
429pub struct UploadBaselineRequest {
430 pub benchmark: String,
431 pub version: Option<String>,
432 pub git_ref: Option<String>,
433 pub git_sha: Option<String>,
434 pub receipt: RunReceipt,
435 pub metadata: BTreeMap<String, String>,
436 pub tags: Vec<String>,
437 pub normalize: bool,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
442pub struct UploadBaselineResponse {
443 pub id: String,
444 pub benchmark: String,
445 pub version: String,
446 pub created_at: DateTime<Utc>,
447 pub etag: String,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
452pub struct PromoteBaselineRequest {
453 pub from_version: String,
454 pub to_version: String,
455 pub git_ref: Option<String>,
456 pub git_sha: Option<String>,
457 pub tags: Vec<String>,
458 #[serde(default)]
459 pub normalize: bool,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
464pub struct PromoteBaselineResponse {
465 pub id: String,
466 pub benchmark: String,
467 pub version: String,
468 pub promoted_from: String,
469 pub promoted_at: DateTime<Utc>,
470 pub created_at: DateTime<Utc>,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
475pub struct DeleteBaselineResponse {
476 pub deleted: bool,
477 pub id: String,
478 pub benchmark: String,
479 pub version: String,
480 pub deleted_at: DateTime<Utc>,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
485pub struct StorageHealth {
486 pub backend: String,
487 pub status: String,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
492pub struct HealthResponse {
493 pub status: String,
494 pub version: String,
495 pub storage: StorageHealth,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
500pub struct ApiError {
501 pub code: String,
502 pub message: String,
503 #[serde(skip_serializing_if = "Option::is_none")]
504 pub details: Option<serde_json::Value>,
505}
506
507impl ApiError {
508 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
509 Self {
510 code: code.into(),
511 message: message.into(),
512 details: None,
513 }
514 }
515 pub fn unauthorized(msg: &str) -> Self {
516 Self::new("unauthorized", msg)
517 }
518 pub fn forbidden(msg: &str) -> Self {
519 Self::new("forbidden", msg)
520 }
521 pub fn not_found(msg: &str) -> Self {
522 Self::new("not_found", msg)
523 }
524 pub fn bad_request(msg: &str) -> Self {
525 Self::new("bad_request", msg)
526 }
527 pub fn conflict(msg: &str) -> Self {
528 Self::new("conflict", msg)
529 }
530 pub fn internal_error(msg: &str) -> Self {
531 Self::new("internal_error", msg)
532 }
533 pub fn internal(msg: &str) -> Self {
534 Self::internal_error(msg)
535 }
536 pub fn validation(msg: &str) -> Self {
537 Self::new("invalid_input", msg)
538 }
539 pub fn already_exists(msg: &str) -> Self {
540 Self::new("conflict", msg)
541 }
542}
543
544#[cfg(feature = "server")]
545impl axum::response::IntoResponse for ApiError {
546 fn into_response(self) -> axum::response::Response {
547 let status = match self.code.as_str() {
548 "bad_request" | "invalid_input" => http::StatusCode::BAD_REQUEST,
549 "unauthorized" => http::StatusCode::UNAUTHORIZED,
550 "forbidden" => http::StatusCode::FORBIDDEN,
551 "not_found" => http::StatusCode::NOT_FOUND,
552 "conflict" => http::StatusCode::CONFLICT,
553 _ => http::StatusCode::INTERNAL_SERVER_ERROR,
554 };
555 (status, axum::Json(self)).into_response()
556 }
557}