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}