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