use crate::cache::DiskCache;
use crate::error::{Result, ZoteroError};
use crate::params::{CollectionListParams, DeletedParams, FulltextParams, ItemListParams, TagListParams};
use crate::response::{PagedResponse, VersionedResponse};
use crate::types::*;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
const DEFAULT_BASE_URL: &str = "https://api.zotero.org";
fn find_zotero_exe() -> Option<String> {
let mut candidates: Vec<String> = Vec::new();
#[cfg(target_os = "windows")]
{
if let Ok(pf) = std::env::var("PROGRAMFILES") {
candidates.push(format!(r"{pf}\Zotero\zotero.exe"));
} else {
candidates.push(r"C:\Program Files\Zotero\zotero.exe".into());
}
if let Ok(local) = std::env::var("LOCALAPPDATA") {
candidates.push(format!(r"{local}\Zotero\zotero.exe"));
}
}
#[cfg(target_os = "macos")]
{
candidates.push("/Applications/Zotero.app/Contents/MacOS/zotero".into());
}
#[cfg(target_os = "linux")]
{
if let Ok(home) = std::env::var("HOME") {
candidates.push(format!("{home}/Zotero/zotero"));
}
candidates.push("/opt/Zotero/zotero".into());
candidates.push("/usr/lib/zotero/zotero".into());
candidates.push("/usr/bin/zotero".into());
}
candidates.into_iter().find(|p| std::path::Path::new(p).exists())
}
#[derive(Clone)]
pub struct ZoteroClient {
http: reqwest::Client,
base_url: String,
user_id: String,
api_key: String,
cache: Option<DiskCache>,
}
impl ZoteroClient {
pub fn new(user_id: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
base_url: DEFAULT_BASE_URL.to_string(),
user_id: user_id.into(),
api_key: api_key.into(),
cache: None,
}
}
pub fn from_env() -> Result<Self> {
let user_id = std::env::var("ZOTERO_USER_ID").map_err(|_| ZoteroError::Api {
status: 0,
message: "ZOTERO_USER_ID environment variable not set".into(),
})?;
let api_key = std::env::var("ZOTERO_API_KEY").map_err(|_| ZoteroError::Api {
status: 0,
message: "ZOTERO_API_KEY environment variable not set".into(),
})?;
Ok(Self::new(user_id, api_key))
}
pub async fn from_env_prefer_local() -> Result<Self> {
let user_id = std::env::var("ZOTERO_USER_ID").map_err(|_| ZoteroError::Api {
status: 0,
message: "ZOTERO_USER_ID environment variable not set".into(),
})?;
let api_key = std::env::var("ZOTERO_API_KEY").map_err(|_| ZoteroError::Api {
status: 0,
message: "ZOTERO_API_KEY environment variable not set".into(),
})?;
const LOCAL_BASE: &str = "http://127.0.0.1:23119/api";
let probe_url = format!("{LOCAL_BASE}/users/{user_id}/items?limit=0");
let local_ok = reqwest::Client::new()
.get(&probe_url)
.timeout(std::time::Duration::from_millis(500))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
if local_ok {
Ok(Self::new(user_id, api_key).with_base_url(LOCAL_BASE))
} else {
let skip_check = std::env::var("ZOTERO_CHECK_LAUNCHED")
.map(|v| v == "0")
.unwrap_or(false);
if !skip_check {
if let Some(path) = find_zotero_exe() {
return Err(ZoteroError::NotRunning { path });
}
}
Ok(Self::new(user_id, api_key))
}
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn with_cache(mut self, cache: DiskCache) -> Self {
self.cache = Some(cache);
self
}
fn user_prefix(&self) -> String {
format!("/users/{}", self.user_id)
}
async fn get_json_array<T: DeserializeOwned>(
&self,
path: &str,
query: Vec<(&str, String)>,
) -> Result<PagedResponse<T>> {
let url = format!("{}{}", self.base_url, path);
if let Some(cache) = &self.cache
&& let Some(text) = cache.get(&url, &query, None)
{
let cached: CachedArrayResponse =
serde_json::from_str(&text).map_err(ZoteroError::Json)?;
let items: Vec<T> =
serde_json::from_str(&cached.body).map_err(ZoteroError::Json)?;
return Ok(PagedResponse {
items,
total_results: cached.total_results,
last_modified_version: cached.last_modified_version,
});
}
let resp = self
.http
.get(&url)
.query(&query)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api {
status: status.as_u16(),
message,
});
}
let total_results = resp
.headers()
.get("Total-Results")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let last_modified_version = resp
.headers()
.get("Last-Modified-Version")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let text = resp.text().await?;
if let Some(cache) = &self.cache {
let cached = CachedArrayResponse {
body: text.clone(),
total_results,
last_modified_version,
};
if let Ok(cache_text) = serde_json::to_string(&cached) {
cache.set(&url, &query, None, &cache_text);
}
}
let items: Vec<T> = serde_json::from_str(&text).map_err(ZoteroError::Json)?;
Ok(PagedResponse {
items,
total_results,
last_modified_version,
})
}
async fn get_json_single<T: DeserializeOwned>(
&self,
path: &str,
query: Vec<(&str, String)>,
) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
if let Some(cache) = &self.cache
&& let Some(text) = cache.get(&url, &query, None)
{
return serde_json::from_str(&text).map_err(ZoteroError::Json);
}
let resp = self
.http
.get(&url)
.query(&query)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api {
status: status.as_u16(),
message,
});
}
let text = resp.text().await?;
if let Some(cache) = &self.cache {
cache.set(&url, &query, None, &text);
}
serde_json::from_str(&text).map_err(ZoteroError::Json)
}
async fn get_json_versioned<T: DeserializeOwned>(
&self,
path: &str,
query: Vec<(&str, String)>,
) -> Result<VersionedResponse<T>> {
let url = format!("{}{}", self.base_url, path);
if let Some(cache) = &self.cache
&& let Some(text) = cache.get(&url, &query, None)
{
let cached: CachedVersionedResponse =
serde_json::from_str(&text).map_err(ZoteroError::Json)?;
let data: T = serde_json::from_str(&cached.body).map_err(ZoteroError::Json)?;
return Ok(VersionedResponse {
data,
last_modified_version: cached.last_modified_version,
});
}
let resp = self
.http
.get(&url)
.query(&query)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api {
status: status.as_u16(),
message,
});
}
let last_modified_version = resp
.headers()
.get("Last-Modified-Version")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let text = resp.text().await?;
if let Some(cache) = &self.cache {
let cached = CachedVersionedResponse {
body: text.clone(),
last_modified_version,
};
if let Ok(cache_text) = serde_json::to_string(&cached) {
cache.set(&url, &query, None, &cache_text);
}
}
let data: T = serde_json::from_str(&text).map_err(ZoteroError::Json)?;
Ok(VersionedResponse {
data,
last_modified_version,
})
}
async fn get_binary(&self, path: &str) -> Result<Vec<u8>> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.get(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api {
status: status.as_u16(),
message,
});
}
Ok(resp.bytes().await?.to_vec())
}
pub async fn list_items(&self, params: &ItemListParams) -> Result<PagedResponse<Item>> {
let path = format!("{}/items", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_top_items(&self, params: &ItemListParams) -> Result<PagedResponse<Item>> {
let path = format!("{}/items/top", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_trash_items(&self, params: &ItemListParams) -> Result<PagedResponse<Item>> {
let path = format!("{}/items/trash", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn get_item(&self, key: &str) -> Result<Item> {
let path = format!("{}/items/{}", self.user_prefix(), key);
self.get_json_single(&path, vec![]).await
}
pub async fn list_item_children(
&self,
key: &str,
params: &ItemListParams,
) -> Result<PagedResponse<Item>> {
let path = format!("{}/items/{}/children", self.user_prefix(), key);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_publication_items(
&self,
params: &ItemListParams,
) -> Result<PagedResponse<Item>> {
let path = format!("{}/publications/items", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_collection_items(
&self,
collection_key: &str,
params: &ItemListParams,
) -> Result<PagedResponse<Item>> {
let path = format!(
"{}/collections/{}/items",
self.user_prefix(),
collection_key
);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_collection_top_items(
&self,
collection_key: &str,
params: &ItemListParams,
) -> Result<PagedResponse<Item>> {
let path = format!(
"{}/collections/{}/items/top",
self.user_prefix(),
collection_key
);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn download_item_file(&self, key: &str) -> Result<Vec<u8>> {
let path = format!("{}/items/{}/file", self.user_prefix(), key);
self.get_binary(&path).await
}
pub async fn create_imported_attachment(
&self,
parent_key: &str,
filename: &str,
content_type: &str,
) -> Result<String> {
let item = serde_json::json!([{
"itemType": "attachment",
"parentItem": parent_key,
"linkMode": "imported_file",
"title": filename,
"filename": filename,
"contentType": content_type,
"tags": [],
"collections": []
}]);
let path = format!("{}/items", self.user_prefix());
let resp = self.post_json_write(&path, &item).await?;
resp.successful
.get("0")
.and_then(|v| v.get("key"))
.and_then(|k| k.as_str())
.map(|s| s.to_string())
.ok_or_else(|| ZoteroError::Api {
status: 0,
message: "create_imported_attachment: no key in successful[\"0\"]".into(),
})
}
pub async fn upload_attachment_file(
&self,
attachment_key: &str,
filename: &str,
data: Vec<u8>,
) -> Result<()> {
use md5::{Digest, Md5};
let hash = Md5::digest(&data);
let md5_hex = format!("{:x}", hash);
let filesize = data.len();
let mtime = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let path = format!("{}/items/{}/file", self.user_prefix(), attachment_key);
let url = format!("{}{}", self.base_url, path);
let register_body = format!(
"md5={}&filename={}&filesize={}&mtime={}",
md5_hex, filename, filesize, mtime
);
let resp = self
.http
.post(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("If-None-Match", "*")
.body(register_body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: status.as_u16(), message });
}
let register_text = resp.text().await?;
let register_json: serde_json::Value =
serde_json::from_str(®ister_text).map_err(ZoteroError::Json)?;
if register_json.get("exists").and_then(|v| v.as_u64()) == Some(1) {
return Ok(());
}
let s3_url = register_json["url"]
.as_str()
.ok_or_else(|| ZoteroError::Api { status: 0, message: "upload response missing url".into() })?
.to_string();
let s3_content_type = register_json["contentType"]
.as_str()
.ok_or_else(|| ZoteroError::Api { status: 0, message: "upload response missing contentType".into() })?
.to_string();
let prefix = register_json["prefix"].as_str().unwrap_or("").as_bytes().to_vec();
let suffix = register_json["suffix"].as_str().unwrap_or("").as_bytes().to_vec();
let upload_key = register_json["uploadKey"]
.as_str()
.ok_or_else(|| ZoteroError::Api { status: 0, message: "upload response missing uploadKey".into() })?
.to_string();
let mut body = prefix;
body.extend_from_slice(&data);
body.extend_from_slice(&suffix);
let s3_resp = self
.http
.post(&s3_url)
.header("Content-Type", s3_content_type)
.body(body)
.send()
.await?;
let s3_status = s3_resp.status();
if !s3_status.is_success() {
let message = s3_resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: s3_status.as_u16(), message });
}
let complete_body = format!("upload={}", upload_key);
let complete_resp = self
.http
.post(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("If-None-Match", "*")
.body(complete_body)
.send()
.await?;
let complete_status = complete_resp.status();
if !complete_status.is_success() {
let message = complete_resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: complete_status.as_u16(), message });
}
Ok(())
}
pub async fn list_collections(
&self,
params: &CollectionListParams,
) -> Result<PagedResponse<Collection>> {
let path = format!("{}/collections", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_top_collections(
&self,
params: &CollectionListParams,
) -> Result<PagedResponse<Collection>> {
let path = format!("{}/collections/top", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn get_collection(&self, key: &str) -> Result<Collection> {
let path = format!("{}/collections/{}", self.user_prefix(), key);
self.get_json_single(&path, vec![]).await
}
pub async fn list_subcollections(
&self,
key: &str,
params: &CollectionListParams,
) -> Result<PagedResponse<Collection>> {
let path = format!("{}/collections/{}/collections", self.user_prefix(), key);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_searches(&self) -> Result<PagedResponse<SavedSearch>> {
let path = format!("{}/searches", self.user_prefix());
self.get_json_array(&path, vec![]).await
}
pub async fn get_search(&self, key: &str) -> Result<SavedSearch> {
let path = format!("{}/searches/{}", self.user_prefix(), key);
self.get_json_single(&path, vec![]).await
}
pub async fn list_tags(&self, params: &TagListParams) -> Result<PagedResponse<Tag>> {
let path = format!("{}/tags", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn get_tag(&self, name: &str) -> Result<PagedResponse<Tag>> {
let encoded = urlencoded(name);
let path = format!("{}/tags/{}", self.user_prefix(), encoded);
self.get_json_array(&path, vec![]).await
}
pub async fn list_item_tags(
&self,
key: &str,
params: &TagListParams,
) -> Result<PagedResponse<Tag>> {
let path = format!("{}/items/{}/tags", self.user_prefix(), key);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_items_tags(&self, params: &TagListParams) -> Result<PagedResponse<Tag>> {
let path = format!("{}/items/tags", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_top_items_tags(&self, params: &TagListParams) -> Result<PagedResponse<Tag>> {
let path = format!("{}/items/top/tags", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_trash_tags(&self, params: &TagListParams) -> Result<PagedResponse<Tag>> {
let path = format!("{}/items/trash/tags", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_collection_tags(
&self,
collection_key: &str,
params: &TagListParams,
) -> Result<PagedResponse<Tag>> {
let path = format!(
"{}/collections/{}/tags",
self.user_prefix(),
collection_key
);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_collection_items_tags(
&self,
collection_key: &str,
params: &TagListParams,
) -> Result<PagedResponse<Tag>> {
let path = format!(
"{}/collections/{}/items/tags",
self.user_prefix(),
collection_key
);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_collection_top_items_tags(
&self,
collection_key: &str,
params: &TagListParams,
) -> Result<PagedResponse<Tag>> {
let path = format!(
"{}/collections/{}/items/top/tags",
self.user_prefix(),
collection_key
);
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_publication_tags(
&self,
params: &TagListParams,
) -> Result<PagedResponse<Tag>> {
let path = format!("{}/publications/items/tags", self.user_prefix());
self.get_json_array(&path, params.to_query_pairs()).await
}
pub async fn list_groups(&self) -> Result<PagedResponse<Group>> {
let path = format!("{}/groups", self.user_prefix());
self.get_json_array(&path, vec![]).await
}
pub async fn list_fulltext_versions(
&self,
params: &FulltextParams,
) -> Result<VersionedResponse<HashMap<String, u64>>> {
let path = format!("{}/fulltext", self.user_prefix());
self.get_json_versioned(&path, params.to_query_pairs()).await
}
pub async fn get_item_fulltext(&self, key: &str) -> Result<VersionedResponse<ItemFulltext>> {
let path = format!("{}/items/{}/fulltext", self.user_prefix(), key);
match self.get_json_versioned(&path, vec![]).await {
Ok(r) => Ok(r),
Err(ZoteroError::Api { status: 404, .. }) => {
self.get_item_fulltext_from_cache(key).await
}
Err(e) => Err(e),
}
}
async fn get_item_fulltext_from_cache(
&self,
key: &str,
) -> Result<VersionedResponse<ItemFulltext>> {
let file_url_str = self.get_item_file_view_url(key).await?;
if let Ok(file_url) = reqwest::Url::parse(&file_url_str) {
if file_url.scheme() == "file" {
if let Ok(pdf_path) = file_url.to_file_path() {
let cache_path = pdf_path
.parent()
.unwrap_or(pdf_path.as_path())
.join(".zotero-ft-cache");
if cache_path.exists() {
let content =
std::fs::read_to_string(&cache_path).map_err(|e| {
ZoteroError::Api {
status: 0,
message: format!(
"local ft-cache read error ({}): {e}",
cache_path.display()
),
}
})?;
return Ok(VersionedResponse {
data: ItemFulltext {
content,
indexed_pages: None,
total_pages: None,
indexed_chars: None,
total_chars: None,
},
last_modified_version: None,
});
}
}
}
}
Err(ZoteroError::Api {
status: 404,
message: "Fulltext not indexed or cache file not found".to_string(),
})
}
pub async fn get_deleted(
&self,
params: &DeletedParams,
) -> Result<VersionedResponse<DeletedObjects>> {
let path = format!("{}/deleted", self.user_prefix());
self.get_json_versioned(&path, params.to_query_pairs()).await
}
pub async fn get_settings(
&self,
) -> Result<VersionedResponse<HashMap<String, SettingEntry>>> {
let path = format!("{}/settings", self.user_prefix());
self.get_json_versioned(&path, vec![]).await
}
pub async fn get_setting(&self, key: &str) -> Result<VersionedResponse<SettingEntry>> {
let path = format!("{}/settings/{}", self.user_prefix(), key);
self.get_json_versioned(&path, vec![]).await
}
pub async fn get_item_file_view(&self, key: &str) -> Result<Vec<u8>> {
let path = format!("{}/items/{}/file/view", self.user_prefix(), key);
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.get(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.send()
.await?;
let status = resp.status();
if status.is_success() {
return Ok(resp.bytes().await?.to_vec());
}
if status.as_u16() == 302 {
if let Some(location) = resp.headers().get("Location") {
if let Ok(loc_str) = location.to_str() {
if let Ok(file_url) = reqwest::Url::parse(loc_str) {
if file_url.scheme() == "file" {
if let Ok(file_path) = file_url.to_file_path() {
return std::fs::read(&file_path).map_err(|e| {
ZoteroError::Api {
status: 0,
message: format!(
"local file read error ({}): {e}",
file_path.display()
),
}
});
}
}
}
}
}
}
let message = resp.text().await.unwrap_or_default();
Err(ZoteroError::Api { status: status.as_u16(), message })
}
pub async fn get_item_file_view_url(&self, key: &str) -> Result<String> {
let path = format!("{}/items/{}/file/view/url", self.user_prefix(), key);
let bytes = self.get_binary(&path).await?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
async fn post_json_write(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<WriteResponse> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.post(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("Content-Type", "application/json")
.json(body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: status.as_u16(), message });
}
let text = resp.text().await?;
serde_json::from_str(&text).map_err(ZoteroError::Json)
}
async fn put_no_content(
&self,
path: &str,
version: u64,
body: &serde_json::Value,
) -> Result<()> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.put(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("Content-Type", "application/json")
.header("If-Unmodified-Since-Version", version.to_string())
.json(body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: status.as_u16(), message });
}
Ok(())
}
async fn patch_no_content(
&self,
path: &str,
version: u64,
body: &serde_json::Value,
) -> Result<()> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.patch(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("Content-Type", "application/json")
.header("If-Unmodified-Since-Version", version.to_string())
.json(body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: status.as_u16(), message });
}
Ok(())
}
async fn delete_no_content(&self, path: &str, version: u64) -> Result<()> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.delete(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("If-Unmodified-Since-Version", version.to_string())
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: status.as_u16(), message });
}
Ok(())
}
async fn delete_multiple_no_content(
&self,
path: &str,
query_key: &str,
values: &[String],
library_version: u64,
) -> Result<()> {
let url = format!("{}{}", self.base_url, path);
let joined = values.join(",");
let resp = self
.http
.delete(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("If-Unmodified-Since-Version", library_version.to_string())
.query(&[(query_key, &joined)])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: status.as_u16(), message });
}
Ok(())
}
pub async fn create_items(&self, items: Vec<serde_json::Value>) -> Result<WriteResponse> {
let path = format!("{}/items", self.user_prefix());
self.post_json_write(&path, &serde_json::Value::Array(items)).await
}
pub async fn update_item(
&self,
key: &str,
version: u64,
data: serde_json::Value,
) -> Result<()> {
let path = format!("{}/items/{}", self.user_prefix(), key);
self.put_no_content(&path, version, &data).await
}
pub async fn patch_item(
&self,
key: &str,
version: u64,
data: serde_json::Value,
) -> Result<()> {
let path = format!("{}/items/{}", self.user_prefix(), key);
self.patch_no_content(&path, version, &data).await
}
pub async fn delete_item(&self, key: &str, version: u64) -> Result<()> {
let path = format!("{}/items/{}", self.user_prefix(), key);
self.delete_no_content(&path, version).await
}
pub async fn delete_items(&self, keys: &[String], library_version: u64) -> Result<()> {
let path = format!("{}/items", self.user_prefix());
self.delete_multiple_no_content(&path, "itemKey", keys, library_version).await
}
pub async fn create_collections(
&self,
collections: Vec<serde_json::Value>,
) -> Result<WriteResponse> {
let path = format!("{}/collections", self.user_prefix());
self.post_json_write(&path, &serde_json::Value::Array(collections)).await
}
pub async fn update_collection(
&self,
key: &str,
version: u64,
data: serde_json::Value,
) -> Result<()> {
let path = format!("{}/collections/{}", self.user_prefix(), key);
self.put_no_content(&path, version, &data).await
}
pub async fn delete_collection(&self, key: &str, version: u64) -> Result<()> {
let path = format!("{}/collections/{}", self.user_prefix(), key);
self.delete_no_content(&path, version).await
}
pub async fn delete_collections(
&self,
keys: &[String],
library_version: u64,
) -> Result<()> {
let path = format!("{}/collections", self.user_prefix());
self.delete_multiple_no_content(&path, "collectionKey", keys, library_version).await
}
pub async fn create_searches(
&self,
searches: Vec<serde_json::Value>,
) -> Result<WriteResponse> {
let path = format!("{}/searches", self.user_prefix());
self.post_json_write(&path, &serde_json::Value::Array(searches)).await
}
pub async fn delete_searches(
&self,
keys: &[String],
library_version: u64,
) -> Result<()> {
let path = format!("{}/searches", self.user_prefix());
self.delete_multiple_no_content(&path, "searchKey", keys, library_version).await
}
pub async fn delete_tags(&self, tags: &[String], library_version: u64) -> Result<()> {
let path = format!("{}/tags", self.user_prefix());
let url = format!("{}{}", self.base_url, path);
let tag_param = tags
.iter()
.map(|t| urlencoded(t))
.collect::<Vec<_>>()
.join(" || ");
let resp = self
.http
.delete(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", &self.api_key)
.header("If-Unmodified-Since-Version", library_version.to_string())
.query(&[("tag", &tag_param)])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let message = resp.text().await.unwrap_or_default();
return Err(ZoteroError::Api { status: status.as_u16(), message });
}
Ok(())
}
pub async fn get_key_info(&self) -> Result<serde_json::Value> {
let path = format!("/keys/{}", self.api_key);
self.get_json_single(&path, vec![]).await
}
pub async fn get_current_key_info(&self) -> Result<serde_json::Value> {
self.get_json_single("/keys/current", vec![]).await
}
}
fn urlencoded(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~' => out.push(byte as char),
b' ' => out.push_str("%20"),
_ => {
out.push('%');
out.push(char::from(b"0123456789ABCDEF"[(byte >> 4) as usize]));
out.push(char::from(b"0123456789ABCDEF"[(byte & 0xf) as usize]));
}
}
}
out
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedArrayResponse {
body: String,
total_results: Option<u64>,
last_modified_version: Option<u64>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedVersionedResponse {
body: String,
last_modified_version: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::DiskCache;
use crate::params::{DeletedParams, FulltextParams};
use std::time::Duration;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn item_list_json() -> String {
r#"[{
"key": "ABC12345",
"version": 100,
"library": { "type": "user", "id": 1, "name": "test", "links": {} },
"links": {},
"meta": {},
"data": {
"key": "ABC12345",
"version": 100,
"itemType": "journalArticle",
"title": "Test",
"creators": [],
"tags": [],
"collections": [],
"relations": {},
"dateAdded": "2024-01-01T00:00:00Z",
"dateModified": "2024-01-01T00:00:00Z"
}
}]"#
.to_string()
}
fn single_item_json() -> String {
r#"{
"key": "ABC12345",
"version": 100,
"library": { "type": "user", "id": 1, "name": "test", "links": {} },
"links": {},
"meta": {},
"data": {
"key": "ABC12345",
"version": 100,
"itemType": "journalArticle",
"title": "Test",
"creators": [],
"tags": [],
"collections": [],
"relations": {},
"dateAdded": "2024-01-01T00:00:00Z",
"dateModified": "2024-01-01T00:00:00Z"
}
}"#
.to_string()
}
fn collection_list_json() -> String {
r#"[{
"key": "COL12345",
"version": 50,
"library": { "type": "user", "id": 1, "name": "test", "links": {} },
"links": {},
"meta": { "numCollections": 0, "numItems": 5 },
"data": {
"key": "COL12345",
"version": 50,
"name": "Test Collection",
"parentCollection": false,
"relations": {}
}
}]"#
.to_string()
}
fn tag_list_json() -> String {
r#"[{
"tag": "TestTag",
"links": {},
"meta": { "type": 0, "numItems": 3 }
}]"#
.to_string()
}
async fn setup_client(server: &MockServer) -> ZoteroClient {
ZoteroClient::new("12345", "test-key").with_base_url(server.uri())
}
fn array_response(body: &str) -> ResponseTemplate {
ResponseTemplate::new(200)
.set_body_string(body.to_string())
.insert_header("Total-Results", "42")
.insert_header("Last-Modified-Version", "100")
}
#[tokio::test]
async fn test_list_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items"))
.and(header("Zotero-API-Version", "3"))
.and(header("Zotero-API-Key", "test-key"))
.respond_with(array_response(&item_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.list_items(&ItemListParams::default()).await.unwrap();
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.total_results, Some(42));
assert_eq!(resp.last_modified_version, Some(100));
assert_eq!(resp.items[0].key, "ABC12345");
}
#[tokio::test]
async fn test_list_top_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/top"))
.respond_with(array_response(&item_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_top_items(&ItemListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_list_trash_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/trash"))
.respond_with(array_response(&item_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_trash_items(&ItemListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_get_item() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/ABC12345"))
.respond_with(ResponseTemplate::new(200).set_body_string(single_item_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let item = client.get_item("ABC12345").await.unwrap();
assert_eq!(item.key, "ABC12345");
}
#[tokio::test]
async fn test_list_item_children() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/ABC12345/children"))
.respond_with(array_response(&item_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_item_children("ABC12345", &ItemListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_list_collection_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/collections/COL1/items"))
.respond_with(array_response(&item_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_collection_items("COL1", &ItemListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_list_collection_top_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/collections/COL1/items/top"))
.respond_with(array_response(&item_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_collection_top_items("COL1", &ItemListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_item_list_with_params() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items"))
.and(query_param("q", "test"))
.and(query_param("itemType", "book"))
.and(query_param("sort", "dateModified"))
.and(query_param("direction", "desc"))
.and(query_param("limit", "5"))
.respond_with(array_response(&item_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let params = ItemListParams::builder()
.q("test")
.item_type("book")
.sort("dateModified")
.direction("desc")
.limit(5)
.build();
let resp = client.list_items(¶ms).await.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_list_collections() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/collections"))
.respond_with(array_response(&collection_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_collections(&CollectionListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.items[0].data.name, "Test Collection");
}
#[tokio::test]
async fn test_list_top_collections() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/collections/top"))
.respond_with(array_response(&collection_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_top_collections(&CollectionListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_get_collection() {
let server = MockServer::start().await;
let single_json = r#"{
"key": "COL12345",
"version": 50,
"library": { "type": "user", "id": 1, "name": "test", "links": {} },
"links": {},
"meta": {},
"data": { "key": "COL12345", "version": 50, "name": "Test", "parentCollection": false, "relations": {} }
}"#;
Mock::given(method("GET"))
.and(path("/users/12345/collections/COL12345"))
.respond_with(ResponseTemplate::new(200).set_body_string(single_json))
.mount(&server)
.await;
let client = setup_client(&server).await;
let coll = client.get_collection("COL12345").await.unwrap();
assert_eq!(coll.key, "COL12345");
}
#[tokio::test]
async fn test_list_subcollections() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/collections/COL1/collections"))
.respond_with(array_response(&collection_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_subcollections("COL1", &CollectionListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_list_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/tags"))
.respond_with(array_response(&tag_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.list_tags(&TagListParams::default()).await.unwrap();
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.items[0].tag, "TestTag");
}
#[tokio::test]
async fn test_list_items_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/tags"))
.respond_with(array_response(&tag_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_items_tags(&TagListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_list_collection_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/collections/COL1/tags"))
.respond_with(array_response(&tag_list_json()))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_collection_tags("COL1", &TagListParams::default())
.await
.unwrap();
assert_eq!(resp.items.len(), 1);
}
#[tokio::test]
async fn test_list_searches() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/searches"))
.respond_with(array_response("[]"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.list_searches().await.unwrap();
assert!(resp.items.is_empty());
}
#[tokio::test]
async fn test_list_groups() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/groups"))
.respond_with(array_response("[]"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.list_groups().await.unwrap();
assert!(resp.items.is_empty());
}
#[tokio::test]
async fn test_error_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/NOTFOUND"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client.get_item("NOTFOUND").await.unwrap_err();
match err {
ZoteroError::Api { status, message } => {
assert_eq!(status, 404);
assert_eq!(message, "Not found");
}
_ => panic!("Expected Api error, got {:?}", err),
}
}
#[tokio::test]
async fn test_error_403() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client
.list_items(&ItemListParams::default())
.await
.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 403),
_ => panic!("Expected Api error"),
}
}
#[tokio::test]
async fn test_header_extraction() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("[]")
.insert_header("Total-Results", "999")
.insert_header("Last-Modified-Version", "42"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.list_items(&ItemListParams::default()).await.unwrap();
assert_eq!(resp.total_results, Some(999));
assert_eq!(resp.last_modified_version, Some(42));
}
#[tokio::test]
async fn test_missing_headers() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items"))
.respond_with(ResponseTemplate::new(200).set_body_string("[]"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.list_items(&ItemListParams::default()).await.unwrap();
assert_eq!(resp.total_results, None);
assert_eq!(resp.last_modified_version, None);
}
fn temp_cache() -> DiskCache {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::{SystemTime, UNIX_EPOCH};
let mut h = DefaultHasher::new();
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
.hash(&mut h);
std::thread::current().id().hash(&mut h);
let dir = std::env::temp_dir()
.join("papers-zotero-test-cache")
.join(format!("{:x}", h.finish()));
DiskCache::new(dir, Duration::from_secs(600)).unwrap()
}
#[tokio::test]
async fn test_cache_hit_avoids_second_request() {
let server = MockServer::start().await;
let mock = Mock::given(method("GET"))
.and(path("/users/12345/items"))
.respond_with(array_response(&item_list_json()))
.expect(1)
.named("list_items")
.mount_as_scoped(&server)
.await;
let client = ZoteroClient::new("12345", "test-key")
.with_base_url(server.uri())
.with_cache(temp_cache());
let resp1 = client.list_items(&ItemListParams::default()).await.unwrap();
assert_eq!(resp1.items.len(), 1);
let resp2 = client.list_items(&ItemListParams::default()).await.unwrap();
assert_eq!(resp2.items.len(), 1);
assert_eq!(resp2.total_results, Some(42));
drop(mock);
}
#[tokio::test]
async fn test_cache_error_not_cached() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/bad"))
.respond_with(ResponseTemplate::new(500).set_body_string("error"))
.expect(2)
.mount(&server)
.await;
let client = ZoteroClient::new("12345", "test-key")
.with_base_url(server.uri())
.with_cache(temp_cache());
let _ = client.get_item("bad").await;
let _ = client.get_item("bad").await;
}
#[tokio::test]
async fn test_download_item_file() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/ATTACH1/file"))
.and(header("Zotero-API-Version", "3"))
.and(header("Zotero-API-Key", "test-key"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(b"fake-pdf-bytes".to_vec()),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let bytes = client.download_item_file("ATTACH1").await.unwrap();
assert_eq!(bytes, b"fake-pdf-bytes");
}
#[tokio::test]
async fn test_download_item_file_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/MISSING/file"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client.download_item_file("MISSING").await.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 404),
_ => panic!("Expected Api error, got {:?}", err),
}
}
#[test]
fn test_urlencoded() {
assert_eq!(urlencoded("simple"), "simple");
assert_eq!(urlencoded("with space"), "with%20space");
assert_eq!(urlencoded("special/chars&more"), "special%2Fchars%26more");
}
#[tokio::test]
async fn test_list_fulltext_versions() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/fulltext"))
.and(header("Zotero-API-Version", "3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"ZLIKNFNF":1385,"EYNDSWQJ":1399}"#)
.insert_header("Last-Modified-Version", "4384"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_fulltext_versions(&FulltextParams::default())
.await
.unwrap();
assert_eq!(resp.last_modified_version, Some(4384));
assert_eq!(resp.data.len(), 2);
assert_eq!(resp.data["ZLIKNFNF"], 1385);
assert_eq!(resp.data["EYNDSWQJ"], 1399);
}
#[tokio::test]
async fn test_list_fulltext_versions_since_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/fulltext"))
.and(query_param("since", "1380"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"ZLIKNFNF":1385}"#)
.insert_header("Last-Modified-Version", "4384"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let params = FulltextParams::builder().since(1380u64).build();
let resp = client.list_fulltext_versions(¶ms).await.unwrap();
assert_eq!(resp.data.len(), 1);
}
#[tokio::test]
async fn test_list_fulltext_versions_empty() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/fulltext"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("{}")
.insert_header("Last-Modified-Version", "4384"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.list_fulltext_versions(&FulltextParams::default())
.await
.unwrap();
assert!(resp.data.is_empty());
assert_eq!(resp.last_modified_version, Some(4384));
}
#[tokio::test]
async fn test_get_item_fulltext() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/ATTACH1/fulltext"))
.and(header("Zotero-API-Version", "3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
r#"{"content":"Distance Transforms of Sampled Functions","indexedPages":14,"totalPages":14}"#,
)
.insert_header("Last-Modified-Version", "13"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.get_item_fulltext("ATTACH1").await.unwrap();
assert_eq!(resp.last_modified_version, Some(13));
assert_eq!(resp.data.indexed_pages, Some(14));
assert_eq!(resp.data.total_pages, Some(14));
assert!(resp.data.content.contains("Distance Transforms"));
}
#[tokio::test]
async fn test_get_item_fulltext_not_indexed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/NOTEXT/fulltext"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client.get_item_fulltext("NOTEXT").await.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 404),
_ => panic!("Expected Api error"),
}
}
#[tokio::test]
async fn test_get_item_fulltext_char_indexed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/HTML1/fulltext"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
r#"{"content":"Some web content","indexedChars":500,"totalChars":1000}"#,
)
.insert_header("Last-Modified-Version", "77"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.get_item_fulltext("HTML1").await.unwrap();
assert_eq!(resp.data.indexed_chars, Some(500));
assert_eq!(resp.data.total_chars, Some(1000));
assert!(resp.data.indexed_pages.is_none());
}
#[tokio::test]
async fn test_get_deleted() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/deleted"))
.and(query_param("since", "0"))
.and(header("Zotero-API-Version", "3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
r#"{"collections":["2WDMI6DR"],"items":["23IAQK5A","24F9PQTC"],"searches":[],"tags":["old tag"],"settings":[]}"#,
)
.insert_header("Last-Modified-Version", "4384"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let params = DeletedParams::builder().since(0u64).build();
let resp = client.get_deleted(¶ms).await.unwrap();
assert_eq!(resp.last_modified_version, Some(4384));
assert_eq!(resp.data.collections, vec!["2WDMI6DR"]);
assert_eq!(resp.data.items.len(), 2);
assert_eq!(resp.data.tags, vec!["old tag"]);
assert!(resp.data.searches.is_empty());
assert!(resp.data.settings.is_empty());
}
#[tokio::test]
async fn test_get_deleted_since_param_forwarded() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/deleted"))
.and(query_param("since", "1000"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
r#"{"collections":[],"items":[],"searches":[],"tags":[],"settings":[]}"#,
)
.insert_header("Last-Modified-Version", "4384"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let params = DeletedParams::builder().since(1000u64).build();
let resp = client.get_deleted(¶ms).await.unwrap();
assert!(resp.data.items.is_empty());
}
#[tokio::test]
async fn test_get_deleted_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/deleted"))
.respond_with(ResponseTemplate::new(400).set_body_string("since parameter required"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let params = DeletedParams::builder().since(0u64).build();
let err = client.get_deleted(¶ms).await.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 400),
_ => panic!("Expected Api error"),
}
}
#[tokio::test]
async fn test_get_settings() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/settings"))
.and(header("Zotero-API-Version", "3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
r##"{"tagColors":{"value":[{"name":"Starred","color":"#FF8C19"}],"version":3826}}"##,
)
.insert_header("Last-Modified-Version", "4384"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.get_settings().await.unwrap();
assert_eq!(resp.last_modified_version, Some(4384));
let tag_colors = resp.data.get("tagColors").unwrap();
assert_eq!(tag_colors.version, 3826);
assert!(tag_colors.value.is_array());
}
#[tokio::test]
async fn test_get_settings_empty() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/settings"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("{}")
.insert_header("Last-Modified-Version", "100"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.get_settings().await.unwrap();
assert!(resp.data.is_empty());
}
#[tokio::test]
async fn test_get_setting() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/settings/tagColors"))
.and(header("Zotero-API-Version", "3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
r##"{"value":[{"name":"Starred","color":"#FF8C19"},{"name":"Survey","color":"#FF6666"}],"version":3826}"##,
)
.insert_header("Last-Modified-Version", "3826"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client.get_setting("tagColors").await.unwrap();
assert_eq!(resp.last_modified_version, Some(3826));
assert_eq!(resp.data.version, 3826);
let arr = resp.data.value.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "Starred");
}
#[tokio::test]
async fn test_get_setting_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/settings/nonexistent"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client.get_setting("nonexistent").await.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 404),
_ => panic!("Expected Api error"),
}
}
#[tokio::test]
async fn test_get_item_file_view() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/ATTACH1/file/view"))
.and(header("Zotero-API-Version", "3"))
.and(header("Zotero-API-Key", "test-key"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(b"fake-pdf-bytes".to_vec()),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let bytes = client.get_item_file_view("ATTACH1").await.unwrap();
assert_eq!(bytes, b"fake-pdf-bytes");
}
#[tokio::test]
async fn test_get_item_file_view_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/NOFILE/file/view"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client.get_item_file_view("NOFILE").await.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 404),
_ => panic!("Expected Api error"),
}
}
#[tokio::test]
async fn test_get_item_file_view_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/ATTACH1/file/view/url"))
.and(header("Zotero-API-Version", "3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("https://files.zotero.net/abc123/paper.pdf"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let url = client.get_item_file_view_url("ATTACH1").await.unwrap();
assert_eq!(url, "https://files.zotero.net/abc123/paper.pdf");
}
#[tokio::test]
async fn test_get_item_file_view_url_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/12345/items/NOFILE/file/view/url"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client.get_item_file_view_url("NOFILE").await.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 404),
_ => panic!("Expected Api error"),
}
}
#[tokio::test]
async fn test_get_current_key_info() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/keys/current"))
.and(header("Zotero-API-Version", "3"))
.and(header("Zotero-API-Key", "test-key"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"key":"test-key","userID":12345,"username":"testuser","displayName":"","access":{"user":{"library":true,"files":true,"notes":true},"groups":{"all":{"library":true,"write":false}}}}"#,
),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let info = client.get_current_key_info().await.unwrap();
assert_eq!(info["userID"], 12345);
assert_eq!(info["username"], "testuser");
assert_eq!(info["access"]["user"]["library"], true);
}
#[tokio::test]
async fn test_get_current_key_info_forbidden() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/keys/current"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client.get_current_key_info().await.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 403),
_ => panic!("Expected Api error"),
}
}
fn item_write_response_json(key: &str) -> String {
format!(
r#"{{"successful":{{"0":{{"key":"{key}","version":1,"library":{{"type":"user","id":1,"name":"test","links":{{}}}},"links":{{}},"meta":{{}},"data":{{"key":"{key}","version":1,"itemType":"note","note":"test"}}}}}},"unchanged":{{}},"failed":{{}}}}"#
)
}
fn collection_write_response_json(key: &str) -> String {
format!(
r#"{{"successful":{{"0":{{"key":"{key}","version":1,"library":{{"type":"user","id":1,"name":"test","links":{{}}}},"links":{{}},"meta":{{"numCollections":0,"numItems":0}},"data":{{"key":"{key}","version":1,"name":"Test Collection","parentCollection":false,"relations":{{}}}}}}}},"unchanged":{{}},"failed":{{}}}}"#
)
}
fn search_write_response_json(key: &str) -> String {
format!(
r#"{{"successful":{{"0":{{"key":"{key}","version":1,"library":{{"type":"user","id":1,"name":"test","links":{{}}}},"links":{{}},"data":{{"key":"{key}","version":1,"name":"Test Search","conditions":[]}}}}}},"unchanged":{{}},"failed":{{}}}}"#
)
}
#[tokio::test]
async fn test_create_items() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/users/12345/items"))
.and(header("Zotero-API-Version", "3"))
.and(header("Zotero-API-Key", "test-key"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(item_write_response_json("NEW12345"))
.insert_header("Last-Modified-Version", "101"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.create_items(vec![serde_json::json!({"itemType": "note", "note": "test"})])
.await
.unwrap();
assert!(resp.is_ok());
assert_eq!(resp.successful_keys(), vec!["NEW12345"]);
assert!(resp.unchanged.is_empty());
assert!(resp.failed.is_empty());
}
#[tokio::test]
async fn test_create_items_partial_failure() {
let server = MockServer::start().await;
let body = r#"{"successful":{"0":{"key":"OK123456","version":1,"library":{"type":"user","id":1,"name":"test","links":{}},"links":{},"meta":{},"data":{"key":"OK123456","version":1,"itemType":"note"}}},"unchanged":{},"failed":{"1":{"key":null,"code":400,"message":"Invalid item type"}}}"#;
Mock::given(method("POST"))
.and(path("/users/12345/items"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.create_items(vec![
serde_json::json!({"itemType": "note"}),
serde_json::json!({"itemType": "badType"}),
])
.await
.unwrap();
assert!(!resp.is_ok());
assert_eq!(resp.successful.len(), 1);
assert_eq!(resp.failed.len(), 1);
assert_eq!(resp.failed["1"].code, 400);
}
#[tokio::test]
async fn test_update_item() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/users/12345/items/ABC12345"))
.and(header("If-Unmodified-Since-Version", "100"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client
.update_item("ABC12345", 100, serde_json::json!({"itemType": "note", "note": "updated"}))
.await
.unwrap();
}
#[tokio::test]
async fn test_patch_item() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/users/12345/items/ABC12345"))
.and(header("If-Unmodified-Since-Version", "100"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client
.patch_item("ABC12345", 100, serde_json::json!({"note": "patched"}))
.await
.unwrap();
}
#[tokio::test]
async fn test_delete_item() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/users/12345/items/ABC12345"))
.and(header("If-Unmodified-Since-Version", "100"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client.delete_item("ABC12345", 100).await.unwrap();
}
#[tokio::test]
async fn test_delete_items() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/users/12345/items"))
.and(query_param("itemKey", "ABC12345,DEF67890"))
.and(header("If-Unmodified-Since-Version", "200"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client
.delete_items(
&["ABC12345".to_string(), "DEF67890".to_string()],
200,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_write_precondition_failed() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/users/12345/items/ABC12345"))
.respond_with(
ResponseTemplate::new(412).set_body_string("Precondition Failed"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let err = client
.update_item("ABC12345", 99, serde_json::json!({"note": "stale"}))
.await
.unwrap_err();
match err {
ZoteroError::Api { status, .. } => assert_eq!(status, 412),
_ => panic!("Expected Api error"),
}
}
#[tokio::test]
async fn test_create_collections() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/users/12345/collections"))
.and(header("Zotero-API-Version", "3"))
.and(header("Zotero-API-Key", "test-key"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(collection_write_response_json("NEWCOL01"))
.insert_header("Last-Modified-Version", "102"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.create_collections(vec![serde_json::json!({"name": "My Collection"})])
.await
.unwrap();
assert!(resp.is_ok());
assert_eq!(resp.successful_keys(), vec!["NEWCOL01"]);
}
#[tokio::test]
async fn test_update_collection() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/users/12345/collections/COL12345"))
.and(header("If-Unmodified-Since-Version", "50"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client
.update_collection(
"COL12345",
50,
serde_json::json!({"key": "COL12345", "version": 50, "name": "New Name", "parentCollection": false}),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_delete_collection() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/users/12345/collections/COL12345"))
.and(header("If-Unmodified-Since-Version", "50"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client.delete_collection("COL12345", 50).await.unwrap();
}
#[tokio::test]
async fn test_delete_collections() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/users/12345/collections"))
.and(query_param("collectionKey", "COL12345,COL67890"))
.and(header("If-Unmodified-Since-Version", "200"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client
.delete_collections(
&["COL12345".to_string(), "COL67890".to_string()],
200,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_create_searches() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/users/12345/searches"))
.and(header("Zotero-API-Version", "3"))
.and(header("Zotero-API-Key", "test-key"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(search_write_response_json("SRCH0001"))
.insert_header("Last-Modified-Version", "103"),
)
.mount(&server)
.await;
let client = setup_client(&server).await;
let resp = client
.create_searches(vec![serde_json::json!({
"name": "My Search",
"conditions": [{"condition": "tag", "operator": "is", "value": "test"}]
})])
.await
.unwrap();
assert!(resp.is_ok());
assert_eq!(resp.successful_keys(), vec!["SRCH0001"]);
}
#[tokio::test]
async fn test_delete_searches() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/users/12345/searches"))
.and(query_param("searchKey", "SRCH0001,SRCH0002"))
.and(header("If-Unmodified-Since-Version", "200"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client
.delete_searches(
&["SRCH0001".to_string(), "SRCH0002".to_string()],
200,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_delete_tags() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/users/12345/tags"))
.and(query_param("tag", "test-tag"))
.and(header("If-Unmodified-Since-Version", "200"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = setup_client(&server).await;
client
.delete_tags(&["test-tag".to_string()], 200)
.await
.unwrap();
}
}