1use crate::client::Client;
9use crate::error::LinearError;
10
11#[derive(Debug, Clone)]
13pub struct DownloadResult {
14 pub bytes: Vec<u8>,
16 pub content_type: Option<String>,
18}
19
20#[derive(Debug, Clone)]
22pub struct UploadResult {
23 pub asset_url: String,
25}
26
27impl Client {
28 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 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 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 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 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 #[tokio::test]
235 async fn download_url_sends_auth_for_linear_urls() {
236 let server = MockServer::start().await;
237 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 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]) .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 #[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 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 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 client.set_base_url(server.uri());
387
388 let bytes = vec![0x89, 0x50, 0x4E, 0x47]; 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 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"); assert_eq!(requests[1].method.as_str(), "PUT"); }
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}