Skip to main content

perfgate_types/
baseline_service.rs

1//! Baseline service wire contracts.
2//!
3//! Defines request/response types, baseline records, project models, and verdict
4//! history used by both the server and client crates.
5//!
6//! These request, response, record, project, audit, and verdict types are shared
7//! by `perfgate-client` and `perfgate-server`.
8//!
9//! # Example
10//!
11//! ```
12//! use perfgate_types::baseline_service::BASELINE_SCHEMA_V1;
13//!
14//! assert_eq!(BASELINE_SCHEMA_V1, "perfgate.baseline.v1");
15//! ```
16
17pub 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
28/// Schema identifier for baseline records.
29pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
30
31/// Schema identifier for project records.
32pub const PROJECT_SCHEMA_V1: &str = "perfgate.project.v1";
33
34/// Schema identifier for verdict records.
35pub const VERDICT_SCHEMA_V1: &str = "perfgate.verdict.v1";
36
37/// Schema identifier for decision ledger records.
38pub const DECISION_RECORD_SCHEMA_V1: &str = "perfgate.decision_record.v1";
39
40/// Schema identifier for audit event records.
41pub const AUDIT_SCHEMA_V1: &str = "perfgate.audit.v1";
42
43/// Schema identifier for health response fixtures.
44pub const HEALTH_SCHEMA_V1: &str = "perfgate.health.v1";
45
46/// Source of baseline creation.
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum BaselineSource {
50    /// Uploaded directly via API
51    #[default]
52    Upload,
53    /// Created via promote operation
54    Promote,
55    /// Migrated from external storage
56    Migrate,
57    /// Created via rollback operation
58    Rollback,
59}
60
61/// The primary storage model for baselines.
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
63pub struct BaselineRecord {
64    /// Schema identifier (perfgate.baseline.v1)
65    pub schema: String,
66    /// Unique baseline identifier (ULID format)
67    pub id: String,
68    /// Project/namespace identifier
69    pub project: String,
70    /// Benchmark name
71    pub benchmark: String,
72    /// Semantic version
73    pub version: String,
74    /// Git reference
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub git_ref: Option<String>,
77    /// Git commit SHA
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub git_sha: Option<String>,
80    /// Full run receipt
81    pub receipt: RunReceipt,
82    /// User-provided metadata
83    #[serde(default)]
84    pub metadata: BTreeMap<String, String>,
85    /// Tags for filtering
86    #[serde(default)]
87    pub tags: Vec<String>,
88    /// Creation timestamp (RFC 3339)
89    pub created_at: DateTime<Utc>,
90    /// Last modification timestamp
91    pub updated_at: DateTime<Utc>,
92    /// Content hash for ETag
93    pub content_hash: String,
94    /// Creation source
95    pub source: BaselineSource,
96    /// Soft delete flag
97    #[serde(default)]
98    pub deleted: bool,
99}
100
101impl BaselineRecord {
102    /// Returns the ETag value for this baseline.
103    pub fn etag(&self) -> String {
104        format!("\"sha256:{}\"", self.content_hash)
105    }
106}
107
108/// A record of a benchmark execution verdict.
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
110pub struct VerdictRecord {
111    /// Schema identifier (perfgate.verdict.v1)
112    pub schema: String,
113    /// Unique verdict identifier
114    pub id: String,
115    /// Project identifier
116    pub project: String,
117    /// Benchmark name
118    pub benchmark: String,
119    /// Run identifier from receipt
120    pub run_id: String,
121    /// Overall status (pass/warn/fail/skip)
122    pub status: VerdictStatus,
123    /// Detailed counts
124    pub counts: VerdictCounts,
125    /// List of reasons for the verdict
126    pub reasons: Vec<String>,
127    /// Git reference
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub git_ref: Option<String>,
130    /// Git commit SHA
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub git_sha: Option<String>,
133    /// Coefficient of variation for benchmark wall time in this verdict.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub wall_ms_cv: Option<f64>,
136    /// Historical flakiness score derived from recent wall-time CV samples.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub flakiness_score: Option<f64>,
139    /// Creation timestamp
140    pub created_at: DateTime<Utc>,
141}
142
143/// Request for submitting a verdict.
144#[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/// Request for verdict list operation.
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
159pub struct ListVerdictsQuery {
160    /// Filter by exact benchmark name
161    pub benchmark: Option<String>,
162    /// Filter by status
163    pub status: Option<VerdictStatus>,
164    /// Filter by creation date (after)
165    pub since: Option<DateTime<Utc>>,
166    /// Filter by creation date (before)
167    pub until: Option<DateTime<Utc>>,
168    /// Pagination limit
169    #[serde(default = "default_limit")]
170    pub limit: u32,
171    /// Pagination offset
172    #[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/// Response for verdict list operation.
212#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
213pub struct ListVerdictsResponse {
214    pub verdicts: Vec<VerdictRecord>,
215    pub pagination: PaginationInfo,
216}
217
218/// A stored performance decision receipt for the server-side decision ledger.
219#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
220pub struct DecisionRecord {
221    /// Schema identifier (perfgate.decision_record.v1)
222    pub schema: String,
223    /// Unique decision identifier.
224    pub id: String,
225    /// Project identifier.
226    pub project: String,
227    /// Scenario/workload name, when present in the tradeoff receipt.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub scenario: Option<String>,
230    /// Final decision metric status after tradeoff policy.
231    pub status: MetricStatus,
232    /// Final policy verdict exposed by the tradeoff receipt.
233    pub verdict: VerdictStatus,
234    /// Accepted tradeoff rule names.
235    #[serde(default)]
236    pub accepted_rules: Vec<String>,
237    /// Whether a human review is required before treating the decision as accepted.
238    #[serde(default)]
239    pub review_required: bool,
240    /// Reasons the decision needs review.
241    #[serde(default)]
242    pub review_reasons: Vec<String>,
243    /// Git reference.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub git_ref: Option<String>,
246    /// Git commit SHA.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub git_sha: Option<String>,
249    /// Optional scenario receipt captured with the decision.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub scenario_receipt: Option<ScenarioReceipt>,
252    /// Tradeoff decision receipt.
253    pub tradeoff_receipt: TradeoffReceipt,
254    /// Optional artifact index for the decision evidence set.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub artifact_index: Option<DecisionArtifactIndex>,
257    /// Creation timestamp.
258    pub created_at: DateTime<Utc>,
259}
260
261/// Request for uploading a performance decision.
262#[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/// Query parameters for listing decision records.
274#[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/// Response for decision list operation.
342#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
343pub struct ListDecisionsResponse {
344    pub decisions: Vec<DecisionRecord>,
345    pub pagination: PaginationInfo,
346}
347
348/// Request for pruning old performance decision records.
349#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
350pub struct PruneDecisionsRequest {
351    /// Delete records created before this timestamp.
352    pub older_than: DateTime<Utc>,
353    /// When true, report matching records without deleting them.
354    #[serde(default)]
355    pub dry_run: bool,
356}
357
358/// Response for a decision prune operation.
359#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
360pub struct PruneDecisionsResponse {
361    /// Project identifier.
362    pub project: String,
363    /// Records older than this timestamp were matched.
364    pub older_than: DateTime<Utc>,
365    /// Whether the operation was a dry run.
366    pub dry_run: bool,
367    /// Number of records matched by the retention cutoff.
368    pub matched: u64,
369    /// Number of records deleted. Always zero for dry runs.
370    pub deleted: u64,
371    /// Decision record ids matched by the retention cutoff.
372    #[serde(default)]
373    pub decision_ids: Vec<String>,
374}
375
376/// Version history metadata (without full receipt).
377#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
378pub struct BaselineVersion {
379    /// Version identifier
380    pub version: String,
381    /// Git reference
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub git_ref: Option<String>,
384    /// Git commit SHA
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub git_sha: Option<String>,
387    /// Creation timestamp
388    pub created_at: DateTime<Utc>,
389    /// Creator identifier
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub created_by: Option<String>,
392    /// Whether this is the current/promoted version
393    pub is_current: bool,
394    /// Source of this version
395    pub source: BaselineSource,
396}
397
398/// Retention policy for a project.
399#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
400pub struct RetentionPolicy {
401    /// Maximum number of versions to keep per benchmark.
402    pub max_versions: Option<u32>,
403    /// Maximum age of a version in days.
404    pub max_age_days: Option<u32>,
405    /// Tags that prevent a version from being deleted.
406    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/// Strategy for auto-generating versions.
420#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
421#[serde(rename_all = "snake_case")]
422pub enum VersioningStrategy {
423    /// Use run_id from receipt as version
424    #[default]
425    RunId,
426    /// Use timestamp as version
427    Timestamp,
428    /// Use git_sha as version
429    GitSha,
430    /// Manual version required
431    Manual,
432}
433
434/// Multi-tenancy namespace with retention policies.
435#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
436pub struct Project {
437    /// Schema identifier (perfgate.project.v1)
438    pub schema: String,
439    /// Project identifier (URL-safe)
440    pub id: String,
441    /// Display name
442    pub name: String,
443    /// Project description
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub description: Option<String>,
446    /// Creation timestamp
447    pub created_at: DateTime<Utc>,
448    /// Retention policy
449    pub retention: RetentionPolicy,
450    /// Default baseline versioning strategy
451    pub versioning: VersioningStrategy,
452}
453
454/// Request for baseline list operation.
455#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
456pub struct ListBaselinesQuery {
457    /// Filter by exact benchmark name
458    pub benchmark: Option<String>,
459    /// Filter by benchmark name prefix
460    pub benchmark_prefix: Option<String>,
461    /// Filter by git reference
462    pub git_ref: Option<String>,
463    /// Filter by git SHA
464    pub git_sha: Option<String>,
465    /// Filter by tags (comma-separated)
466    pub tags: Option<String>,
467    /// Filter by creation date (after)
468    pub since: Option<DateTime<Utc>>,
469    /// Filter by creation date (before)
470    pub until: Option<DateTime<Utc>>,
471    /// Include full receipts in output
472    #[serde(default)]
473    pub include_receipt: bool,
474    /// Pagination limit
475    #[serde(default = "default_limit")]
476    pub limit: u32,
477    /// Pagination offset
478    #[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/// Pagination information for lists.
571#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
572pub struct PaginationInfo {
573    /// Total count of items (if known)
574    pub total: u64,
575    /// Offset of current page
576    pub offset: u64,
577    /// Limit of items per page
578    pub limit: u32,
579    /// Whether more items are available
580    pub has_more: bool,
581}
582
583/// Response for baseline list operation.
584#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
585pub struct ListBaselinesResponse {
586    /// List of baseline summaries or records
587    pub baselines: Vec<BaselineSummary>,
588    /// Pagination metadata
589    pub pagination: PaginationInfo,
590}
591
592/// Summary of a baseline record (without full receipt).
593#[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/// Request for baseline upload.
622#[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/// Response for successful baseline upload.
635#[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/// Request for baseline promotion.
645#[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/// Response for baseline promotion.
657#[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/// Response for baseline deletion.
668#[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// =========================================================================
678// Audit logging types
679// =========================================================================
680
681/// The action that was performed in an audit event.
682#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
683#[serde(rename_all = "snake_case")]
684pub enum AuditAction {
685    /// A resource was created (e.g., baseline upload)
686    Create,
687    /// A resource was updated
688    Update,
689    /// A resource was deleted (soft or hard)
690    Delete,
691    /// A baseline was promoted
692    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/// The type of resource affected by an audit event.
721#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
722#[serde(rename_all = "snake_case")]
723pub enum AuditResourceType {
724    /// A baseline record
725    Baseline,
726    /// An API key
727    Key,
728    /// A verdict record
729    Verdict,
730    /// A performance decision record
731    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/// An append-only audit event for tracking mutations and admin actions.
760#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
761pub struct AuditEvent {
762    /// Unique event identifier
763    pub id: String,
764    /// Timestamp of the event (RFC 3339)
765    pub timestamp: DateTime<Utc>,
766    /// Actor identity (API key ID or OIDC subject)
767    pub actor: String,
768    /// The action performed
769    pub action: AuditAction,
770    /// Type of resource affected
771    pub resource_type: AuditResourceType,
772    /// Identifier for the affected resource
773    pub resource_id: String,
774    /// Project scope
775    pub project: String,
776    /// Additional structured metadata (endpoint-specific details)
777    #[serde(default)]
778    pub metadata: serde_json::Value,
779}
780
781/// Query parameters for listing audit events.
782#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
783pub struct ListAuditEventsQuery {
784    /// Filter by project
785    pub project: Option<String>,
786    /// Filter by action
787    pub action: Option<String>,
788    /// Filter by resource type
789    pub resource_type: Option<String>,
790    /// Filter by actor
791    pub actor: Option<String>,
792    /// Filter by events after this time
793    pub since: Option<DateTime<Utc>>,
794    /// Filter by events before this time
795    pub until: Option<DateTime<Utc>>,
796    /// Pagination limit
797    #[serde(default = "default_limit")]
798    pub limit: u32,
799    /// Pagination offset
800    #[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/// Response for audit event list operation.
820#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
821pub struct ListAuditEventsResponse {
822    /// The audit events matching the query
823    pub events: Vec<AuditEvent>,
824    /// Pagination metadata
825    pub pagination: PaginationInfo,
826}
827
828/// Health status of a storage backend.
829#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
830pub struct StorageHealth {
831    pub backend: String,
832    pub status: String,
833    /// Coarse, sanitized failure detail when the backend is unhealthy.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub detail: Option<String>,
836}
837
838/// Connection pool metrics exposed via the health endpoint.
839#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
840pub struct PoolMetrics {
841    /// Number of idle connections in the pool.
842    pub idle: u32,
843    /// Number of active (in-use) connections.
844    pub active: u32,
845    /// Maximum number of connections the pool is configured for.
846    pub max: u32,
847}
848
849/// Response for health check.
850#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
851pub struct HealthResponse {
852    pub status: String,
853    pub version: String,
854    pub storage: StorageHealth,
855    /// Connection pool metrics (present only for pooled backends such as PostgreSQL).
856    #[serde(skip_serializing_if = "Option::is_none")]
857    pub pool: Option<PoolMetrics>,
858}
859
860/// Generic error response for the API.
861#[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// ── API Key Management Types ──────────────────────────────────────────
907
908/// Request for creating a new API key.
909#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
910pub struct CreateKeyRequest {
911    /// Human-readable description
912    pub description: String,
913    /// Role to assign (viewer, contributor, promoter, admin)
914    pub role: auth::Role,
915    /// Project this key is scoped to (use "*" for all projects)
916    pub project: String,
917    /// Optional glob pattern to restrict benchmark access
918    #[serde(skip_serializing_if = "Option::is_none")]
919    pub pattern: Option<String>,
920    /// Optional expiration timestamp
921    #[serde(skip_serializing_if = "Option::is_none")]
922    pub expires_at: Option<DateTime<Utc>>,
923}
924
925/// Response for creating a new API key (contains the plaintext key once).
926#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
927pub struct CreateKeyResponse {
928    /// Unique key identifier (for management)
929    pub id: String,
930    /// The plaintext API key (only returned once)
931    pub key: String,
932    /// Human-readable description
933    pub description: String,
934    /// Assigned role
935    pub role: auth::Role,
936    /// Scoped project
937    pub project: String,
938    /// Optional benchmark pattern
939    #[serde(skip_serializing_if = "Option::is_none")]
940    pub pattern: Option<String>,
941    /// Creation timestamp
942    pub created_at: DateTime<Utc>,
943    /// Expiration timestamp
944    #[serde(skip_serializing_if = "Option::is_none")]
945    pub expires_at: Option<DateTime<Utc>>,
946}
947
948/// A redacted API key entry returned by list operations.
949#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
950pub struct KeyEntry {
951    /// Unique key identifier
952    pub id: String,
953    /// Redacted key prefix (e.g., "pg_live_abc1...***")
954    pub key_prefix: String,
955    /// Human-readable description
956    pub description: String,
957    /// Assigned role
958    pub role: auth::Role,
959    /// Scoped project
960    pub project: String,
961    /// Optional benchmark pattern
962    #[serde(skip_serializing_if = "Option::is_none")]
963    pub pattern: Option<String>,
964    /// Creation timestamp
965    pub created_at: DateTime<Utc>,
966    /// Expiration timestamp
967    #[serde(skip_serializing_if = "Option::is_none")]
968    pub expires_at: Option<DateTime<Utc>>,
969    /// Revocation timestamp (if revoked)
970    #[serde(skip_serializing_if = "Option::is_none")]
971    pub revoked_at: Option<DateTime<Utc>>,
972}
973
974/// Response for listing API keys.
975#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
976pub struct ListKeysResponse {
977    /// List of key entries (redacted)
978    pub keys: Vec<KeyEntry>,
979}
980
981/// Response for revoking an API key.
982#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
983pub struct RevokeKeyResponse {
984    /// The key ID that was revoked
985    pub id: String,
986    /// When the key was revoked
987    pub revoked_at: DateTime<Utc>,
988}
989
990// ---------------------------------------------------------------------------
991// Fleet-wide dependency regression detection types
992// ---------------------------------------------------------------------------
993
994/// Schema identifier for dependency event records.
995pub const DEPENDENCY_EVENT_SCHEMA_V1: &str = "perfgate.dependency_event.v1";
996
997/// Schema identifier for fleet alert records.
998pub const FLEET_ALERT_SCHEMA_V1: &str = "perfgate.fleet_alert.v1";
999
1000/// A single dependency version change observed alongside a benchmark run.
1001#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1002pub struct DependencyChange {
1003    /// Dependency name (e.g., crate name)
1004    pub name: String,
1005    /// Previous version (None if newly added)
1006    #[serde(skip_serializing_if = "Option::is_none")]
1007    pub old_version: Option<String>,
1008    /// New version (None if removed)
1009    #[serde(skip_serializing_if = "Option::is_none")]
1010    pub new_version: Option<String>,
1011}
1012
1013/// A recorded dependency change event with its performance impact.
1014#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1015pub struct DependencyEvent {
1016    /// Schema identifier
1017    pub schema: String,
1018    /// Unique event identifier
1019    pub id: String,
1020    /// Project that reported the event
1021    pub project: String,
1022    /// Benchmark name
1023    pub benchmark: String,
1024    /// Dependency name
1025    pub dep_name: String,
1026    /// Previous version
1027    #[serde(skip_serializing_if = "Option::is_none")]
1028    pub old_version: Option<String>,
1029    /// New version
1030    #[serde(skip_serializing_if = "Option::is_none")]
1031    pub new_version: Option<String>,
1032    /// Primary metric name (e.g., "wall_ms")
1033    pub metric: String,
1034    /// Percentage change in that metric (positive = regression)
1035    pub delta_pct: f64,
1036    /// Timestamp of the event
1037    pub created_at: DateTime<Utc>,
1038}
1039
1040/// Request to record a dependency change event.
1041#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1042pub struct RecordDependencyEventRequest {
1043    /// Project that observed the event
1044    pub project: String,
1045    /// Benchmark name
1046    pub benchmark: String,
1047    /// List of dependency changes observed
1048    pub dependency_changes: Vec<DependencyChange>,
1049    /// Primary metric name
1050    pub metric: String,
1051    /// Percentage change in the metric (positive = regression)
1052    pub delta_pct: f64,
1053}
1054
1055/// Response after recording dependency events.
1056#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1057pub struct RecordDependencyEventResponse {
1058    /// Number of events recorded
1059    pub recorded: usize,
1060}
1061
1062/// A project affected by a fleet-wide dependency regression.
1063#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1064pub struct AffectedProject {
1065    /// Project identifier
1066    pub project: String,
1067    /// Benchmark name
1068    pub benchmark: String,
1069    /// Primary metric name
1070    pub metric: String,
1071    /// Percentage change
1072    pub delta_pct: f64,
1073}
1074
1075/// A fleet-wide alert: multiple projects regressed after the same dependency update.
1076#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1077pub struct FleetAlert {
1078    /// Schema identifier
1079    pub schema: String,
1080    /// Unique alert identifier
1081    pub id: String,
1082    /// Dependency name
1083    pub dependency: String,
1084    /// Previous version
1085    #[serde(skip_serializing_if = "Option::is_none")]
1086    pub old_version: Option<String>,
1087    /// New version
1088    #[serde(skip_serializing_if = "Option::is_none")]
1089    pub new_version: Option<String>,
1090    /// Projects affected by this dependency change
1091    pub affected_projects: Vec<AffectedProject>,
1092    /// Confidence score (0.0 - 1.0): higher means more projects affected
1093    pub confidence: f64,
1094    /// Average delta percentage across affected projects
1095    pub avg_delta_pct: f64,
1096    /// When the alert was first detected
1097    pub first_seen: DateTime<Utc>,
1098}
1099
1100/// Query parameters for listing fleet alerts.
1101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1102pub struct ListFleetAlertsQuery {
1103    /// Minimum number of affected projects to include
1104    #[serde(default = "default_min_affected")]
1105    pub min_affected: usize,
1106    /// Only include alerts since this time
1107    pub since: Option<DateTime<Utc>>,
1108    /// Pagination limit
1109    #[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/// Response for listing fleet alerts.
1128#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1129pub struct ListFleetAlertsResponse {
1130    pub alerts: Vec<FleetAlert>,
1131}
1132
1133/// Query parameters for dependency impact lookup.
1134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1135pub struct DependencyImpactQuery {
1136    /// Only include events since this time
1137    pub since: Option<DateTime<Utc>>,
1138    /// Pagination limit
1139    #[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/// Response for dependency impact lookup.
1153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1154pub struct DependencyImpactResponse {
1155    /// Dependency name
1156    pub dependency: String,
1157    /// All recorded events for this dependency
1158    pub events: Vec<DependencyEvent>,
1159    /// Number of distinct projects affected
1160    pub project_count: usize,
1161    /// Average delta percentage
1162    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}