1use anyhow::{Context, Result};
23use serde_json::Value;
24
25#[derive(Debug)]
27pub struct ZoteroWebClient {
28 client: reqwest::blocking::Client,
29 api_key: String,
30 base_url: String,
31}
32
33impl ZoteroWebClient {
34 pub fn new(api_key: &str, library_id: &str, library_type: &str) -> Self {
36 let base_url = format!("https://api.zotero.org/{library_type}s/{library_id}");
37 Self {
38 client: reqwest::blocking::Client::builder()
39 .timeout(std::time::Duration::from_secs(60))
40 .build()
41 .expect("Failed to build HTTP client"),
42 api_key: api_key.into(),
43 base_url,
44 }
45 }
46
47 pub fn with_base_url(api_key: &str, base_url: &str) -> Self {
49 Self {
50 client: reqwest::blocking::Client::builder()
51 .timeout(std::time::Duration::from_secs(60))
52 .build()
53 .expect("Failed to build HTTP client"),
54 api_key: api_key.into(),
55 base_url: base_url.into(),
56 }
57 }
58
59 fn headers(&self) -> reqwest::header::HeaderMap {
61 let mut h = reqwest::header::HeaderMap::new();
62 h.insert(
63 "Zotero-API-Key",
64 self.api_key
65 .parse()
66 .expect("ZOTERO_API_KEY contains invalid header characters"),
67 );
68 h.insert("Zotero-API-Version", "3".parse().unwrap());
69 h.insert(
70 reqwest::header::CONTENT_TYPE,
71 "application/json".parse().unwrap(),
72 );
73 h
74 }
75
76 pub fn get_item(&self, key: &str) -> Result<Value> {
78 let resp = self
79 .client
80 .get(format!("{}/items/{key}", self.base_url))
81 .headers(self.headers())
82 .send()
83 .with_context(|| format!("Failed to fetch item {key}"))?;
84 resp.error_for_status_ref()
85 .with_context(|| format!("API error fetching item {key}"))?;
86 resp.json().with_context(|| "Invalid JSON from Zotero API")
87 }
88
89 pub fn children(&self, key: &str) -> Result<Vec<Value>> {
91 let resp = self
92 .client
93 .get(format!("{}/items/{key}/children", self.base_url))
94 .headers(self.headers())
95 .send()?;
96 resp.error_for_status_ref()?;
97 resp.json().map_err(Into::into)
98 }
99
100 pub fn item_template(&self, item_type: &str) -> Result<Value> {
105 let api_root = if self.base_url.contains("api.zotero.org") {
106 "https://api.zotero.org"
107 } else {
108 &self.base_url
110 };
111 let resp = self
112 .client
113 .get(format!("{api_root}/items/new"))
114 .query(&[("itemType", item_type)])
115 .headers(self.headers())
116 .send()?;
117 resp.error_for_status_ref()
118 .with_context(|| format!("Failed to get template for itemType '{item_type}'"))?;
119 resp.json().map_err(Into::into)
120 }
121
122 pub fn create_items(&self, items: &[Value]) -> Result<Value> {
124 let resp = self
125 .client
126 .post(format!("{}/items", self.base_url))
127 .headers(self.headers())
128 .json(items)
129 .send()
130 .with_context(|| "Failed to create items")?;
131 resp.error_for_status_ref()
132 .with_context(|| "API error creating items")?;
133 resp.json().map_err(Into::into)
134 }
135
136 pub fn update_item(&self, key: &str, data: &Value, version: i32) -> Result<()> {
140 let resp = self
141 .client
142 .patch(format!("{}/items/{key}", self.base_url))
143 .headers(self.headers())
144 .header("If-Unmodified-Since-Version", version.to_string())
145 .json(data)
146 .send()?;
147 resp.error_for_status_ref()
148 .with_context(|| format!("API error updating item {key}"))?;
149 Ok(())
150 }
151
152 pub fn delete_item(&self, key: &str, version: i32) -> Result<()> {
154 let resp = self
155 .client
156 .delete(format!("{}/items/{key}", self.base_url))
157 .headers(self.headers())
158 .header("If-Unmodified-Since-Version", version.to_string())
159 .send()?;
160 resp.error_for_status_ref()
161 .with_context(|| format!("API error deleting item {key}"))?;
162 Ok(())
163 }
164
165 pub fn get_collections(&self) -> Result<Vec<Value>> {
167 let resp = self
168 .client
169 .get(format!("{}/collections", self.base_url))
170 .headers(self.headers())
171 .send()?;
172 resp.error_for_status_ref()?;
173 resp.json().map_err(Into::into)
174 }
175
176 pub fn create_collections(&self, collections: &[Value]) -> Result<Value> {
178 let resp = self
179 .client
180 .post(format!("{}/collections", self.base_url))
181 .headers(self.headers())
182 .json(collections)
183 .send()?;
184 resp.error_for_status_ref()?;
185 resp.json().map_err(Into::into)
186 }
187
188 pub fn download_file(&self, url: &str, dest: &std::path::Path) -> Result<()> {
190 let resp = self
191 .client
192 .get(url)
193 .header(
194 "User-Agent",
195 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
196 )
197 .send()
198 .with_context(|| format!("Failed to download {url}"))?;
199 resp.error_for_status_ref()?;
200 let bytes = resp.bytes()?;
201
202 if !bytes.starts_with(b"%PDF") {
204 anyhow::bail!("Downloaded file is not a valid PDF (missing %PDF header)");
205 }
206
207 std::fs::write(dest, &bytes)
208 .with_context(|| format!("Failed to write to {}", dest.display()))?;
209 Ok(())
210 }
211
212 pub fn attach_file(
220 &self,
221 parent_key: &str,
222 file_path: &std::path::Path,
223 title: &str,
224 ) -> Result<Value> {
225 let filename = file_path
226 .file_name()
227 .and_then(|n| n.to_str())
228 .unwrap_or("attachment.pdf");
229
230 let file_bytes = std::fs::read(file_path)
231 .with_context(|| format!("Failed to read {}", file_path.display()))?;
232 let file_md5 = format!("{:x}", md5::compute(&file_bytes));
233 let filesize = file_bytes.len();
234 let mtime = std::time::SystemTime::now()
235 .duration_since(std::time::UNIX_EPOCH)
236 .unwrap_or_default()
237 .as_millis();
238
239 let template = serde_json::json!({
241 "itemType": "attachment",
242 "parentItem": parent_key,
243 "linkMode": "imported_file",
244 "title": title,
245 "contentType": "application/pdf",
246 "filename": filename,
247 "tags": [],
248 "relations": {},
249 });
250
251 let create_resp = self
252 .create_items(&[template])
253 .with_context(|| "Failed to create attachment item")?;
254
255 let attachment_key = create_resp
256 .pointer("/successful/0/key")
257 .or_else(|| create_resp.pointer("/successful/0/data/key"))
258 .and_then(|v| v.as_str())
259 .ok_or_else(|| anyhow::anyhow!("No attachment key in API response: {create_resp}"))?
260 .to_string();
261
262 let attachment_version = create_resp
263 .pointer("/successful/0/version")
264 .or_else(|| create_resp.pointer("/successful/0/data/version"))
265 .and_then(|v| v.as_i64())
266 .unwrap_or(0) as i32;
267
268 let auth = match self.get_upload_authorization(
270 &attachment_key,
271 &file_md5,
272 filename,
273 filesize,
274 mtime,
275 ) {
276 Ok(a) => a,
277 Err(e) => {
278 let _ = self.delete_item(&attachment_key, attachment_version);
279 return Err(e.context("Failed to get upload authorization"));
280 }
281 };
282
283 if auth.get("exists").and_then(|v| v.as_i64()) == Some(1) {
285 return Ok(create_resp);
286 }
287
288 let url = auth["url"]
290 .as_str()
291 .ok_or_else(|| anyhow::anyhow!("Missing 'url' in upload auth response"))?;
292 let content_type = auth["contentType"]
293 .as_str()
294 .ok_or_else(|| anyhow::anyhow!("Missing 'contentType' in upload auth response"))?;
295 let prefix = auth["prefix"]
296 .as_str()
297 .ok_or_else(|| anyhow::anyhow!("Missing 'prefix' in upload auth response"))?;
298 let suffix = auth["suffix"]
299 .as_str()
300 .ok_or_else(|| anyhow::anyhow!("Missing 'suffix' in upload auth response"))?;
301 let upload_key = auth["uploadKey"]
302 .as_str()
303 .ok_or_else(|| anyhow::anyhow!("Missing 'uploadKey' in upload auth response"))?;
304
305 if let Err(e) = self.upload_to_s3(url, content_type, prefix, &file_bytes, suffix) {
306 let _ = self.delete_item(&attachment_key, attachment_version);
307 return Err(e.context("Failed to upload file to storage"));
308 }
309
310 if let Err(e) = self.register_upload(&attachment_key, upload_key) {
312 let _ = self.delete_item(&attachment_key, attachment_version);
313 return Err(e.context("Failed to register upload"));
314 }
315
316 Ok(create_resp)
317 }
318
319 fn get_upload_authorization(
321 &self,
322 key: &str,
323 md5: &str,
324 filename: &str,
325 filesize: usize,
326 mtime: u128,
327 ) -> Result<Value> {
328 let mut headers = self.headers();
329 headers.insert(
330 reqwest::header::CONTENT_TYPE,
331 "application/x-www-form-urlencoded".parse().unwrap(),
332 );
333 headers.insert("If-None-Match", "*".parse().unwrap());
334
335 let body = format!("md5={md5}&filename={filename}&filesize={filesize}&mtime={mtime}");
336
337 let resp = self
338 .client
339 .post(format!("{}/items/{key}/file", self.base_url))
340 .headers(headers)
341 .body(body)
342 .send()
343 .with_context(|| format!("Upload auth request failed for {key}"))?;
344 resp.error_for_status_ref()
345 .with_context(|| format!("Upload auth rejected for {key}"))?;
346 resp.json().map_err(Into::into)
347 }
348
349 fn upload_to_s3(
351 &self,
352 url: &str,
353 content_type: &str,
354 prefix: &str,
355 file_bytes: &[u8],
356 suffix: &str,
357 ) -> Result<()> {
358 let mut body = Vec::with_capacity(prefix.len() + file_bytes.len() + suffix.len());
359 body.extend_from_slice(prefix.as_bytes());
360 body.extend_from_slice(file_bytes);
361 body.extend_from_slice(suffix.as_bytes());
362
363 let resp = self
364 .client
365 .post(url)
366 .header(reqwest::header::CONTENT_TYPE, content_type)
367 .body(body)
368 .send()
369 .with_context(|| "S3 upload request failed")?;
370 resp.error_for_status_ref()
371 .with_context(|| "S3 upload rejected")?;
372 Ok(())
373 }
374
375 fn register_upload(&self, key: &str, upload_key: &str) -> Result<()> {
377 let mut headers = self.headers();
378 headers.insert(
379 reqwest::header::CONTENT_TYPE,
380 "application/x-www-form-urlencoded".parse().unwrap(),
381 );
382 headers.insert("If-None-Match", "*".parse().unwrap());
383
384 let resp = self
385 .client
386 .post(format!("{}/items/{key}/file", self.base_url))
387 .headers(headers)
388 .body(format!("upload={upload_key}"))
389 .send()
390 .with_context(|| format!("Register upload failed for {key}"))?;
391 resp.error_for_status_ref()
392 .with_context(|| format!("Register upload rejected for {key}"))?;
393 Ok(())
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn client_constructs_correct_base_url() {
403 let client = ZoteroWebClient::new("test-key", "12345", "user");
404 assert_eq!(client.base_url, "https://api.zotero.org/users/12345");
405 }
406
407 #[test]
408 fn client_headers_include_api_key() {
409 let client = ZoteroWebClient::new("my-secret-key", "1", "user");
410 let headers = client.headers();
411 assert_eq!(headers.get("Zotero-API-Key").unwrap(), "my-secret-key");
412 assert_eq!(headers.get("Zotero-API-Version").unwrap(), "3");
413 }
414}
415
416#[cfg(test)]
417mod upload_tests {
418 use super::*;
419 use wiremock::matchers::{body_string_contains, header, method, path, path_regex};
420 use wiremock::{Mock, MockServer, ResponseTemplate};
421
422 fn make_test_pdf(dir: &std::path::Path) -> std::path::PathBuf {
423 let path = dir.join("test.pdf");
424 std::fs::write(&path, b"%PDF-1.4 test content").unwrap();
425 path
426 }
427
428 #[tokio::test]
429 async fn upload_flow_success() {
430 let server = MockServer::start().await;
431 let uri = server.uri();
432
433 Mock::given(method("POST"))
435 .and(path("/items"))
436 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
437 "successful": {
438 "0": { "key": "ATT001", "version": 1, "data": { "key": "ATT001", "version": 1 } }
439 },
440 "unchanged": {},
441 "failed": {}
442 })))
443 .expect(1)
444 .mount(&server)
445 .await;
446
447 let s3_url = format!("{uri}/s3-upload");
449 Mock::given(method("POST"))
450 .and(path_regex(r"/items/ATT001/file"))
451 .and(body_string_contains("md5="))
452 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
453 "url": s3_url,
454 "contentType": "application/pdf",
455 "prefix": "PREFIX",
456 "suffix": "SUFFIX",
457 "uploadKey": "upload-key-123"
458 })))
459 .expect(1)
460 .mount(&server)
461 .await;
462
463 Mock::given(method("POST"))
465 .and(path("/s3-upload"))
466 .respond_with(ResponseTemplate::new(201))
467 .expect(1)
468 .mount(&server)
469 .await;
470
471 Mock::given(method("POST"))
473 .and(path_regex(r"/items/ATT001/file"))
474 .and(body_string_contains("upload="))
475 .respond_with(ResponseTemplate::new(204))
476 .expect(1)
477 .mount(&server)
478 .await;
479
480 let tmp = tempfile::tempdir().unwrap();
481 let pdf = make_test_pdf(tmp.path());
482 let result = tokio::task::spawn_blocking(move || {
483 let client = ZoteroWebClient::with_base_url("test-key", &uri);
484 client.attach_file("PARENT01", &pdf, "Test Paper")
485 })
486 .await
487 .unwrap();
488 assert!(result.is_ok());
489 }
490
491 #[tokio::test]
492 async fn upload_flow_file_exists() {
493 let server = MockServer::start().await;
494 let uri = server.uri();
495
496 Mock::given(method("POST"))
498 .and(path("/items"))
499 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
500 "successful": {
501 "0": { "key": "ATT002", "version": 1, "data": { "key": "ATT002", "version": 1 } }
502 },
503 "unchanged": {},
504 "failed": {}
505 })))
506 .mount(&server)
507 .await;
508
509 Mock::given(method("POST"))
511 .and(path_regex(r"/items/ATT002/file"))
512 .and(body_string_contains("md5="))
513 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
514 "exists": 1
515 })))
516 .mount(&server)
517 .await;
518
519 Mock::given(method("POST"))
521 .and(path("/s3-upload"))
522 .respond_with(ResponseTemplate::new(201))
523 .expect(0)
524 .mount(&server)
525 .await;
526
527 let tmp = tempfile::tempdir().unwrap();
528 let pdf = make_test_pdf(tmp.path());
529 let result = tokio::task::spawn_blocking(move || {
530 let client = ZoteroWebClient::with_base_url("test-key", &uri);
531 client.attach_file("PARENT02", &pdf, "Test Paper")
532 })
533 .await
534 .unwrap();
535 assert!(result.is_ok());
536 }
537
538 #[tokio::test]
539 async fn upload_flow_s3_failure_cleans_up() {
540 let server = MockServer::start().await;
541 let uri = server.uri();
542
543 Mock::given(method("POST"))
545 .and(path("/items"))
546 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
547 "successful": {
548 "0": { "key": "ATT003", "version": 1, "data": { "key": "ATT003", "version": 1 } }
549 },
550 "unchanged": {},
551 "failed": {}
552 })))
553 .mount(&server)
554 .await;
555
556 let s3_url = format!("{uri}/s3-upload");
558 Mock::given(method("POST"))
559 .and(path_regex(r"/items/ATT003/file"))
560 .and(body_string_contains("md5="))
561 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
562 "url": s3_url,
563 "contentType": "application/pdf",
564 "prefix": "P",
565 "suffix": "S",
566 "uploadKey": "uk"
567 })))
568 .mount(&server)
569 .await;
570
571 Mock::given(method("POST"))
573 .and(path("/s3-upload"))
574 .respond_with(ResponseTemplate::new(500))
575 .mount(&server)
576 .await;
577
578 Mock::given(method("DELETE"))
580 .and(path_regex(r"/items/ATT003"))
581 .respond_with(ResponseTemplate::new(204))
582 .expect(1)
583 .mount(&server)
584 .await;
585
586 let tmp = tempfile::tempdir().unwrap();
587 let pdf = make_test_pdf(tmp.path());
588 let result = tokio::task::spawn_blocking(move || {
589 let client = ZoteroWebClient::with_base_url("test-key", &uri);
590 client.attach_file("PARENT03", &pdf, "Test Paper")
591 })
592 .await
593 .unwrap();
594 assert!(result.is_err());
595 }
596
597 #[tokio::test]
600 async fn item_template_uses_root_path() {
601 let server = MockServer::start().await;
602 let uri = server.uri();
603
604 Mock::given(method("GET"))
606 .and(path("/items/new"))
607 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
608 "itemType": "journalArticle",
609 "title": ""
610 })))
611 .expect(1)
612 .mount(&server)
613 .await;
614
615 let result = tokio::task::spawn_blocking(move || {
616 let client = ZoteroWebClient::with_base_url("test-key", &uri);
617 client.item_template("journalArticle")
618 })
619 .await
620 .unwrap();
621 assert!(result.is_ok());
622 }
623
624 #[tokio::test]
628 async fn attach_file_sends_flat_array_to_create_items() {
629 let server = MockServer::start().await;
630 let uri = server.uri();
631
632 Mock::given(method("POST"))
635 .and(path("/items"))
636 .and(body_string_contains(r#"[{"#))
637 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
638 "successful": {
639 "0": { "key": "ATT004", "version": 1, "data": { "key": "ATT004", "version": 1 } }
640 },
641 "unchanged": {},
642 "failed": {}
643 })))
644 .expect(1)
645 .mount(&server)
646 .await;
647
648 Mock::given(method("POST"))
650 .and(path_regex(r"/items/ATT004/file"))
651 .respond_with(
652 ResponseTemplate::new(200).set_body_json(serde_json::json!({"exists": 1})),
653 )
654 .mount(&server)
655 .await;
656
657 let tmp = tempfile::tempdir().unwrap();
658 let pdf = make_test_pdf(tmp.path());
659 let result = tokio::task::spawn_blocking(move || {
660 let client = ZoteroWebClient::with_base_url("test-key", &uri);
661 client.attach_file("PARENT04", &pdf, "Test")
662 })
663 .await
664 .unwrap();
665 assert!(result.is_ok());
666 }
667}