Skip to main content

perfgate_api/
lib.rs

1//! Common API types and models for the perfgate baseline service.
2//!
3//! Defines request/response types, baseline records, project models, and verdict
4//! history used by both the server and client crates.
5//!
6//! Part of the [perfgate](https://github.com/EffortlessMetrics/perfgate) workspace.
7//!
8//! # Example
9//!
10//! ```
11//! use perfgate_api::BASELINE_SCHEMA_V1;
12//!
13//! assert_eq!(BASELINE_SCHEMA_V1, "perfgate.baseline.v1");
14//! ```
15
16use chrono::{DateTime, Utc};
17use perfgate_types::{RunReceipt, VerdictCounts, VerdictStatus};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use std::collections::BTreeMap;
21
22/// Schema identifier for baseline records.
23pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
24
25/// Schema identifier for project records.
26pub const PROJECT_SCHEMA_V1: &str = "perfgate.project.v1";
27
28/// Schema identifier for verdict records.
29pub const VERDICT_SCHEMA_V1: &str = "perfgate.verdict.v1";
30
31/// Schema identifier for audit event records.
32pub const AUDIT_SCHEMA_V1: &str = "perfgate.audit.v1";
33
34/// Source of baseline creation.
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum BaselineSource {
38    /// Uploaded directly via API
39    #[default]
40    Upload,
41    /// Created via promote operation
42    Promote,
43    /// Migrated from external storage
44    Migrate,
45    /// Created via rollback operation
46    Rollback,
47}
48
49/// The primary storage model for baselines.
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
51pub struct BaselineRecord {
52    /// Schema identifier (perfgate.baseline.v1)
53    pub schema: String,
54    /// Unique baseline identifier (ULID format)
55    pub id: String,
56    /// Project/namespace identifier
57    pub project: String,
58    /// Benchmark name
59    pub benchmark: String,
60    /// Semantic version
61    pub version: String,
62    /// Git reference
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub git_ref: Option<String>,
65    /// Git commit SHA
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub git_sha: Option<String>,
68    /// Full run receipt
69    pub receipt: RunReceipt,
70    /// User-provided metadata
71    #[serde(default)]
72    pub metadata: BTreeMap<String, String>,
73    /// Tags for filtering
74    #[serde(default)]
75    pub tags: Vec<String>,
76    /// Creation timestamp (RFC 3339)
77    pub created_at: DateTime<Utc>,
78    /// Last modification timestamp
79    pub updated_at: DateTime<Utc>,
80    /// Content hash for ETag
81    pub content_hash: String,
82    /// Creation source
83    pub source: BaselineSource,
84    /// Soft delete flag
85    #[serde(default)]
86    pub deleted: bool,
87}
88
89impl BaselineRecord {
90    /// Returns the ETag value for this baseline.
91    pub fn etag(&self) -> String {
92        format!("\"sha256:{}\"", self.content_hash)
93    }
94}
95
96/// A record of a benchmark execution verdict.
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
98pub struct VerdictRecord {
99    /// Schema identifier (perfgate.verdict.v1)
100    pub schema: String,
101    /// Unique verdict identifier
102    pub id: String,
103    /// Project identifier
104    pub project: String,
105    /// Benchmark name
106    pub benchmark: String,
107    /// Run identifier from receipt
108    pub run_id: String,
109    /// Overall status (pass/warn/fail/skip)
110    pub status: VerdictStatus,
111    /// Detailed counts
112    pub counts: VerdictCounts,
113    /// List of reasons for the verdict
114    pub reasons: Vec<String>,
115    /// Git reference
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub git_ref: Option<String>,
118    /// Git commit SHA
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub git_sha: Option<String>,
121    /// Creation timestamp
122    pub created_at: DateTime<Utc>,
123}
124
125/// Request for submitting a verdict.
126#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
127pub struct SubmitVerdictRequest {
128    pub benchmark: String,
129    pub run_id: String,
130    pub status: VerdictStatus,
131    pub counts: VerdictCounts,
132    pub reasons: Vec<String>,
133    pub git_ref: Option<String>,
134    pub git_sha: Option<String>,
135}
136
137/// Request for verdict list operation.
138#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
139pub struct ListVerdictsQuery {
140    /// Filter by exact benchmark name
141    pub benchmark: Option<String>,
142    /// Filter by status
143    pub status: Option<VerdictStatus>,
144    /// Filter by creation date (after)
145    pub since: Option<DateTime<Utc>>,
146    /// Filter by creation date (before)
147    pub until: Option<DateTime<Utc>>,
148    /// Pagination limit
149    #[serde(default = "default_limit")]
150    pub limit: u32,
151    /// Pagination offset
152    #[serde(default)]
153    pub offset: u64,
154}
155
156impl Default for ListVerdictsQuery {
157    fn default() -> Self {
158        Self {
159            benchmark: None,
160            status: None,
161            since: None,
162            until: None,
163            limit: default_limit(),
164            offset: 0,
165        }
166    }
167}
168
169impl ListVerdictsQuery {
170    pub fn new() -> Self {
171        Self::default()
172    }
173    pub fn with_benchmark(mut self, b: impl Into<String>) -> Self {
174        self.benchmark = Some(b.into());
175        self
176    }
177    pub fn with_status(mut self, s: VerdictStatus) -> Self {
178        self.status = Some(s);
179        self
180    }
181    pub fn with_limit(mut self, l: u32) -> Self {
182        self.limit = l;
183        self
184    }
185    pub fn with_offset(mut self, o: u64) -> Self {
186        self.offset = o;
187        self
188    }
189}
190
191/// Response for verdict list operation.
192#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
193pub struct ListVerdictsResponse {
194    pub verdicts: Vec<VerdictRecord>,
195    pub pagination: PaginationInfo,
196}
197
198/// Version history metadata (without full receipt).
199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
200pub struct BaselineVersion {
201    /// Version identifier
202    pub version: String,
203    /// Git reference
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub git_ref: Option<String>,
206    /// Git commit SHA
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub git_sha: Option<String>,
209    /// Creation timestamp
210    pub created_at: DateTime<Utc>,
211    /// Creator identifier
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub created_by: Option<String>,
214    /// Whether this is the current/promoted version
215    pub is_current: bool,
216    /// Source of this version
217    pub source: BaselineSource,
218}
219
220/// Retention policy for a project.
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
222pub struct RetentionPolicy {
223    /// Maximum number of versions to keep per benchmark.
224    pub max_versions: Option<u32>,
225    /// Maximum age of a version in days.
226    pub max_age_days: Option<u32>,
227    /// Tags that prevent a version from being deleted.
228    pub preserve_tags: Vec<String>,
229}
230
231impl Default for RetentionPolicy {
232    fn default() -> Self {
233        Self {
234            max_versions: Some(50),
235            max_age_days: Some(365),
236            preserve_tags: vec!["production".to_string(), "stable".to_string()],
237        }
238    }
239}
240
241/// Strategy for auto-generating versions.
242#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
243#[serde(rename_all = "snake_case")]
244pub enum VersioningStrategy {
245    /// Use run_id from receipt as version
246    #[default]
247    RunId,
248    /// Use timestamp as version
249    Timestamp,
250    /// Use git_sha as version
251    GitSha,
252    /// Manual version required
253    Manual,
254}
255
256/// Multi-tenancy namespace with retention policies.
257#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
258pub struct Project {
259    /// Schema identifier (perfgate.project.v1)
260    pub schema: String,
261    /// Project identifier (URL-safe)
262    pub id: String,
263    /// Display name
264    pub name: String,
265    /// Project description
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub description: Option<String>,
268    /// Creation timestamp
269    pub created_at: DateTime<Utc>,
270    /// Retention policy
271    pub retention: RetentionPolicy,
272    /// Default baseline versioning strategy
273    pub versioning: VersioningStrategy,
274}
275
276/// Request for baseline list operation.
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
278pub struct ListBaselinesQuery {
279    /// Filter by exact benchmark name
280    pub benchmark: Option<String>,
281    /// Filter by benchmark name prefix
282    pub benchmark_prefix: Option<String>,
283    /// Filter by git reference
284    pub git_ref: Option<String>,
285    /// Filter by git SHA
286    pub git_sha: Option<String>,
287    /// Filter by tags (comma-separated)
288    pub tags: Option<String>,
289    /// Filter by creation date (after)
290    pub since: Option<DateTime<Utc>>,
291    /// Filter by creation date (before)
292    pub until: Option<DateTime<Utc>>,
293    /// Include full receipts in output
294    #[serde(default)]
295    pub include_receipt: bool,
296    /// Pagination limit
297    #[serde(default = "default_limit")]
298    pub limit: u32,
299    /// Pagination offset
300    #[serde(default)]
301    pub offset: u64,
302}
303
304impl Default for ListBaselinesQuery {
305    fn default() -> Self {
306        Self {
307            benchmark: None,
308            benchmark_prefix: None,
309            git_ref: None,
310            git_sha: None,
311            tags: None,
312            since: None,
313            until: None,
314            include_receipt: false,
315            limit: default_limit(),
316            offset: 0,
317        }
318    }
319}
320
321fn default_limit() -> u32 {
322    50
323}
324
325impl ListBaselinesQuery {
326    pub fn new() -> Self {
327        Self::default()
328    }
329    pub fn with_benchmark(mut self, b: impl Into<String>) -> Self {
330        self.benchmark = Some(b.into());
331        self
332    }
333    pub fn with_benchmark_prefix(mut self, p: impl Into<String>) -> Self {
334        self.benchmark_prefix = Some(p.into());
335        self
336    }
337    pub fn with_offset(mut self, o: u64) -> Self {
338        self.offset = o;
339        self
340    }
341    pub fn with_limit(mut self, l: u32) -> Self {
342        self.limit = l;
343        self
344    }
345    pub fn with_receipts(mut self) -> Self {
346        self.include_receipt = true;
347        self
348    }
349    pub fn parsed_tags(&self) -> Vec<String> {
350        self.tags
351            .as_ref()
352            .map(|t| {
353                t.split(',')
354                    .map(|s| s.trim().to_string())
355                    .filter(|s| !s.is_empty())
356                    .collect()
357            })
358            .unwrap_or_default()
359    }
360    pub fn to_query_params(&self) -> Vec<(String, String)> {
361        let mut params = Vec::new();
362        if let Some(b) = &self.benchmark {
363            params.push(("benchmark".to_string(), b.clone()));
364        }
365        if let Some(p) = &self.benchmark_prefix {
366            params.push(("benchmark_prefix".to_string(), p.clone()));
367        }
368        if let Some(r) = &self.git_ref {
369            params.push(("git_ref".to_string(), r.clone()));
370        }
371        if let Some(s) = &self.git_sha {
372            params.push(("git_sha".to_string(), s.clone()));
373        }
374        if let Some(t) = &self.tags {
375            params.push(("tags".to_string(), t.clone()));
376        }
377        if let Some(s) = &self.since {
378            params.push(("since".to_string(), s.to_rfc3339()));
379        }
380        if let Some(u) = &self.until {
381            params.push(("until".to_string(), u.to_rfc3339()));
382        }
383        params.push(("limit".to_string(), self.limit.to_string()));
384        params.push(("offset".to_string(), self.offset.to_string()));
385        if self.include_receipt {
386            params.push(("include_receipt".to_string(), "true".to_string()));
387        }
388        params
389    }
390}
391
392/// Pagination information for lists.
393#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
394pub struct PaginationInfo {
395    /// Total count of items (if known)
396    pub total: u64,
397    /// Offset of current page
398    pub offset: u64,
399    /// Limit of items per page
400    pub limit: u32,
401    /// Whether more items are available
402    pub has_more: bool,
403}
404
405/// Response for baseline list operation.
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
407pub struct ListBaselinesResponse {
408    /// List of baseline summaries or records
409    pub baselines: Vec<BaselineSummary>,
410    /// Pagination metadata
411    pub pagination: PaginationInfo,
412}
413
414/// Summary of a baseline record (without full receipt).
415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
416pub struct BaselineSummary {
417    pub id: String,
418    pub benchmark: String,
419    pub version: String,
420    pub created_at: DateTime<Utc>,
421    pub git_ref: Option<String>,
422    pub git_sha: Option<String>,
423    pub tags: Vec<String>,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub receipt: Option<RunReceipt>,
426}
427
428impl From<BaselineRecord> for BaselineSummary {
429    fn from(record: BaselineRecord) -> Self {
430        Self {
431            id: record.id,
432            benchmark: record.benchmark,
433            version: record.version,
434            created_at: record.created_at,
435            git_ref: record.git_ref,
436            git_sha: record.git_sha,
437            tags: record.tags,
438            receipt: Some(record.receipt),
439        }
440    }
441}
442
443/// Request for baseline upload.
444#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
445pub struct UploadBaselineRequest {
446    pub benchmark: String,
447    pub version: Option<String>,
448    pub git_ref: Option<String>,
449    pub git_sha: Option<String>,
450    pub receipt: RunReceipt,
451    pub metadata: BTreeMap<String, String>,
452    pub tags: Vec<String>,
453    pub normalize: bool,
454}
455
456/// Response for successful baseline upload.
457#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
458pub struct UploadBaselineResponse {
459    pub id: String,
460    pub benchmark: String,
461    pub version: String,
462    pub created_at: DateTime<Utc>,
463    pub etag: String,
464}
465
466/// Request for baseline promotion.
467#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
468pub struct PromoteBaselineRequest {
469    pub from_version: String,
470    pub to_version: String,
471    pub git_ref: Option<String>,
472    pub git_sha: Option<String>,
473    pub tags: Vec<String>,
474    #[serde(default)]
475    pub normalize: bool,
476}
477
478/// Response for baseline promotion.
479#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
480pub struct PromoteBaselineResponse {
481    pub id: String,
482    pub benchmark: String,
483    pub version: String,
484    pub promoted_from: String,
485    pub promoted_at: DateTime<Utc>,
486    pub created_at: DateTime<Utc>,
487}
488
489/// Response for baseline deletion.
490#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
491pub struct DeleteBaselineResponse {
492    pub deleted: bool,
493    pub id: String,
494    pub benchmark: String,
495    pub version: String,
496    pub deleted_at: DateTime<Utc>,
497}
498
499// =========================================================================
500// Audit logging types
501// =========================================================================
502
503/// The action that was performed in an audit event.
504#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
505#[serde(rename_all = "snake_case")]
506pub enum AuditAction {
507    /// A resource was created (e.g., baseline upload)
508    Create,
509    /// A resource was updated
510    Update,
511    /// A resource was deleted (soft or hard)
512    Delete,
513    /// A baseline was promoted
514    Promote,
515}
516
517impl std::fmt::Display for AuditAction {
518    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519        match self {
520            AuditAction::Create => write!(f, "create"),
521            AuditAction::Update => write!(f, "update"),
522            AuditAction::Delete => write!(f, "delete"),
523            AuditAction::Promote => write!(f, "promote"),
524        }
525    }
526}
527
528impl std::str::FromStr for AuditAction {
529    type Err = String;
530
531    fn from_str(s: &str) -> Result<Self, Self::Err> {
532        match s {
533            "create" => Ok(AuditAction::Create),
534            "update" => Ok(AuditAction::Update),
535            "delete" => Ok(AuditAction::Delete),
536            "promote" => Ok(AuditAction::Promote),
537            other => Err(format!("Unknown audit action: {}", other)),
538        }
539    }
540}
541
542/// The type of resource affected by an audit event.
543#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
544#[serde(rename_all = "snake_case")]
545pub enum AuditResourceType {
546    /// A baseline record
547    Baseline,
548    /// An API key
549    Key,
550    /// A verdict record
551    Verdict,
552}
553
554impl std::fmt::Display for AuditResourceType {
555    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
556        match self {
557            AuditResourceType::Baseline => write!(f, "baseline"),
558            AuditResourceType::Key => write!(f, "key"),
559            AuditResourceType::Verdict => write!(f, "verdict"),
560        }
561    }
562}
563
564impl std::str::FromStr for AuditResourceType {
565    type Err = String;
566
567    fn from_str(s: &str) -> Result<Self, Self::Err> {
568        match s {
569            "baseline" => Ok(AuditResourceType::Baseline),
570            "key" => Ok(AuditResourceType::Key),
571            "verdict" => Ok(AuditResourceType::Verdict),
572            other => Err(format!("Unknown resource type: {}", other)),
573        }
574    }
575}
576
577/// An append-only audit event for tracking mutations and admin actions.
578#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
579pub struct AuditEvent {
580    /// Unique event identifier
581    pub id: String,
582    /// Timestamp of the event (RFC 3339)
583    pub timestamp: DateTime<Utc>,
584    /// Actor identity (API key ID or OIDC subject)
585    pub actor: String,
586    /// The action performed
587    pub action: AuditAction,
588    /// Type of resource affected
589    pub resource_type: AuditResourceType,
590    /// Identifier for the affected resource
591    pub resource_id: String,
592    /// Project scope
593    pub project: String,
594    /// Additional structured metadata (endpoint-specific details)
595    #[serde(default)]
596    pub metadata: serde_json::Value,
597}
598
599/// Query parameters for listing audit events.
600#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
601pub struct ListAuditEventsQuery {
602    /// Filter by project
603    pub project: Option<String>,
604    /// Filter by action
605    pub action: Option<String>,
606    /// Filter by resource type
607    pub resource_type: Option<String>,
608    /// Filter by actor
609    pub actor: Option<String>,
610    /// Filter by events after this time
611    pub since: Option<DateTime<Utc>>,
612    /// Filter by events before this time
613    pub until: Option<DateTime<Utc>>,
614    /// Pagination limit
615    #[serde(default = "default_limit")]
616    pub limit: u32,
617    /// Pagination offset
618    #[serde(default)]
619    pub offset: u64,
620}
621
622impl Default for ListAuditEventsQuery {
623    fn default() -> Self {
624        Self {
625            project: None,
626            action: None,
627            resource_type: None,
628            actor: None,
629            since: None,
630            until: None,
631            limit: default_limit(),
632            offset: 0,
633        }
634    }
635}
636
637/// Response for audit event list operation.
638#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
639pub struct ListAuditEventsResponse {
640    /// The audit events matching the query
641    pub events: Vec<AuditEvent>,
642    /// Pagination metadata
643    pub pagination: PaginationInfo,
644}
645
646/// Health status of a storage backend.
647#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
648pub struct StorageHealth {
649    pub backend: String,
650    pub status: String,
651}
652
653/// Connection pool metrics exposed via the health endpoint.
654#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
655pub struct PoolMetrics {
656    /// Number of idle connections in the pool.
657    pub idle: u32,
658    /// Number of active (in-use) connections.
659    pub active: u32,
660    /// Maximum number of connections the pool is configured for.
661    pub max: u32,
662}
663
664/// Response for health check.
665#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
666pub struct HealthResponse {
667    pub status: String,
668    pub version: String,
669    pub storage: StorageHealth,
670    /// Connection pool metrics (present only for pooled backends such as PostgreSQL).
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub pool: Option<PoolMetrics>,
673}
674
675/// Generic error response for the API.
676#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
677pub struct ApiError {
678    pub code: String,
679    pub message: String,
680    #[serde(skip_serializing_if = "Option::is_none")]
681    pub details: Option<serde_json::Value>,
682}
683
684impl ApiError {
685    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
686        Self {
687            code: code.into(),
688            message: message.into(),
689            details: None,
690        }
691    }
692    pub fn unauthorized(msg: &str) -> Self {
693        Self::new("unauthorized", msg)
694    }
695    pub fn forbidden(msg: &str) -> Self {
696        Self::new("forbidden", msg)
697    }
698    pub fn not_found(msg: &str) -> Self {
699        Self::new("not_found", msg)
700    }
701    pub fn bad_request(msg: &str) -> Self {
702        Self::new("bad_request", msg)
703    }
704    pub fn conflict(msg: &str) -> Self {
705        Self::new("conflict", msg)
706    }
707    pub fn internal_error(msg: &str) -> Self {
708        Self::new("internal_error", msg)
709    }
710    pub fn internal(msg: &str) -> Self {
711        Self::internal_error(msg)
712    }
713    pub fn validation(msg: &str) -> Self {
714        Self::new("invalid_input", msg)
715    }
716    pub fn already_exists(msg: &str) -> Self {
717        Self::new("conflict", msg)
718    }
719}
720
721// ── API Key Management Types ──────────────────────────────────────────
722
723/// Request for creating a new API key.
724#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
725pub struct CreateKeyRequest {
726    /// Human-readable description
727    pub description: String,
728    /// Role to assign (viewer, contributor, promoter, admin)
729    pub role: perfgate_auth::Role,
730    /// Project this key is scoped to (use "*" for all projects)
731    pub project: String,
732    /// Optional glob pattern to restrict benchmark access
733    #[serde(skip_serializing_if = "Option::is_none")]
734    pub pattern: Option<String>,
735    /// Optional expiration timestamp
736    #[serde(skip_serializing_if = "Option::is_none")]
737    pub expires_at: Option<DateTime<Utc>>,
738}
739
740/// Response for creating a new API key (contains the plaintext key once).
741#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
742pub struct CreateKeyResponse {
743    /// Unique key identifier (for management)
744    pub id: String,
745    /// The plaintext API key (only returned once)
746    pub key: String,
747    /// Human-readable description
748    pub description: String,
749    /// Assigned role
750    pub role: perfgate_auth::Role,
751    /// Scoped project
752    pub project: String,
753    /// Optional benchmark pattern
754    #[serde(skip_serializing_if = "Option::is_none")]
755    pub pattern: Option<String>,
756    /// Creation timestamp
757    pub created_at: DateTime<Utc>,
758    /// Expiration timestamp
759    #[serde(skip_serializing_if = "Option::is_none")]
760    pub expires_at: Option<DateTime<Utc>>,
761}
762
763/// A redacted API key entry returned by list operations.
764#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
765pub struct KeyEntry {
766    /// Unique key identifier
767    pub id: String,
768    /// Redacted key prefix (e.g., "pg_live_abc1...***")
769    pub key_prefix: String,
770    /// Human-readable description
771    pub description: String,
772    /// Assigned role
773    pub role: perfgate_auth::Role,
774    /// Scoped project
775    pub project: String,
776    /// Optional benchmark pattern
777    #[serde(skip_serializing_if = "Option::is_none")]
778    pub pattern: Option<String>,
779    /// Creation timestamp
780    pub created_at: DateTime<Utc>,
781    /// Expiration timestamp
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub expires_at: Option<DateTime<Utc>>,
784    /// Revocation timestamp (if revoked)
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub revoked_at: Option<DateTime<Utc>>,
787}
788
789/// Response for listing API keys.
790#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
791pub struct ListKeysResponse {
792    /// List of key entries (redacted)
793    pub keys: Vec<KeyEntry>,
794}
795
796/// Response for revoking an API key.
797#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
798pub struct RevokeKeyResponse {
799    /// The key ID that was revoked
800    pub id: String,
801    /// When the key was revoked
802    pub revoked_at: DateTime<Utc>,
803}
804
805// ---------------------------------------------------------------------------
806// Fleet-wide dependency regression detection types
807// ---------------------------------------------------------------------------
808
809/// Schema identifier for dependency event records.
810pub const DEPENDENCY_EVENT_SCHEMA_V1: &str = "perfgate.dependency_event.v1";
811
812/// Schema identifier for fleet alert records.
813pub const FLEET_ALERT_SCHEMA_V1: &str = "perfgate.fleet_alert.v1";
814
815/// A single dependency version change observed alongside a benchmark run.
816#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
817pub struct DependencyChange {
818    /// Dependency name (e.g., crate name)
819    pub name: String,
820    /// Previous version (None if newly added)
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub old_version: Option<String>,
823    /// New version (None if removed)
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub new_version: Option<String>,
826}
827
828/// A recorded dependency change event with its performance impact.
829#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
830pub struct DependencyEvent {
831    /// Schema identifier
832    pub schema: String,
833    /// Unique event identifier
834    pub id: String,
835    /// Project that reported the event
836    pub project: String,
837    /// Benchmark name
838    pub benchmark: String,
839    /// Dependency name
840    pub dep_name: String,
841    /// Previous version
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub old_version: Option<String>,
844    /// New version
845    #[serde(skip_serializing_if = "Option::is_none")]
846    pub new_version: Option<String>,
847    /// Primary metric name (e.g., "wall_ms")
848    pub metric: String,
849    /// Percentage change in that metric (positive = regression)
850    pub delta_pct: f64,
851    /// Timestamp of the event
852    pub created_at: DateTime<Utc>,
853}
854
855/// Request to record a dependency change event.
856#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
857pub struct RecordDependencyEventRequest {
858    /// Project that observed the event
859    pub project: String,
860    /// Benchmark name
861    pub benchmark: String,
862    /// List of dependency changes observed
863    pub dependency_changes: Vec<DependencyChange>,
864    /// Primary metric name
865    pub metric: String,
866    /// Percentage change in the metric (positive = regression)
867    pub delta_pct: f64,
868}
869
870/// Response after recording dependency events.
871#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
872pub struct RecordDependencyEventResponse {
873    /// Number of events recorded
874    pub recorded: usize,
875}
876
877/// A project affected by a fleet-wide dependency regression.
878#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
879pub struct AffectedProject {
880    /// Project identifier
881    pub project: String,
882    /// Benchmark name
883    pub benchmark: String,
884    /// Primary metric name
885    pub metric: String,
886    /// Percentage change
887    pub delta_pct: f64,
888}
889
890/// A fleet-wide alert: multiple projects regressed after the same dependency update.
891#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
892pub struct FleetAlert {
893    /// Schema identifier
894    pub schema: String,
895    /// Unique alert identifier
896    pub id: String,
897    /// Dependency name
898    pub dependency: String,
899    /// Previous version
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub old_version: Option<String>,
902    /// New version
903    #[serde(skip_serializing_if = "Option::is_none")]
904    pub new_version: Option<String>,
905    /// Projects affected by this dependency change
906    pub affected_projects: Vec<AffectedProject>,
907    /// Confidence score (0.0 - 1.0): higher means more projects affected
908    pub confidence: f64,
909    /// Average delta percentage across affected projects
910    pub avg_delta_pct: f64,
911    /// When the alert was first detected
912    pub first_seen: DateTime<Utc>,
913}
914
915/// Query parameters for listing fleet alerts.
916#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
917pub struct ListFleetAlertsQuery {
918    /// Minimum number of affected projects to include
919    #[serde(default = "default_min_affected")]
920    pub min_affected: usize,
921    /// Only include alerts since this time
922    pub since: Option<DateTime<Utc>>,
923    /// Pagination limit
924    #[serde(default = "default_limit")]
925    pub limit: u32,
926}
927
928impl Default for ListFleetAlertsQuery {
929    fn default() -> Self {
930        Self {
931            min_affected: default_min_affected(),
932            since: None,
933            limit: default_limit(),
934        }
935    }
936}
937
938fn default_min_affected() -> usize {
939    2
940}
941
942/// Response for listing fleet alerts.
943#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
944pub struct ListFleetAlertsResponse {
945    pub alerts: Vec<FleetAlert>,
946}
947
948/// Query parameters for dependency impact lookup.
949#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
950pub struct DependencyImpactQuery {
951    /// Only include events since this time
952    pub since: Option<DateTime<Utc>>,
953    /// Pagination limit
954    #[serde(default = "default_limit")]
955    pub limit: u32,
956}
957
958impl Default for DependencyImpactQuery {
959    fn default() -> Self {
960        Self {
961            since: None,
962            limit: default_limit(),
963        }
964    }
965}
966
967/// Response for dependency impact lookup.
968#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
969pub struct DependencyImpactResponse {
970    /// Dependency name
971    pub dependency: String,
972    /// All recorded events for this dependency
973    pub events: Vec<DependencyEvent>,
974    /// Number of distinct projects affected
975    pub project_count: usize,
976    /// Average delta percentage
977    pub avg_delta_pct: f64,
978}
979
980#[cfg(feature = "server")]
981impl axum::response::IntoResponse for ApiError {
982    fn into_response(self) -> axum::response::Response {
983        let status = match self.code.as_str() {
984            "bad_request" | "invalid_input" => http::StatusCode::BAD_REQUEST,
985            "unauthorized" => http::StatusCode::UNAUTHORIZED,
986            "forbidden" => http::StatusCode::FORBIDDEN,
987            "not_found" => http::StatusCode::NOT_FOUND,
988            "conflict" => http::StatusCode::CONFLICT,
989            _ => http::StatusCode::INTERNAL_SERVER_ERROR,
990        };
991        (status, axum::Json(self)).into_response()
992    }
993}