Skip to main content

perfgate_api/
lib.rs

1//! Common API types and models for perfgate baseline service.
2
3use chrono::{DateTime, Utc};
4use perfgate_types::{RunReceipt, VerdictCounts, VerdictStatus};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// Schema identifier for baseline records.
10pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
11
12/// Schema identifier for project records.
13pub const PROJECT_SCHEMA_V1: &str = "perfgate.project.v1";
14
15/// Schema identifier for verdict records.
16pub const VERDICT_SCHEMA_V1: &str = "perfgate.verdict.v1";
17
18/// Source of baseline creation.
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
20#[serde(rename_all = "snake_case")]
21pub enum BaselineSource {
22    /// Uploaded directly via API
23    #[default]
24    Upload,
25    /// Created via promote operation
26    Promote,
27    /// Migrated from external storage
28    Migrate,
29    /// Created via rollback operation
30    Rollback,
31}
32
33/// The primary storage model for baselines.
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
35pub struct BaselineRecord {
36    /// Schema identifier (perfgate.baseline.v1)
37    pub schema: String,
38    /// Unique baseline identifier (ULID format)
39    pub id: String,
40    /// Project/namespace identifier
41    pub project: String,
42    /// Benchmark name
43    pub benchmark: String,
44    /// Semantic version
45    pub version: String,
46    /// Git reference
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub git_ref: Option<String>,
49    /// Git commit SHA
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub git_sha: Option<String>,
52    /// Full run receipt
53    pub receipt: RunReceipt,
54    /// User-provided metadata
55    #[serde(default)]
56    pub metadata: BTreeMap<String, String>,
57    /// Tags for filtering
58    #[serde(default)]
59    pub tags: Vec<String>,
60    /// Creation timestamp (RFC 3339)
61    pub created_at: DateTime<Utc>,
62    /// Last modification timestamp
63    pub updated_at: DateTime<Utc>,
64    /// Content hash for ETag
65    pub content_hash: String,
66    /// Creation source
67    pub source: BaselineSource,
68    /// Soft delete flag
69    #[serde(default)]
70    pub deleted: bool,
71}
72
73impl BaselineRecord {
74    /// Returns the ETag value for this baseline.
75    pub fn etag(&self) -> String {
76        format!("\"sha256:{}\"", self.content_hash)
77    }
78}
79
80/// A record of a benchmark execution verdict.
81#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
82pub struct VerdictRecord {
83    /// Schema identifier (perfgate.verdict.v1)
84    pub schema: String,
85    /// Unique verdict identifier
86    pub id: String,
87    /// Project identifier
88    pub project: String,
89    /// Benchmark name
90    pub benchmark: String,
91    /// Run identifier from receipt
92    pub run_id: String,
93    /// Overall status (pass/warn/fail/skip)
94    pub status: VerdictStatus,
95    /// Detailed counts
96    pub counts: VerdictCounts,
97    /// List of reasons for the verdict
98    pub reasons: Vec<String>,
99    /// Git reference
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub git_ref: Option<String>,
102    /// Git commit SHA
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub git_sha: Option<String>,
105    /// Creation timestamp
106    pub created_at: DateTime<Utc>,
107}
108
109/// Request for submitting a verdict.
110#[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/// Request for verdict list operation.
122#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct ListVerdictsQuery {
124    /// Filter by exact benchmark name
125    pub benchmark: Option<String>,
126    /// Filter by status
127    pub status: Option<VerdictStatus>,
128    /// Filter by creation date (after)
129    pub since: Option<DateTime<Utc>>,
130    /// Filter by creation date (before)
131    pub until: Option<DateTime<Utc>>,
132    /// Pagination limit
133    #[serde(default = "default_limit")]
134    pub limit: u32,
135    /// Pagination offset
136    #[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/// Response for verdict list operation.
176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177pub struct ListVerdictsResponse {
178    pub verdicts: Vec<VerdictRecord>,
179    pub pagination: PaginationInfo,
180}
181
182/// Version history metadata (without full receipt).
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
184pub struct BaselineVersion {
185    /// Version identifier
186    pub version: String,
187    /// Git reference
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub git_ref: Option<String>,
190    /// Git commit SHA
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub git_sha: Option<String>,
193    /// Creation timestamp
194    pub created_at: DateTime<Utc>,
195    /// Creator identifier
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub created_by: Option<String>,
198    /// Whether this is the current/promoted version
199    pub is_current: bool,
200    /// Source of this version
201    pub source: BaselineSource,
202}
203
204/// Retention policy for a project.
205#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
206pub struct RetentionPolicy {
207    /// Maximum number of versions to keep per benchmark.
208    pub max_versions: Option<u32>,
209    /// Maximum age of a version in days.
210    pub max_age_days: Option<u32>,
211    /// Tags that prevent a version from being deleted.
212    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/// Strategy for auto-generating versions.
226#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
227#[serde(rename_all = "snake_case")]
228pub enum VersioningStrategy {
229    /// Use run_id from receipt as version
230    #[default]
231    RunId,
232    /// Use timestamp as version
233    Timestamp,
234    /// Use git_sha as version
235    GitSha,
236    /// Manual version required
237    Manual,
238}
239
240/// Multi-tenancy namespace with retention policies.
241#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
242pub struct Project {
243    /// Schema identifier (perfgate.project.v1)
244    pub schema: String,
245    /// Project identifier (URL-safe)
246    pub id: String,
247    /// Display name
248    pub name: String,
249    /// Project description
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub description: Option<String>,
252    /// Creation timestamp
253    pub created_at: DateTime<Utc>,
254    /// Retention policy
255    pub retention: RetentionPolicy,
256    /// Default baseline versioning strategy
257    pub versioning: VersioningStrategy,
258}
259
260/// Request for baseline list operation.
261#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
262pub struct ListBaselinesQuery {
263    /// Filter by exact benchmark name
264    pub benchmark: Option<String>,
265    /// Filter by benchmark name prefix
266    pub benchmark_prefix: Option<String>,
267    /// Filter by git reference
268    pub git_ref: Option<String>,
269    /// Filter by git SHA
270    pub git_sha: Option<String>,
271    /// Filter by tags (comma-separated)
272    pub tags: Option<String>,
273    /// Filter by creation date (after)
274    pub since: Option<DateTime<Utc>>,
275    /// Filter by creation date (before)
276    pub until: Option<DateTime<Utc>>,
277    /// Include full receipts in output
278    #[serde(default)]
279    pub include_receipt: bool,
280    /// Pagination limit
281    #[serde(default = "default_limit")]
282    pub limit: u32,
283    /// Pagination offset
284    #[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/// Pagination information for lists.
377#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
378pub struct PaginationInfo {
379    /// Total count of items (if known)
380    pub total: u64,
381    /// Offset of current page
382    pub offset: u64,
383    /// Limit of items per page
384    pub limit: u32,
385    /// Whether more items are available
386    pub has_more: bool,
387}
388
389/// Response for baseline list operation.
390#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
391pub struct ListBaselinesResponse {
392    /// List of baseline summaries or records
393    pub baselines: Vec<BaselineSummary>,
394    /// Pagination metadata
395    pub pagination: PaginationInfo,
396}
397
398/// Summary of a baseline record (without full receipt).
399#[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/// Request for baseline upload.
428#[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/// Response for successful baseline upload.
441#[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/// Request for baseline promotion.
451#[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/// Response for baseline promotion.
463#[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/// Response for baseline deletion.
474#[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/// Health status of a storage backend.
484#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
485pub struct StorageHealth {
486    pub backend: String,
487    pub status: String,
488}
489
490/// Response for health check.
491#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
492pub struct HealthResponse {
493    pub status: String,
494    pub version: String,
495    pub storage: StorageHealth,
496}
497
498/// Generic error response for the API.
499#[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}