Skip to main content

ironflow_sdk/
artifact_methods.rs

1//! Artifact download methods for [`IronflowClient`].
2
3use uuid::Uuid;
4
5use crate::client::{ArtifactDownload, IronflowClient};
6use crate::error::Error;
7
8fn validate_artifact_name(name: &str) -> Result<(), Error> {
9    if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
10        return Err(Error::Deserialize(format!(
11            "invalid artifact name: {name:?}"
12        )));
13    }
14    Ok(())
15}
16
17impl IronflowClient {
18    /// Download an artifact by step name.
19    ///
20    /// Resolves `step_name` to a step ID via [`get_run`](Self::get_run),
21    /// then downloads the artifact. The SHA-256 digest is taken from the
22    /// step's artifact metadata (no extra round-trip for the hash).
23    ///
24    /// Use [`download_artifact_by_step_id`](Self::download_artifact_by_step_id)
25    /// to skip the run detail lookup when you already have the step ID.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`Error::Api`] on 404 (run, step or artifact not found) or 401.
30    /// Returns [`Error::Deserialize`] if no step matches `step_name`.
31    ///
32    /// # Examples
33    ///
34    /// ```no_run
35    /// use ironflow_sdk::IronflowClient;
36    /// use uuid::Uuid;
37    ///
38    /// # async fn example() -> Result<(), ironflow_sdk::Error> {
39    /// let client = IronflowClient::new("https://ironflow.example.com", "key");
40    /// let run_id = Uuid::nil();
41    /// let artifact = client.download_artifact(run_id, "build", "report.html").await?;
42    /// let _ = artifact.content_type;
43    /// # Ok(())
44    /// # }
45    /// ```
46    pub async fn download_artifact(
47        &self,
48        run_id: Uuid,
49        step_name: &str,
50        artifact_name: &str,
51    ) -> Result<ArtifactDownload, Error> {
52        validate_artifact_name(artifact_name)?;
53
54        let run_detail = self.get_run(run_id).await?;
55        let step = run_detail
56            .data
57            .steps
58            .iter()
59            .find(|s| s.name == step_name)
60            .ok_or_else(|| {
61                Error::Deserialize(format!("step '{step_name}' not found in run {run_id}"))
62            })?;
63
64        let artifact_meta = step
65            .artifacts
66            .iter()
67            .find(|a| a.name == artifact_name)
68            .ok_or_else(|| {
69                Error::Deserialize(format!(
70                    "artifact '{artifact_name}' not found in step '{step_name}'"
71                ))
72            })?;
73
74        let mut download = self.download_raw(run_id, step.id, artifact_name).await?;
75        download.sha256 = artifact_meta.sha256.clone();
76        Ok(download)
77    }
78
79    /// Download an artifact by step ID (no extra round-trip).
80    ///
81    /// Calls `GET /api/v1/runs/{run_id}/steps/{step_id}/artifacts/{name}`
82    /// and returns the raw bytes with content type.
83    ///
84    /// The SHA-256 field is empty because the download route does not
85    /// include it. Use [`download_artifact`](Self::download_artifact)
86    /// (by step name) to get the digest from the step metadata.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`Error::Api`] on 404 (run, step or artifact not found),
91    /// 401 or 501 (artifact storage not configured).
92    ///
93    /// # Examples
94    ///
95    /// ```no_run
96    /// use ironflow_sdk::IronflowClient;
97    /// use uuid::Uuid;
98    ///
99    /// # async fn example() -> Result<(), ironflow_sdk::Error> {
100    /// let client = IronflowClient::new("https://ironflow.example.com", "key");
101    /// let run_id = Uuid::nil();
102    /// let step_id = Uuid::nil();
103    /// let artifact = client
104    ///     .download_artifact_by_step_id(run_id, step_id, "report.html")
105    ///     .await?;
106    /// let _ = artifact.content_type;
107    /// # Ok(())
108    /// # }
109    /// ```
110    pub async fn download_artifact_by_step_id(
111        &self,
112        run_id: Uuid,
113        step_id: Uuid,
114        artifact_name: &str,
115    ) -> Result<ArtifactDownload, Error> {
116        validate_artifact_name(artifact_name)?;
117        self.download_raw(run_id, step_id, artifact_name).await
118    }
119
120    /// Internal: fetch the raw artifact bytes.
121    async fn download_raw(
122        &self,
123        run_id: Uuid,
124        step_id: Uuid,
125        artifact_name: &str,
126    ) -> Result<ArtifactDownload, Error> {
127        let path = format!("/api/v1/runs/{run_id}/steps/{step_id}/artifacts/{artifact_name}");
128        let response = self.send_with_retry(self.get(&path)).await?;
129
130        if !response.status().is_success() {
131            return Err(Self::into_api_error(response).await);
132        }
133
134        let content_type = response
135            .headers()
136            .get("content-type")
137            .and_then(|v| v.to_str().ok())
138            .unwrap_or("application/octet-stream")
139            .to_string();
140
141        let bytes = response.bytes().await?;
142
143        Ok(ArtifactDownload {
144            bytes,
145            content_type,
146            sha256: String::new(),
147        })
148    }
149}