Skip to main content

lineark_sdk/
helpers.rs

1//! HTTP helpers for Linear file operations.
2//!
3//! Linear's file handling works outside the GraphQL API: uploads go to Google
4//! Cloud Storage via signed URLs, and downloads fetch from Linear's CDN. These
5//! helpers use the SDK's internal HTTP client so consumers don't need a separate
6//! `reqwest` dependency.
7
8use crate::client::Client;
9use crate::error::LinearError;
10
11/// Metadata about a successfully downloaded file.
12#[derive(Debug, Clone)]
13pub struct DownloadResult {
14    /// The raw file bytes.
15    pub bytes: Vec<u8>,
16    /// Content-Type header from the response, if present.
17    pub content_type: Option<String>,
18}
19
20/// Metadata returned after a successful two-step file upload.
21#[derive(Debug, Clone)]
22pub struct UploadResult {
23    /// The permanent asset URL for referencing this file in comments, descriptions, etc.
24    pub asset_url: String,
25}
26
27impl Client {
28    /// Download a file from a URL.
29    ///
30    /// Handles Linear's signed/expiring CDN URLs (e.g. `https://uploads.linear.app/...`)
31    /// as well as any other publicly accessible URL. Returns the raw bytes and
32    /// content type so the caller can write them to disk or process them further.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`LinearError::HttpError`] if the server responds with a non-2xx status,
37    /// or [`LinearError::Network`] if the request fails at the transport level.
38    ///
39    /// # Example
40    ///
41    /// ```no_run
42    /// # async fn example() -> Result<(), lineark_sdk::LinearError> {
43    /// let client = lineark_sdk::Client::auto()?;
44    /// let result = client.download_url("https://uploads.linear.app/...").await?;
45    /// std::fs::write("output.png", &result.bytes).unwrap();
46    /// # Ok(())
47    /// # }
48    /// ```
49    pub async fn download_url(&self, url: &str) -> Result<DownloadResult, LinearError> {
50        let response = self
51            .http()
52            .get(url)
53            .header("Authorization", self.token())
54            .send()
55            .await?;
56
57        let status = response.status();
58        if !status.is_success() {
59            let body = response.text().await.unwrap_or_default();
60            return Err(LinearError::HttpError {
61                status: status.as_u16(),
62                body,
63            });
64        }
65
66        let content_type = response
67            .headers()
68            .get("content-type")
69            .and_then(|v| v.to_str().ok())
70            .map(|s| s.to_string());
71
72        let bytes = response.bytes().await?.to_vec();
73
74        Ok(DownloadResult {
75            bytes,
76            content_type,
77        })
78    }
79
80    /// Upload a file to Linear's cloud storage.
81    ///
82    /// This is a two-step process:
83    /// 1. Call the [`fileUpload`](Client::file_upload) GraphQL mutation to obtain
84    ///    a signed upload URL and required headers from Linear.
85    /// 2. `PUT` the raw file bytes to that signed URL (a Google Cloud Storage endpoint).
86    ///
87    /// On success, returns an [`UploadResult`] containing the permanent `asset_url`
88    /// that can be referenced in issue descriptions, comments, or attachments.
89    ///
90    /// # Arguments
91    ///
92    /// * `filename` — The original filename (e.g. `"screenshot.png"`). Linear uses this
93    ///   for display and content-type inference on its side.
94    /// * `content_type` — MIME type of the file (e.g. `"image/png"`).
95    /// * `bytes` — The raw file content.
96    /// * `make_public` — If `true`, the uploaded file will be publicly accessible
97    ///   without authentication.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the `fileUpload` mutation fails, if the signed URL
102    /// upload fails, or if the response is missing expected fields.
103    ///
104    /// # Example
105    ///
106    /// ```no_run
107    /// # async fn example() -> Result<(), lineark_sdk::LinearError> {
108    /// let client = lineark_sdk::Client::auto()?;
109    /// let bytes = std::fs::read("screenshot.png").unwrap();
110    /// let result = client
111    ///     .upload_file("screenshot.png", "image/png", bytes, false)
112    ///     .await?;
113    /// println!("Uploaded to: {}", result.asset_url);
114    /// # Ok(())
115    /// # }
116    /// ```
117    pub async fn upload_file(
118        &self,
119        filename: &str,
120        content_type: &str,
121        bytes: Vec<u8>,
122        make_public: bool,
123    ) -> Result<UploadResult, LinearError> {
124        let size = bytes.len() as i64;
125
126        // Step 1: Request a signed upload URL from Linear's API.
127        // We use a custom query instead of the generated `file_upload` method
128        // because we need the nested `headers { key value }` field which the
129        // codegen omits (it only includes scalar fields).
130        let variables = serde_json::json!({
131            "metaData": null,
132            "makePublic": if make_public { Some(true) } else { None::<bool> },
133            "size": size,
134            "contentType": content_type,
135            "filename": filename,
136        });
137        let payload = self
138            .execute::<serde_json::Value>(
139                "mutation FileUpload($metaData: JSON, $makePublic: Boolean, $size: Int!, \
140                 $contentType: String!, $filename: String!) { \
141                 fileUpload(metaData: $metaData, makePublic: $makePublic, size: $size, \
142                 contentType: $contentType, filename: $filename) { \
143                 success uploadFile { filename contentType size uploadUrl assetUrl \
144                 headers { key value } } } }",
145                variables,
146                "fileUpload",
147            )
148            .await?;
149
150        if payload.get("success").and_then(|v| v.as_bool()) != Some(true) {
151            return Err(LinearError::MissingData(format!(
152                "fileUpload mutation failed: {}",
153                serde_json::to_string(&payload).unwrap_or_default()
154            )));
155        }
156
157        let upload_file = payload.get("uploadFile").ok_or_else(|| {
158            LinearError::MissingData("No 'uploadFile' in fileUpload response".to_string())
159        })?;
160
161        let upload_url = upload_file
162            .get("uploadUrl")
163            .and_then(|v| v.as_str())
164            .ok_or_else(|| {
165                LinearError::MissingData("No 'uploadUrl' in fileUpload response".to_string())
166            })?;
167
168        let asset_url = upload_file
169            .get("assetUrl")
170            .and_then(|v| v.as_str())
171            .ok_or_else(|| {
172                LinearError::MissingData("No 'assetUrl' in fileUpload response".to_string())
173            })?
174            .to_string();
175
176        // Collect upload headers prescribed by Linear.
177        let headers: Vec<(String, String)> = upload_file
178            .get("headers")
179            .and_then(|v| v.as_array())
180            .map(|arr| {
181                arr.iter()
182                    .filter_map(|h| {
183                        let key = h.get("key")?.as_str()?.to_string();
184                        let val = h.get("value")?.as_str()?.to_string();
185                        Some((key, val))
186                    })
187                    .collect()
188            })
189            .unwrap_or_default();
190
191        // Step 2: PUT the file bytes to the signed upload URL.
192        let mut request = self
193            .http()
194            .put(upload_url)
195            .header("Content-Type", content_type)
196            .body(bytes);
197
198        for (key, value) in &headers {
199            request = request.header(key.as_str(), value.as_str());
200        }
201
202        let response = request.send().await?;
203
204        if !response.status().is_success() {
205            let status = response.status();
206            let body = response.text().await.unwrap_or_default();
207            return Err(LinearError::HttpError {
208                status: status.as_u16(),
209                body,
210            });
211        }
212
213        Ok(UploadResult { asset_url })
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use wiremock::matchers::{method, path};
221    use wiremock::{Mock, MockServer, ResponseTemplate};
222
223    fn test_client_with_base(base_url: &str) -> Client {
224        let mut client = Client::from_token("test-token").unwrap();
225        client.set_base_url(base_url.to_string());
226        client
227    }
228
229    // ── download_url ────────────────────────────────────────────────────────
230
231    #[tokio::test]
232    async fn download_url_returns_bytes_and_content_type() {
233        let server = MockServer::start().await;
234        Mock::given(method("GET"))
235            .and(path("/files/test.png"))
236            .respond_with(
237                ResponseTemplate::new(200)
238                    .set_body_bytes(vec![0x89, 0x50, 0x4E, 0x47]) // PNG magic bytes
239                    .insert_header("content-type", "image/png"),
240            )
241            .mount(&server)
242            .await;
243
244        let client = test_client_with_base(&server.uri());
245        let url = format!("{}/files/test.png", server.uri());
246        let result = client.download_url(&url).await.unwrap();
247
248        assert_eq!(result.bytes, vec![0x89, 0x50, 0x4E, 0x47]);
249        assert_eq!(result.content_type, Some("image/png".to_string()));
250    }
251
252    #[tokio::test]
253    async fn download_url_without_content_type_header() {
254        let server = MockServer::start().await;
255        Mock::given(method("GET"))
256            .and(path("/files/raw"))
257            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"raw data".to_vec()))
258            .mount(&server)
259            .await;
260
261        let client = test_client_with_base(&server.uri());
262        let url = format!("{}/files/raw", server.uri());
263        let result = client.download_url(&url).await.unwrap();
264
265        assert_eq!(result.bytes, b"raw data");
266        assert_eq!(result.content_type, None);
267    }
268
269    #[tokio::test]
270    async fn download_url_404_returns_http_error() {
271        let server = MockServer::start().await;
272        Mock::given(method("GET"))
273            .and(path("/files/missing"))
274            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
275            .mount(&server)
276            .await;
277
278        let client = test_client_with_base(&server.uri());
279        let url = format!("{}/files/missing", server.uri());
280        let result = client.download_url(&url).await;
281
282        assert!(result.is_err());
283        match result.unwrap_err() {
284            LinearError::HttpError { status, body } => {
285                assert_eq!(status, 404);
286                assert_eq!(body, "Not Found");
287            }
288            other => panic!("expected HttpError, got: {:?}", other),
289        }
290    }
291
292    #[tokio::test]
293    async fn download_url_500_returns_http_error() {
294        let server = MockServer::start().await;
295        Mock::given(method("GET"))
296            .and(path("/files/error"))
297            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
298            .mount(&server)
299            .await;
300
301        let client = test_client_with_base(&server.uri());
302        let url = format!("{}/files/error", server.uri());
303        let result = client.download_url(&url).await;
304
305        assert!(result.is_err());
306        match result.unwrap_err() {
307            LinearError::HttpError { status, .. } => assert_eq!(status, 500),
308            other => panic!("expected HttpError, got: {:?}", other),
309        }
310    }
311
312    // ── upload_file ─────────────────────────────────────────────────────────
313
314    #[tokio::test]
315    async fn upload_file_two_step_flow() {
316        let server = MockServer::start().await;
317        let upload_url = format!("{}/upload-target", server.uri());
318        let asset_url = "https://linear-uploads.example.com/asset/test.png";
319
320        // Step 1: Mock the fileUpload GraphQL mutation.
321        Mock::given(method("POST"))
322            .and(path("/"))
323            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
324                "data": {
325                    "fileUpload": {
326                        "success": true,
327                        "uploadFile": {
328                            "uploadUrl": upload_url,
329                            "assetUrl": asset_url,
330                            "filename": "test.png",
331                            "contentType": "image/png",
332                            "size": 4,
333                            "headers": [
334                                { "key": "x-goog-meta-test", "value": "123" }
335                            ]
336                        }
337                    }
338                }
339            })))
340            .mount(&server)
341            .await;
342
343        // Step 2: Mock the PUT to the signed upload URL.
344        Mock::given(method("PUT"))
345            .and(path("/upload-target"))
346            .respond_with(ResponseTemplate::new(200))
347            .mount(&server)
348            .await;
349
350        let mut client = Client::from_token("test-token").unwrap();
351        // Point GraphQL calls at the mock server.
352        client.set_base_url(server.uri());
353
354        let bytes = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic
355        let result = client
356            .upload_file("test.png", "image/png", bytes, false)
357            .await
358            .unwrap();
359
360        assert_eq!(result.asset_url, asset_url);
361
362        // Verify both requests were made.
363        let requests = server.received_requests().await.unwrap();
364        assert_eq!(
365            requests.len(),
366            2,
367            "should have made 2 requests (mutation + PUT)"
368        );
369        assert_eq!(requests[0].method.as_str(), "POST"); // GraphQL mutation
370        assert_eq!(requests[1].method.as_str(), "PUT"); // File upload
371    }
372
373    #[tokio::test]
374    async fn upload_file_mutation_failure_returns_error() {
375        let server = MockServer::start().await;
376
377        Mock::given(method("POST"))
378            .and(path("/"))
379            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
380                "data": {
381                    "fileUpload": {
382                        "success": false
383                    }
384                }
385            })))
386            .mount(&server)
387            .await;
388
389        let mut client = Client::from_token("test-token").unwrap();
390        client.set_base_url(server.uri());
391
392        let result = client
393            .upload_file("test.png", "image/png", vec![1, 2, 3], false)
394            .await;
395
396        assert!(result.is_err());
397        match result.unwrap_err() {
398            LinearError::MissingData(msg) => {
399                assert!(msg.contains("fileUpload mutation failed"), "got: {msg}");
400            }
401            other => panic!("expected MissingData, got: {:?}", other),
402        }
403    }
404
405    #[tokio::test]
406    async fn upload_file_put_failure_returns_http_error() {
407        let server = MockServer::start().await;
408        let upload_url = format!("{}/upload-target", server.uri());
409
410        Mock::given(method("POST"))
411            .and(path("/"))
412            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
413                "data": {
414                    "fileUpload": {
415                        "success": true,
416                        "uploadFile": {
417                            "uploadUrl": upload_url,
418                            "assetUrl": "https://example.com/asset.png",
419                            "headers": []
420                        }
421                    }
422                }
423            })))
424            .mount(&server)
425            .await;
426
427        Mock::given(method("PUT"))
428            .and(path("/upload-target"))
429            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
430            .mount(&server)
431            .await;
432
433        let mut client = Client::from_token("test-token").unwrap();
434        client.set_base_url(server.uri());
435
436        let result = client
437            .upload_file("test.png", "image/png", vec![1, 2, 3], false)
438            .await;
439
440        assert!(result.is_err());
441        match result.unwrap_err() {
442            LinearError::HttpError { status, body } => {
443                assert_eq!(status, 403);
444                assert_eq!(body, "Forbidden");
445            }
446            other => panic!("expected HttpError, got: {:?}", other),
447        }
448    }
449}