Skip to main content

yt/api/
attachments.rs

1use std::path::PathBuf;
2
3use miette::IntoDiagnostic as _;
4use serde::Deserialize;
5
6use crate::api::issues::UserRef;
7use crate::api::{Client, Parent, Projection};
8use crate::error::{Result, YtError};
9use crate::fields::{choices, usage};
10
11#[derive(Clone, Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Attachment {
14    pub id: String,
15    pub name: String,
16    pub size: Option<i64>,
17    pub mime_type: Option<String>,
18    pub author: Option<UserRef>,
19    pub created: Option<i64>,
20    pub url: Option<String>,
21}
22
23impl Projection for Attachment {
24    const FIELDS: &'static str = "id,name,size,mimeType,author(login,fullName),created,url";
25}
26
27pub async fn list(client: &Client, parent: &Parent) -> Result<Vec<Attachment>> {
28    client
29        .get_all(&format!("{}/attachments", parent.path()))
30        .await
31}
32
33/// Uploads files to an issue or article in one request.
34pub async fn upload(
35    client: &Client,
36    parent: &Parent,
37    files: &[PathBuf],
38) -> Result<Vec<Attachment>> {
39    let paths = files.to_vec();
40    let parts = tokio::task::spawn_blocking(move || -> Result<Vec<(String, Vec<u8>)>> {
41        paths
42            .iter()
43            .map(|path| {
44                let data = std::fs::read(path)
45                    .map_err(|error| usage(format!("cannot read {}: {error}", path.display())))?;
46                let name = path
47                    .file_name()
48                    .map(|name| name.to_string_lossy().into_owned())
49                    .unwrap_or_else(|| "file".into());
50                Ok((name, data))
51            })
52            .collect()
53    })
54    .await
55    .into_diagnostic()??;
56    client
57        .upload(&format!("{}/attachments", parent.path()), &parts)
58        .await
59}
60
61/// The attachment's content, byte for byte.
62pub async fn download(client: &Client, attachment: &Attachment) -> Result<Vec<u8>> {
63    let url = attachment.url.as_deref().ok_or_else(|| {
64        usage(format!(
65            "YouTrack gave no download address for {}",
66            attachment.name
67        ))
68    })?;
69    let mut response = client.download(url).await?;
70    let mut data = Vec::new();
71    while let Some(chunk) = response
72        .chunk()
73        .await
74        .map_err(|error| YtError::Network(crate::redact::redact(&error.to_string())))?
75    {
76        data.extend_from_slice(&chunk);
77    }
78    Ok(data)
79}
80
81pub async fn delete(client: &Client, parent: &Parent, attachment: &str) -> Result<()> {
82    if !attachment
83        .chars()
84        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
85    {
86        return Err(usage(format!("'{attachment}' is not an attachment ID")));
87    }
88    client
89        .delete(&format!("{}/attachments/{attachment}", parent.path()))
90        .await
91}
92
93/// An attachment by ID, or by a name only one attachment has.
94pub fn find<'a>(
95    attachments: &'a [Attachment],
96    name_or_id: &str,
97    parent: &Parent,
98) -> Result<&'a Attachment> {
99    if let Some(found) = attachments
100        .iter()
101        .find(|attachment| attachment.id == name_or_id)
102    {
103        return Ok(found);
104    }
105    let named: Vec<&Attachment> = attachments
106        .iter()
107        .filter(|attachment| attachment.name == name_or_id)
108        .collect();
109    match named.as_slice() {
110        [one] => Ok(one),
111        [] => {
112            let names: Vec<String> = attachments
113                .iter()
114                .map(|attachment| attachment.name.clone())
115                .collect();
116            Err(usage(if names.is_empty() {
117                format!(
118                    "there is no attachment '{name_or_id}'; the {} has none",
119                    parent.noun()
120                )
121            } else {
122                format!(
123                    "there is no attachment '{name_or_id}'; attachments: {}",
124                    choices(&names)
125                )
126            }))
127        }
128        several => Err(usage(format!(
129            "{} attachments are named '{name_or_id}'; pick one by ID: {}",
130            several.len(),
131            several
132                .iter()
133                .map(|attachment| attachment.id.as_str())
134                .collect::<Vec<_>>()
135                .join(", ")
136        ))),
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use serde_json::json;
143    use wiremock::matchers::{body_string_contains, header_regex, method, path};
144    use wiremock::{Mock, MockServer, ResponseTemplate};
145
146    use super::*;
147    use crate::api::test_client;
148
149    fn attachment(id: &str, name: &str) -> Attachment {
150        Attachment {
151            id: id.into(),
152            name: name.into(),
153            size: Some(5),
154            mime_type: None,
155            author: None,
156            created: None,
157            url: Some(format!("/api/files/{id}?sign=x")),
158        }
159    }
160
161    #[tokio::test]
162    async fn files_upload_in_one_multipart_request() {
163        let server = MockServer::start().await;
164        Mock::given(method("POST"))
165            .and(path("/api/issues/WEB-1/attachments"))
166            .and(header_regex("content-type", "^multipart/form-data"))
167            .and(body_string_contains("hello attachment"))
168            .respond_with(
169                ResponseTemplate::new(200)
170                    .set_body_json(json!([{"id": "1-1", "name": "log.txt", "size": 16}])),
171            )
172            .expect(1)
173            .mount(&server)
174            .await;
175        let dir = tempfile::tempdir().unwrap();
176        let file = dir.path().join("log.txt");
177        std::fs::write(&file, "hello attachment").unwrap();
178        let client = test_client(&server.uri());
179        let uploaded = upload(&client, &Parent::Issue("WEB-1".into()), &[file])
180            .await
181            .unwrap();
182        assert_eq!(uploaded[0].name, "log.txt");
183        assert!(client.has_written());
184    }
185
186    #[tokio::test]
187    async fn a_missing_file_names_its_path() {
188        let client = test_client("https://acme.youtrack.cloud");
189        let error = upload(
190            &client,
191            &Parent::Issue("WEB-1".into()),
192            &[PathBuf::from("/nonexistent/x.bin")],
193        )
194        .await
195        .unwrap_err();
196        assert!(error.to_string().contains("/nonexistent/x.bin"), "{error}");
197    }
198
199    #[tokio::test]
200    async fn downloads_keep_every_byte() {
201        let server = MockServer::start().await;
202        let bytes = vec![0_u8, 159, 146, 150, 255, 10, 13];
203        Mock::given(method("GET"))
204            .and(path("/api/files/1-1"))
205            .respond_with(ResponseTemplate::new(200).set_body_bytes(bytes.clone()))
206            .mount(&server)
207            .await;
208        let data = download(&test_client(&server.uri()), &attachment("1-1", "data.bin"))
209            .await
210            .unwrap();
211        assert_eq!(data, bytes);
212    }
213
214    #[tokio::test]
215    async fn a_slow_download_outlasts_the_request_timeout() {
216        let server = MockServer::start().await;
217        Mock::given(method("GET"))
218            .and(path("/api/files/1-1"))
219            .respond_with(
220                ResponseTemplate::new(200)
221                    .set_body_bytes(vec![1_u8, 2, 3])
222                    .set_delay(std::time::Duration::from_millis(1500)),
223            )
224            .mount(&server)
225            .await;
226        let http = crate::http::client(&crate::http::HttpConfig {
227            timeout: std::time::Duration::from_millis(500),
228            retries: 0,
229            ca_file: None,
230        })
231        .unwrap();
232        let token = std::sync::Arc::new(crate::auth::token_source::StaticToken::new(
233            secrecy::SecretString::from("perm-c2xvdw==.dGVzdA==.secret123456".to_string()),
234            "acme",
235        ));
236        let client = Client::new(
237            http,
238            crate::instance::InstanceUrl::parse(&server.uri()).unwrap(),
239            token,
240            0,
241        );
242        assert_eq!(
243            download(&client, &attachment("1-1", "data.bin"))
244                .await
245                .unwrap(),
246            [1, 2, 3]
247        );
248    }
249
250    #[test]
251    fn attachments_are_found_by_id_or_unique_name() {
252        let attachments = vec![
253            attachment("1-1", "a.txt"),
254            attachment("1-2", "b.txt"),
255            attachment("1-3", "b.txt"),
256        ];
257        assert_eq!(
258            find(&attachments, "a.txt", &Parent::Issue("WEB-1".into()))
259                .unwrap()
260                .id,
261            "1-1"
262        );
263        assert_eq!(
264            find(&attachments, "1-3", &Parent::Issue("WEB-1".into()))
265                .unwrap()
266                .id,
267            "1-3"
268        );
269        let error = find(&attachments, "b.txt", &Parent::Issue("WEB-1".into())).unwrap_err();
270        assert!(error.to_string().contains("1-2, 1-3"), "{error}");
271        assert!(find(&attachments, "c.txt", &Parent::Issue("WEB-1".into())).is_err());
272    }
273}