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 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 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 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 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 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 #[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]) .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 #[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 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 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 client.set_base_url(server.uri());
353
354 let bytes = vec![0x89, 0x50, 0x4E, 0x47]; 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 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"); assert_eq!(requests[1].method.as_str(), "PUT"); }
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}