1pub mod auth;
18
19use crate::{
20 DecisionArtifactIndex, MetricStatus, RunReceipt, ScenarioReceipt, TradeoffReceipt,
21 VerdictCounts, VerdictStatus,
22};
23use chrono::{DateTime, Utc};
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use std::collections::BTreeMap;
27
28pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
30
31pub const PROJECT_SCHEMA_V1: &str = "perfgate.project.v1";
33
34pub const VERDICT_SCHEMA_V1: &str = "perfgate.verdict.v1";
36
37pub const DECISION_RECORD_SCHEMA_V1: &str = "perfgate.decision_record.v1";
39
40pub const AUDIT_SCHEMA_V1: &str = "perfgate.audit.v1";
42
43pub const HEALTH_SCHEMA_V1: &str = "perfgate.health.v1";
45
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum BaselineSource {
50 #[default]
52 Upload,
53 Promote,
55 Migrate,
57 Rollback,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
63pub struct BaselineRecord {
64 pub schema: String,
66 pub id: String,
68 pub project: String,
70 pub benchmark: String,
72 pub version: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub git_ref: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub git_sha: Option<String>,
80 pub receipt: RunReceipt,
82 #[serde(default)]
84 pub metadata: BTreeMap<String, String>,
85 #[serde(default)]
87 pub tags: Vec<String>,
88 pub created_at: DateTime<Utc>,
90 pub updated_at: DateTime<Utc>,
92 pub content_hash: String,
94 pub source: BaselineSource,
96 #[serde(default)]
98 pub deleted: bool,
99}
100
101impl BaselineRecord {
102 pub fn etag(&self) -> String {
104 format!("\"sha256:{}\"", self.content_hash)
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
110pub struct VerdictRecord {
111 pub schema: String,
113 pub id: String,
115 pub project: String,
117 pub benchmark: String,
119 pub run_id: String,
121 pub status: VerdictStatus,
123 pub counts: VerdictCounts,
125 pub reasons: Vec<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub git_ref: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub git_sha: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub wall_ms_cv: Option<f64>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub flakiness_score: Option<f64>,
139 pub created_at: DateTime<Utc>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
145pub struct SubmitVerdictRequest {
146 pub benchmark: String,
147 pub run_id: String,
148 pub status: VerdictStatus,
149 pub counts: VerdictCounts,
150 pub reasons: Vec<String>,
151 pub git_ref: Option<String>,
152 pub git_sha: Option<String>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub wall_ms_cv: Option<f64>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
159pub struct ListVerdictsQuery {
160 pub benchmark: Option<String>,
162 pub status: Option<VerdictStatus>,
164 pub since: Option<DateTime<Utc>>,
166 pub until: Option<DateTime<Utc>>,
168 #[serde(default = "default_limit")]
170 pub limit: u32,
171 #[serde(default)]
173 pub offset: u64,
174}
175
176impl Default for ListVerdictsQuery {
177 fn default() -> Self {
178 Self {
179 benchmark: None,
180 status: None,
181 since: None,
182 until: None,
183 limit: default_limit(),
184 offset: 0,
185 }
186 }
187}
188
189impl ListVerdictsQuery {
190 pub fn new() -> Self {
191 Self::default()
192 }
193 pub fn with_benchmark(mut self, b: impl Into<String>) -> Self {
194 self.benchmark = Some(b.into());
195 self
196 }
197 pub fn with_status(mut self, s: VerdictStatus) -> Self {
198 self.status = Some(s);
199 self
200 }
201 pub fn with_limit(mut self, l: u32) -> Self {
202 self.limit = l;
203 self
204 }
205 pub fn with_offset(mut self, o: u64) -> Self {
206 self.offset = o;
207 self
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
213pub struct ListVerdictsResponse {
214 pub verdicts: Vec<VerdictRecord>,
215 pub pagination: PaginationInfo,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
220pub struct DecisionRecord {
221 pub schema: String,
223 pub id: String,
225 pub project: String,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub scenario: Option<String>,
230 pub status: MetricStatus,
232 pub verdict: VerdictStatus,
234 #[serde(default)]
236 pub accepted_rules: Vec<String>,
237 #[serde(default)]
239 pub review_required: bool,
240 #[serde(default)]
242 pub review_reasons: Vec<String>,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub git_ref: Option<String>,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub git_sha: Option<String>,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub scenario_receipt: Option<ScenarioReceipt>,
252 pub tradeoff_receipt: TradeoffReceipt,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub artifact_index: Option<DecisionArtifactIndex>,
257 pub created_at: DateTime<Utc>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
263pub struct UploadDecisionRequest {
264 pub tradeoff: TradeoffReceipt,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub scenario: Option<ScenarioReceipt>,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub artifact_index: Option<DecisionArtifactIndex>,
269 pub git_ref: Option<String>,
270 pub git_sha: Option<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
275pub struct ListDecisionsQuery {
276 pub scenario: Option<String>,
277 pub status: Option<MetricStatus>,
278 pub verdict: Option<VerdictStatus>,
279 pub review_required: Option<bool>,
280 pub accepted: Option<bool>,
281 pub rule: Option<String>,
282 #[serde(default = "default_limit")]
283 pub limit: u32,
284 #[serde(default)]
285 pub offset: u64,
286}
287
288impl Default for ListDecisionsQuery {
289 fn default() -> Self {
290 Self {
291 scenario: None,
292 status: None,
293 verdict: None,
294 review_required: None,
295 accepted: None,
296 rule: None,
297 limit: default_limit(),
298 offset: 0,
299 }
300 }
301}
302
303impl ListDecisionsQuery {
304 pub fn new() -> Self {
305 Self::default()
306 }
307 pub fn with_scenario(mut self, scenario: impl Into<String>) -> Self {
308 self.scenario = Some(scenario.into());
309 self
310 }
311 pub fn with_status(mut self, status: MetricStatus) -> Self {
312 self.status = Some(status);
313 self
314 }
315 pub fn with_verdict(mut self, verdict: VerdictStatus) -> Self {
316 self.verdict = Some(verdict);
317 self
318 }
319 pub fn with_review_required(mut self, review_required: bool) -> Self {
320 self.review_required = Some(review_required);
321 self
322 }
323 pub fn with_accepted(mut self, accepted: bool) -> Self {
324 self.accepted = Some(accepted);
325 self
326 }
327 pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
328 self.rule = Some(rule.into());
329 self
330 }
331 pub fn with_limit(mut self, limit: u32) -> Self {
332 self.limit = limit;
333 self
334 }
335 pub fn with_offset(mut self, offset: u64) -> Self {
336 self.offset = offset;
337 self
338 }
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
343pub struct ListDecisionsResponse {
344 pub decisions: Vec<DecisionRecord>,
345 pub pagination: PaginationInfo,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
350pub struct PruneDecisionsRequest {
351 pub older_than: DateTime<Utc>,
353 #[serde(default)]
355 pub dry_run: bool,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
360pub struct PruneDecisionsResponse {
361 pub project: String,
363 pub older_than: DateTime<Utc>,
365 pub dry_run: bool,
367 pub matched: u64,
369 pub deleted: u64,
371 #[serde(default)]
373 pub decision_ids: Vec<String>,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
378pub struct BaselineVersion {
379 pub version: String,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub git_ref: Option<String>,
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub git_sha: Option<String>,
387 pub created_at: DateTime<Utc>,
389 #[serde(skip_serializing_if = "Option::is_none")]
391 pub created_by: Option<String>,
392 pub is_current: bool,
394 pub source: BaselineSource,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
400pub struct RetentionPolicy {
401 pub max_versions: Option<u32>,
403 pub max_age_days: Option<u32>,
405 pub preserve_tags: Vec<String>,
407}
408
409impl Default for RetentionPolicy {
410 fn default() -> Self {
411 Self {
412 max_versions: Some(50),
413 max_age_days: Some(365),
414 preserve_tags: vec!["production".to_string(), "stable".to_string()],
415 }
416 }
417}
418
419#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
421#[serde(rename_all = "snake_case")]
422pub enum VersioningStrategy {
423 #[default]
425 RunId,
426 Timestamp,
428 GitSha,
430 Manual,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
436pub struct Project {
437 pub schema: String,
439 pub id: String,
441 pub name: String,
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub description: Option<String>,
446 pub created_at: DateTime<Utc>,
448 pub retention: RetentionPolicy,
450 pub versioning: VersioningStrategy,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
456pub struct ListBaselinesQuery {
457 pub benchmark: Option<String>,
459 pub benchmark_prefix: Option<String>,
461 pub git_ref: Option<String>,
463 pub git_sha: Option<String>,
465 pub tags: Option<String>,
467 pub since: Option<DateTime<Utc>>,
469 pub until: Option<DateTime<Utc>>,
471 #[serde(default)]
473 pub include_receipt: bool,
474 #[serde(default = "default_limit")]
476 pub limit: u32,
477 #[serde(default)]
479 pub offset: u64,
480}
481
482impl Default for ListBaselinesQuery {
483 fn default() -> Self {
484 Self {
485 benchmark: None,
486 benchmark_prefix: None,
487 git_ref: None,
488 git_sha: None,
489 tags: None,
490 since: None,
491 until: None,
492 include_receipt: false,
493 limit: default_limit(),
494 offset: 0,
495 }
496 }
497}
498
499fn default_limit() -> u32 {
500 50
501}
502
503impl ListBaselinesQuery {
504 pub fn new() -> Self {
505 Self::default()
506 }
507 pub fn with_benchmark(mut self, b: impl Into<String>) -> Self {
508 self.benchmark = Some(b.into());
509 self
510 }
511 pub fn with_benchmark_prefix(mut self, p: impl Into<String>) -> Self {
512 self.benchmark_prefix = Some(p.into());
513 self
514 }
515 pub fn with_offset(mut self, o: u64) -> Self {
516 self.offset = o;
517 self
518 }
519 pub fn with_limit(mut self, l: u32) -> Self {
520 self.limit = l;
521 self
522 }
523 pub fn with_receipts(mut self) -> Self {
524 self.include_receipt = true;
525 self
526 }
527 pub fn parsed_tags(&self) -> Vec<String> {
528 self.tags
529 .as_ref()
530 .map(|t| {
531 t.split(',')
532 .map(|s| s.trim().to_string())
533 .filter(|s| !s.is_empty())
534 .collect()
535 })
536 .unwrap_or_default()
537 }
538 pub fn to_query_params(&self) -> Vec<(String, String)> {
539 let mut params = Vec::new();
540 if let Some(b) = &self.benchmark {
541 params.push(("benchmark".to_string(), b.clone()));
542 }
543 if let Some(p) = &self.benchmark_prefix {
544 params.push(("benchmark_prefix".to_string(), p.clone()));
545 }
546 if let Some(r) = &self.git_ref {
547 params.push(("git_ref".to_string(), r.clone()));
548 }
549 if let Some(s) = &self.git_sha {
550 params.push(("git_sha".to_string(), s.clone()));
551 }
552 if let Some(t) = &self.tags {
553 params.push(("tags".to_string(), t.clone()));
554 }
555 if let Some(s) = &self.since {
556 params.push(("since".to_string(), s.to_rfc3339()));
557 }
558 if let Some(u) = &self.until {
559 params.push(("until".to_string(), u.to_rfc3339()));
560 }
561 params.push(("limit".to_string(), self.limit.to_string()));
562 params.push(("offset".to_string(), self.offset.to_string()));
563 if self.include_receipt {
564 params.push(("include_receipt".to_string(), "true".to_string()));
565 }
566 params
567 }
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
572pub struct PaginationInfo {
573 pub total: u64,
575 pub offset: u64,
577 pub limit: u32,
579 pub has_more: bool,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
585pub struct ListBaselinesResponse {
586 pub baselines: Vec<BaselineSummary>,
588 pub pagination: PaginationInfo,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
594pub struct BaselineSummary {
595 pub id: String,
596 pub benchmark: String,
597 pub version: String,
598 pub created_at: DateTime<Utc>,
599 pub git_ref: Option<String>,
600 pub git_sha: Option<String>,
601 pub tags: Vec<String>,
602 #[serde(skip_serializing_if = "Option::is_none")]
603 pub receipt: Option<RunReceipt>,
604}
605
606impl From<BaselineRecord> for BaselineSummary {
607 fn from(record: BaselineRecord) -> Self {
608 Self {
609 id: record.id,
610 benchmark: record.benchmark,
611 version: record.version,
612 created_at: record.created_at,
613 git_ref: record.git_ref,
614 git_sha: record.git_sha,
615 tags: record.tags,
616 receipt: Some(record.receipt),
617 }
618 }
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
623pub struct UploadBaselineRequest {
624 pub benchmark: String,
625 pub version: Option<String>,
626 pub git_ref: Option<String>,
627 pub git_sha: Option<String>,
628 pub receipt: RunReceipt,
629 pub metadata: BTreeMap<String, String>,
630 pub tags: Vec<String>,
631 pub normalize: bool,
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
636pub struct UploadBaselineResponse {
637 pub id: String,
638 pub benchmark: String,
639 pub version: String,
640 pub created_at: DateTime<Utc>,
641 pub etag: String,
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
646pub struct PromoteBaselineRequest {
647 pub from_version: String,
648 pub to_version: String,
649 pub git_ref: Option<String>,
650 pub git_sha: Option<String>,
651 pub tags: Vec<String>,
652 #[serde(default)]
653 pub normalize: bool,
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
658pub struct PromoteBaselineResponse {
659 pub id: String,
660 pub benchmark: String,
661 pub version: String,
662 pub promoted_from: String,
663 pub promoted_at: DateTime<Utc>,
664 pub created_at: DateTime<Utc>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
669pub struct DeleteBaselineResponse {
670 pub deleted: bool,
671 pub id: String,
672 pub benchmark: String,
673 pub version: String,
674 pub deleted_at: DateTime<Utc>,
675}
676
677#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
683#[serde(rename_all = "snake_case")]
684pub enum AuditAction {
685 Create,
687 Update,
689 Delete,
691 Promote,
693}
694
695impl std::fmt::Display for AuditAction {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 match self {
698 AuditAction::Create => write!(f, "create"),
699 AuditAction::Update => write!(f, "update"),
700 AuditAction::Delete => write!(f, "delete"),
701 AuditAction::Promote => write!(f, "promote"),
702 }
703 }
704}
705
706impl std::str::FromStr for AuditAction {
707 type Err = String;
708
709 fn from_str(s: &str) -> Result<Self, Self::Err> {
710 match s {
711 "create" => Ok(AuditAction::Create),
712 "update" => Ok(AuditAction::Update),
713 "delete" => Ok(AuditAction::Delete),
714 "promote" => Ok(AuditAction::Promote),
715 other => Err(format!("Unknown audit action: {}", other)),
716 }
717 }
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
722#[serde(rename_all = "snake_case")]
723pub enum AuditResourceType {
724 Baseline,
726 Key,
728 Verdict,
730 Decision,
732}
733
734impl std::fmt::Display for AuditResourceType {
735 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
736 match self {
737 AuditResourceType::Baseline => write!(f, "baseline"),
738 AuditResourceType::Key => write!(f, "key"),
739 AuditResourceType::Verdict => write!(f, "verdict"),
740 AuditResourceType::Decision => write!(f, "decision"),
741 }
742 }
743}
744
745impl std::str::FromStr for AuditResourceType {
746 type Err = String;
747
748 fn from_str(s: &str) -> Result<Self, Self::Err> {
749 match s {
750 "baseline" => Ok(AuditResourceType::Baseline),
751 "key" => Ok(AuditResourceType::Key),
752 "verdict" => Ok(AuditResourceType::Verdict),
753 "decision" => Ok(AuditResourceType::Decision),
754 other => Err(format!("Unknown resource type: {}", other)),
755 }
756 }
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
761pub struct AuditEvent {
762 pub id: String,
764 pub timestamp: DateTime<Utc>,
766 pub actor: String,
768 pub action: AuditAction,
770 pub resource_type: AuditResourceType,
772 pub resource_id: String,
774 pub project: String,
776 #[serde(default)]
778 pub metadata: serde_json::Value,
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
783pub struct ListAuditEventsQuery {
784 pub project: Option<String>,
786 pub action: Option<String>,
788 pub resource_type: Option<String>,
790 pub actor: Option<String>,
792 pub since: Option<DateTime<Utc>>,
794 pub until: Option<DateTime<Utc>>,
796 #[serde(default = "default_limit")]
798 pub limit: u32,
799 #[serde(default)]
801 pub offset: u64,
802}
803
804impl Default for ListAuditEventsQuery {
805 fn default() -> Self {
806 Self {
807 project: None,
808 action: None,
809 resource_type: None,
810 actor: None,
811 since: None,
812 until: None,
813 limit: default_limit(),
814 offset: 0,
815 }
816 }
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
821pub struct ListAuditEventsResponse {
822 pub events: Vec<AuditEvent>,
824 pub pagination: PaginationInfo,
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
830pub struct StorageHealth {
831 pub backend: String,
832 pub status: String,
833 #[serde(skip_serializing_if = "Option::is_none")]
835 pub detail: Option<String>,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
840pub struct PoolMetrics {
841 pub idle: u32,
843 pub active: u32,
845 pub max: u32,
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
851pub struct HealthResponse {
852 pub status: String,
853 pub version: String,
854 pub storage: StorageHealth,
855 #[serde(skip_serializing_if = "Option::is_none")]
857 pub pool: Option<PoolMetrics>,
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
862pub struct ApiError {
863 pub code: String,
864 pub message: String,
865 #[serde(skip_serializing_if = "Option::is_none")]
866 pub details: Option<serde_json::Value>,
867}
868
869impl ApiError {
870 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
871 Self {
872 code: code.into(),
873 message: message.into(),
874 details: None,
875 }
876 }
877 pub fn unauthorized(msg: &str) -> Self {
878 Self::new("unauthorized", msg)
879 }
880 pub fn forbidden(msg: &str) -> Self {
881 Self::new("forbidden", msg)
882 }
883 pub fn not_found(msg: &str) -> Self {
884 Self::new("not_found", msg)
885 }
886 pub fn bad_request(msg: &str) -> Self {
887 Self::new("bad_request", msg)
888 }
889 pub fn conflict(msg: &str) -> Self {
890 Self::new("conflict", msg)
891 }
892 pub fn internal_error(msg: &str) -> Self {
893 Self::new("internal_error", msg)
894 }
895 pub fn internal(msg: &str) -> Self {
896 Self::internal_error(msg)
897 }
898 pub fn validation(msg: &str) -> Self {
899 Self::new("invalid_input", msg)
900 }
901 pub fn already_exists(msg: &str) -> Self {
902 Self::new("conflict", msg)
903 }
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
910pub struct CreateKeyRequest {
911 pub description: String,
913 pub role: auth::Role,
915 pub project: String,
917 #[serde(skip_serializing_if = "Option::is_none")]
919 pub pattern: Option<String>,
920 #[serde(skip_serializing_if = "Option::is_none")]
922 pub expires_at: Option<DateTime<Utc>>,
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
927pub struct CreateKeyResponse {
928 pub id: String,
930 pub key: String,
932 pub description: String,
934 pub role: auth::Role,
936 pub project: String,
938 #[serde(skip_serializing_if = "Option::is_none")]
940 pub pattern: Option<String>,
941 pub created_at: DateTime<Utc>,
943 #[serde(skip_serializing_if = "Option::is_none")]
945 pub expires_at: Option<DateTime<Utc>>,
946}
947
948#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
950pub struct KeyEntry {
951 pub id: String,
953 pub key_prefix: String,
955 pub description: String,
957 pub role: auth::Role,
959 pub project: String,
961 #[serde(skip_serializing_if = "Option::is_none")]
963 pub pattern: Option<String>,
964 pub created_at: DateTime<Utc>,
966 #[serde(skip_serializing_if = "Option::is_none")]
968 pub expires_at: Option<DateTime<Utc>>,
969 #[serde(skip_serializing_if = "Option::is_none")]
971 pub revoked_at: Option<DateTime<Utc>>,
972}
973
974#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
976pub struct ListKeysResponse {
977 pub keys: Vec<KeyEntry>,
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
983pub struct RevokeKeyResponse {
984 pub id: String,
986 pub revoked_at: DateTime<Utc>,
988}
989
990pub const DEPENDENCY_EVENT_SCHEMA_V1: &str = "perfgate.dependency_event.v1";
996
997pub const FLEET_ALERT_SCHEMA_V1: &str = "perfgate.fleet_alert.v1";
999
1000#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1002pub struct DependencyChange {
1003 pub name: String,
1005 #[serde(skip_serializing_if = "Option::is_none")]
1007 pub old_version: Option<String>,
1008 #[serde(skip_serializing_if = "Option::is_none")]
1010 pub new_version: Option<String>,
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1015pub struct DependencyEvent {
1016 pub schema: String,
1018 pub id: String,
1020 pub project: String,
1022 pub benchmark: String,
1024 pub dep_name: String,
1026 #[serde(skip_serializing_if = "Option::is_none")]
1028 pub old_version: Option<String>,
1029 #[serde(skip_serializing_if = "Option::is_none")]
1031 pub new_version: Option<String>,
1032 pub metric: String,
1034 pub delta_pct: f64,
1036 pub created_at: DateTime<Utc>,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1042pub struct RecordDependencyEventRequest {
1043 pub project: String,
1045 pub benchmark: String,
1047 pub dependency_changes: Vec<DependencyChange>,
1049 pub metric: String,
1051 pub delta_pct: f64,
1053}
1054
1055#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1057pub struct RecordDependencyEventResponse {
1058 pub recorded: usize,
1060}
1061
1062#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1064pub struct AffectedProject {
1065 pub project: String,
1067 pub benchmark: String,
1069 pub metric: String,
1071 pub delta_pct: f64,
1073}
1074
1075#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1077pub struct FleetAlert {
1078 pub schema: String,
1080 pub id: String,
1082 pub dependency: String,
1084 #[serde(skip_serializing_if = "Option::is_none")]
1086 pub old_version: Option<String>,
1087 #[serde(skip_serializing_if = "Option::is_none")]
1089 pub new_version: Option<String>,
1090 pub affected_projects: Vec<AffectedProject>,
1092 pub confidence: f64,
1094 pub avg_delta_pct: f64,
1096 pub first_seen: DateTime<Utc>,
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1102pub struct ListFleetAlertsQuery {
1103 #[serde(default = "default_min_affected")]
1105 pub min_affected: usize,
1106 pub since: Option<DateTime<Utc>>,
1108 #[serde(default = "default_limit")]
1110 pub limit: u32,
1111}
1112
1113impl Default for ListFleetAlertsQuery {
1114 fn default() -> Self {
1115 Self {
1116 min_affected: default_min_affected(),
1117 since: None,
1118 limit: default_limit(),
1119 }
1120 }
1121}
1122
1123fn default_min_affected() -> usize {
1124 2
1125}
1126
1127#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1129pub struct ListFleetAlertsResponse {
1130 pub alerts: Vec<FleetAlert>,
1131}
1132
1133#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1135pub struct DependencyImpactQuery {
1136 pub since: Option<DateTime<Utc>>,
1138 #[serde(default = "default_limit")]
1140 pub limit: u32,
1141}
1142
1143impl Default for DependencyImpactQuery {
1144 fn default() -> Self {
1145 Self {
1146 since: None,
1147 limit: default_limit(),
1148 }
1149 }
1150}
1151
1152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1154pub struct DependencyImpactResponse {
1155 pub dependency: String,
1157 pub events: Vec<DependencyEvent>,
1159 pub project_count: usize,
1161 pub avg_delta_pct: f64,
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168 use crate::{BenchMeta, HostInfo, RUN_SCHEMA_V1, RunMeta, Stats, ToolInfo, U64Summary};
1169 use chrono::TimeZone;
1170
1171 fn timestamp() -> DateTime<Utc> {
1172 Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()
1173 }
1174
1175 fn run_receipt() -> RunReceipt {
1176 RunReceipt {
1177 schema: RUN_SCHEMA_V1.to_string(),
1178 tool: ToolInfo {
1179 name: "perfgate".to_string(),
1180 version: "0.15.1".to_string(),
1181 },
1182 run: RunMeta {
1183 id: "run-1".to_string(),
1184 started_at: "2026-01-02T03:04:05Z".to_string(),
1185 ended_at: "2026-01-02T03:04:06Z".to_string(),
1186 host: HostInfo {
1187 os: "linux".to_string(),
1188 arch: "x86_64".to_string(),
1189 cpu_count: None,
1190 memory_bytes: None,
1191 hostname_hash: None,
1192 },
1193 },
1194 bench: BenchMeta {
1195 name: "bench-a".to_string(),
1196 cwd: None,
1197 command: vec!["bench".to_string()],
1198 repeat: 1,
1199 warmup: 0,
1200 work_units: None,
1201 timeout_ms: None,
1202 },
1203 samples: Vec::new(),
1204 stats: Stats {
1205 wall_ms: U64Summary::new(100, 90, 110),
1206 cpu_ms: None,
1207 page_faults: None,
1208 ctx_switches: None,
1209 max_rss_kb: None,
1210 io_read_bytes: None,
1211 io_write_bytes: None,
1212 network_packets: None,
1213 energy_uj: None,
1214 binary_bytes: None,
1215 throughput_per_s: None,
1216 },
1217 }
1218 }
1219
1220 fn baseline_record() -> BaselineRecord {
1221 BaselineRecord {
1222 schema: BASELINE_SCHEMA_V1.to_string(),
1223 id: "baseline-1".to_string(),
1224 project: "project-a".to_string(),
1225 benchmark: "bench-a".to_string(),
1226 version: "v1".to_string(),
1227 git_ref: Some("refs/heads/main".to_string()),
1228 git_sha: Some("abc123".to_string()),
1229 receipt: run_receipt(),
1230 metadata: BTreeMap::from([("runner".to_string(), "linux".to_string())]),
1231 tags: vec!["stable".to_string()],
1232 created_at: timestamp(),
1233 updated_at: timestamp(),
1234 content_hash: "deadbeef".to_string(),
1235 source: BaselineSource::Promote,
1236 deleted: false,
1237 }
1238 }
1239
1240 #[test]
1241 fn baseline_record_helpers_preserve_contract_fields() {
1242 let record = baseline_record();
1243 assert_eq!(record.etag(), "\"sha256:deadbeef\"");
1244
1245 let summary = BaselineSummary::from(record);
1246 assert_eq!(summary.id, "baseline-1");
1247 assert_eq!(summary.benchmark, "bench-a");
1248 assert_eq!(summary.version, "v1");
1249 assert_eq!(summary.git_ref.as_deref(), Some("refs/heads/main"));
1250 assert_eq!(summary.git_sha.as_deref(), Some("abc123"));
1251 assert_eq!(summary.tags, ["stable"]);
1252 assert!(summary.receipt.is_some());
1253 }
1254
1255 #[test]
1256 fn baseline_query_builder_tracks_filters_and_params() {
1257 let query = ListBaselinesQuery::new()
1258 .with_benchmark("bench-a")
1259 .with_benchmark_prefix("bench")
1260 .with_limit(10)
1261 .with_offset(20)
1262 .with_receipts();
1263 assert_eq!(query.benchmark.as_deref(), Some("bench-a"));
1264 assert_eq!(query.benchmark_prefix.as_deref(), Some("bench"));
1265 assert!(query.include_receipt);
1266
1267 let mut query = query;
1268 query.git_ref = Some("main".to_string());
1269 query.git_sha = Some("abc123".to_string());
1270 query.tags = Some("stable, production,, ".to_string());
1271 query.since = Some(timestamp());
1272 query.until = Some(timestamp());
1273
1274 assert_eq!(query.parsed_tags(), ["stable", "production"]);
1275 let params = query.to_query_params();
1276 assert!(params.contains(&("benchmark".to_string(), "bench-a".to_string())));
1277 assert!(params.contains(&("benchmark_prefix".to_string(), "bench".to_string())));
1278 assert!(params.contains(&("git_ref".to_string(), "main".to_string())));
1279 assert!(params.contains(&("git_sha".to_string(), "abc123".to_string())));
1280 assert!(params.contains(&("tags".to_string(), "stable, production,, ".to_string())));
1281 assert!(params.contains(&("since".to_string(), timestamp().to_rfc3339())));
1282 assert!(params.contains(&("until".to_string(), timestamp().to_rfc3339())));
1283 assert!(params.contains(&("limit".to_string(), "10".to_string())));
1284 assert!(params.contains(&("offset".to_string(), "20".to_string())));
1285 assert!(params.contains(&("include_receipt".to_string(), "true".to_string())));
1286 }
1287
1288 #[test]
1289 fn verdict_and_audit_queries_have_stable_defaults() {
1290 let verdicts = ListVerdictsQuery::new()
1291 .with_benchmark("bench-a")
1292 .with_status(VerdictStatus::Warn)
1293 .with_limit(25)
1294 .with_offset(5);
1295 assert_eq!(verdicts.benchmark.as_deref(), Some("bench-a"));
1296 assert_eq!(verdicts.status, Some(VerdictStatus::Warn));
1297 assert_eq!(verdicts.limit, 25);
1298 assert_eq!(verdicts.offset, 5);
1299
1300 let audit = ListAuditEventsQuery::default();
1301 assert_eq!(audit.limit, default_limit());
1302 assert_eq!(audit.offset, 0);
1303 assert!(audit.project.is_none());
1304 assert!(audit.action.is_none());
1305 assert!(audit.resource_type.is_none());
1306 assert!(audit.actor.is_none());
1307 assert!(audit.since.is_none());
1308 assert!(audit.until.is_none());
1309 }
1310
1311 #[test]
1312 fn retention_and_fleet_defaults_are_stable() {
1313 let retention = RetentionPolicy::default();
1314 assert_eq!(retention.max_versions, Some(50));
1315 assert_eq!(retention.max_age_days, Some(365));
1316 assert_eq!(retention.preserve_tags, ["production", "stable"]);
1317
1318 let fleet = ListFleetAlertsQuery::default();
1319 assert_eq!(fleet.min_affected, default_min_affected());
1320 assert_eq!(fleet.limit, default_limit());
1321 assert!(fleet.since.is_none());
1322
1323 let dependency = DependencyImpactQuery::default();
1324 assert_eq!(dependency.limit, default_limit());
1325 assert!(dependency.since.is_none());
1326 }
1327
1328 #[test]
1329 fn health_storage_detail_is_additive() {
1330 let legacy = serde_json::json!({
1331 "status": "healthy",
1332 "version": "0.15.1",
1333 "storage": {
1334 "backend": "memory",
1335 "status": "healthy"
1336 }
1337 });
1338 let legacy: HealthResponse = serde_json::from_value(legacy).expect("legacy health");
1339 assert_eq!(legacy.storage.detail, None);
1340
1341 let detailed = serde_json::json!({
1342 "status": "degraded",
1343 "version": "0.15.1",
1344 "storage": {
1345 "backend": "postgres",
1346 "status": "unhealthy",
1347 "detail": "query_error"
1348 }
1349 });
1350 let detailed: HealthResponse = serde_json::from_value(detailed).expect("detailed health");
1351 assert_eq!(detailed.storage.detail.as_deref(), Some("query_error"));
1352 }
1353
1354 #[test]
1355 fn audit_enums_parse_and_render_wire_tokens() {
1356 assert_eq!(AuditAction::Create.to_string(), "create");
1357 assert_eq!(AuditAction::Update.to_string(), "update");
1358 assert_eq!(AuditAction::Delete.to_string(), "delete");
1359 assert_eq!(AuditAction::Promote.to_string(), "promote");
1360 assert_eq!("create".parse::<AuditAction>(), Ok(AuditAction::Create));
1361 assert_eq!("update".parse::<AuditAction>(), Ok(AuditAction::Update));
1362 assert_eq!("delete".parse::<AuditAction>(), Ok(AuditAction::Delete));
1363 assert_eq!("promote".parse::<AuditAction>(), Ok(AuditAction::Promote));
1364 assert!("unknown".parse::<AuditAction>().is_err());
1365
1366 assert_eq!(AuditResourceType::Baseline.to_string(), "baseline");
1367 assert_eq!(AuditResourceType::Key.to_string(), "key");
1368 assert_eq!(AuditResourceType::Verdict.to_string(), "verdict");
1369 assert_eq!(
1370 "baseline".parse::<AuditResourceType>(),
1371 Ok(AuditResourceType::Baseline)
1372 );
1373 assert_eq!(
1374 "key".parse::<AuditResourceType>(),
1375 Ok(AuditResourceType::Key)
1376 );
1377 assert_eq!(
1378 "verdict".parse::<AuditResourceType>(),
1379 Ok(AuditResourceType::Verdict)
1380 );
1381 assert!("runner".parse::<AuditResourceType>().is_err());
1382 }
1383
1384 #[test]
1385 fn api_error_constructors_use_stable_codes() {
1386 assert_eq!(ApiError::new("custom", "message").code, "custom");
1387 assert_eq!(ApiError::unauthorized("message").code, "unauthorized");
1388 assert_eq!(ApiError::forbidden("message").code, "forbidden");
1389 assert_eq!(ApiError::not_found("message").code, "not_found");
1390 assert_eq!(ApiError::bad_request("message").code, "bad_request");
1391 assert_eq!(ApiError::conflict("message").code, "conflict");
1392 assert_eq!(ApiError::internal_error("message").code, "internal_error");
1393 assert_eq!(ApiError::internal("message").code, "internal_error");
1394 assert_eq!(ApiError::validation("message").code, "invalid_input");
1395 assert_eq!(ApiError::already_exists("message").code, "conflict");
1396 }
1397}