Skip to main content

ironflow_api/entities/
artifact.rs

1//! Artifact DTO -- public API representation of a file produced by a step.
2
3use chrono::{DateTime, Utc};
4use ironflow_store::models::Artifact;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// An artifact as exposed by the REST API.
9///
10/// The storage key is deliberately absent: it is internal plumbing, and callers
11/// address an artifact by `(run, step, name)` through the download route.
12///
13/// # Examples
14///
15/// ```
16/// use chrono::Utc;
17/// use ironflow_api::entities::ArtifactResponse;
18/// use uuid::Uuid;
19///
20/// let response = ArtifactResponse {
21///     id: Uuid::now_v7(),
22///     step_id: Uuid::now_v7(),
23///     name: "report.html".to_string(),
24///     content_type: "text/html".to_string(),
25///     size_bytes: 142,
26///     sha256: "0".repeat(64),
27///     created_at: Utc::now(),
28/// };
29/// assert_eq!(response.name, "report.html");
30/// ```
31#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ArtifactResponse {
34    /// Unique artifact identifier.
35    pub id: Uuid,
36    /// The step that produced it.
37    pub step_id: Uuid,
38    /// File name, unique within the step.
39    pub name: String,
40    /// MIME type served on download.
41    pub content_type: String,
42    /// Size of the content, in bytes.
43    pub size_bytes: u64,
44    /// Lowercase hex SHA-256 of the content.
45    pub sha256: String,
46    /// When the artifact was recorded.
47    pub created_at: DateTime<Utc>,
48}
49
50impl From<Artifact> for ArtifactResponse {
51    fn from(artifact: Artifact) -> Self {
52        Self {
53            id: artifact.id,
54            step_id: artifact.step_id,
55            name: artifact.name,
56            content_type: artifact.content_type,
57            size_bytes: artifact.size_bytes,
58            sha256: artifact.sha256,
59            created_at: artifact.created_at,
60        }
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn sample() -> Artifact {
69        Artifact {
70            id: Uuid::now_v7(),
71            run_id: Uuid::now_v7(),
72            step_id: Uuid::now_v7(),
73            name: "report.html".to_string(),
74            storage_key: "artifacts/secret/path/uuid".to_string(),
75            content_type: "text/html".to_string(),
76            size_bytes: 142,
77            sha256: "0".repeat(64),
78            created_at: Utc::now(),
79            updated_at: Utc::now(),
80        }
81    }
82
83    #[test]
84    fn conversion_keeps_the_caller_facing_fields() {
85        let artifact = sample();
86        let response = ArtifactResponse::from(artifact.clone());
87
88        assert_eq!(response.id, artifact.id);
89        assert_eq!(response.step_id, artifact.step_id);
90        assert_eq!(response.name, artifact.name);
91        assert_eq!(response.size_bytes, artifact.size_bytes);
92        assert_eq!(response.sha256, artifact.sha256);
93    }
94
95    #[test]
96    fn serialization_never_leaks_the_storage_key() {
97        let json = serde_json::to_string(&ArtifactResponse::from(sample())).expect("serialize");
98
99        assert!(!json.contains("storage_key"));
100        assert!(!json.contains("secret/path"));
101    }
102
103    #[test]
104    fn serialization_omits_the_run_id_carried_by_the_route() {
105        let json = serde_json::to_string(&ArtifactResponse::from(sample())).expect("serialize");
106        assert!(!json.contains("run_id"));
107    }
108}