biblion/api/
zotero_web.rs

1//! Zotero Web API v3 client — for all write operations.
2//!
3//! # Why not write to SQLite directly?
4//!
5//! Zotero syncs its database to the cloud. Writing to the SQLite file
6//! directly would cause sync conflicts and data corruption. All writes
7//! MUST go through the official Web API.
8//!
9//! # API Reference
10//!
11//! Base URL: `https://api.zotero.org`
12//! Auth: `Zotero-API-Key: {key}` header
13//! Content-Type: `application/json`
14//! API version: 3 (via `Zotero-API-Version: 3` header)
15//!
16//! # Rate limits
17//!
18//! Zotero doesn't document strict rate limits but recommends <1 req/sec
19//! for heavy operations. Our MCP usage pattern (human-triggered, one
20//! operation at a time) is well within bounds.
21
22use anyhow::{Context, Result};
23use serde_json::Value;
24
25/// Blocking client for the Zotero Web API v3.
26#[derive(Debug)]
27pub struct ZoteroWebClient {
28    client: reqwest::blocking::Client,
29    api_key: String,
30    base_url: String,
31}
32
33impl ZoteroWebClient {
34    /// Create a new client for a user library.
35    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    /// Create a client with a custom base URL (for testing).
48    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    /// Common headers for all API requests.
60    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    /// GET a single item by key.
77    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    /// GET children of an item (attachments, notes).
90    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    /// GET an item template for a given type.
101    ///
102    /// Note: the template endpoint is global (`/items/new`), not scoped
103    /// to a user library. We use the API root, not `self.base_url`.
104    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            // Testing with custom base URL — use it as-is
109            &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    /// POST new items to the library.
123    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    /// PATCH (update) an existing item.
137    ///
138    /// Requires the item's current `version` for optimistic concurrency.
139    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    /// DELETE an item permanently.
153    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    /// GET all collections.
166    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    /// POST new collections.
177    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    /// Download a file from a URL to a local path.
189    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        // Validate PDF magic bytes
203        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    /// Upload a file attachment to an item.
213    ///
214    /// Implements the full Zotero file upload protocol:
215    /// 1. Create attachment item metadata
216    /// 2. Get upload authorization (with file hash)
217    /// 3. Upload file bytes to S3
218    /// 4. Register the upload with Zotero
219    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        // Step 1: Create attachment item
240        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        // Step 2: Get upload authorization
269        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 file already exists on server, we're done
284        if auth.get("exists").and_then(|v| v.as_i64()) == Some(1) {
285            return Ok(create_resp);
286        }
287
288        // Step 3: Upload to S3
289        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        // Step 4: Register upload
311        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    /// Step 2: Get upload authorization from Zotero.
320    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    /// Step 3: Upload file bytes to S3 pre-signed URL.
350    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    /// Step 4: Register a completed upload with Zotero.
376    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        // Step 1: Create attachment item
434        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        // Step 2: Upload authorization
448        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        // Step 3: S3 upload
464        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        // Step 4: Register upload
472        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        // Step 1: Create attachment
497        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        // Step 2: File already exists
510        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        // S3 should NOT be called
520        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        // Step 1: Create attachment
544        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        // Step 2: Authorization succeeds
557        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        // Step 3: S3 fails
572        Mock::given(method("POST"))
573            .and(path("/s3-upload"))
574            .respond_with(ResponseTemplate::new(500))
575            .mount(&server)
576            .await;
577
578        // Cleanup: DELETE the orphan attachment
579        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    /// Regression: item_template() must hit /items/new at the root,
598    /// not under /users/{id}/items/new (which returns 404).
599    #[tokio::test]
600    async fn item_template_uses_root_path() {
601        let server = MockServer::start().await;
602        let uri = server.uri();
603
604        // Mock at /items/new (root) — should be called
605        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    /// Regression: attach_file() must send [{...}] not [[{...}]] to POST /items.
625    /// The attachment template must be a single JSON object in the array,
626    /// not a nested array.
627    #[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        // Verify the body is a flat array containing an object with "itemType"
633        // If it were [[{...}]], the string would contain "[[" which we reject
634        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        // Auth returns exists=1 so we skip upload steps
649        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}