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