use std::time::Duration;
use serde_json::Value;
pub const DEFAULT_API_URL: &str = "https://api.zotero.org/";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_KEY_LENGTH: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
pub user_id: u64,
pub username: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Rejected,
Unreachable(String),
}
pub async fn account(http: &reqwest::Client, api_url: &str, key: &str) -> Result<Account, Refusal> {
if key.is_empty()
|| key.len() > MAX_KEY_LENGTH
|| !key.bytes().all(|b| b.is_ascii_alphanumeric())
{
return Err(Refusal::Rejected);
}
let url = format!("{}/keys/current", api_url.trim_end_matches('/'));
let response = http
.get(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", key)
.timeout(REQUEST_TIMEOUT)
.send()
.await
.map_err(|e| Refusal::Unreachable(e.without_url().to_string()))?;
let status = response.status();
if matches!(status.as_u16(), 400 | 401 | 403 | 404) {
return Err(Refusal::Rejected);
}
if !status.is_success() {
return Err(Refusal::Unreachable(format!("zotero.org: {status}")));
}
let body: Value = response
.json()
.await
.map_err(|e| Refusal::Unreachable(format!("zotero.org: not JSON: {}", e.without_url())))?;
let user_id = body
.get("userID")
.and_then(Value::as_u64)
.filter(|id| *id > 0)
.ok_or_else(|| Refusal::Unreachable("zotero.org: no userID".to_string()))?;
let username = body
.get("username")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("")
.to_string();
Ok(Account { user_id, username })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Group {
pub id: u64,
pub name: String,
pub description: String,
pub library_editing: String,
pub file_editing: String,
pub owner: u64,
pub admins: Vec<u64>,
pub members: Option<Vec<u64>>,
pub kind: String,
pub library_reading: String,
pub url: String,
}
const GROUPS_PAGE: usize = 100;
pub async fn groups(
http: &reqwest::Client,
api_url: &str,
key: &str,
user_id: u64,
) -> Result<Vec<Group>, Refusal> {
let mut out = Vec::new();
loop {
let url = format!(
"{}/users/{user_id}/groups?limit={GROUPS_PAGE}&start={}",
api_url.trim_end_matches('/'),
out.len()
);
let response = http
.get(&url)
.header("Zotero-API-Version", "3")
.header("Zotero-API-Key", key)
.timeout(REQUEST_TIMEOUT)
.send()
.await
.map_err(|e| Refusal::Unreachable(e.without_url().to_string()))?;
let status = response.status();
if matches!(status.as_u16(), 400 | 401 | 403 | 404) {
return Err(Refusal::Rejected);
}
if !status.is_success() {
return Err(Refusal::Unreachable(format!("zotero.org: {status}")));
}
let total = response
.headers()
.get("total-results")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<usize>().ok());
let page: Vec<Value> = response.json().await.map_err(|e| {
Refusal::Unreachable(format!("zotero.org: not JSON: {}", e.without_url()))
})?;
let count = page.len();
out.extend(page.iter().filter_map(group));
if count < GROUPS_PAGE || total.is_some_and(|total| out.len() >= total) {
return Ok(out);
}
}
}
fn group(entry: &Value) -> Option<Group> {
let data = entry.get("data")?;
let text = |k: &str| {
data.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let ids = |k: &str| {
data.get(k)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_u64).collect::<Vec<_>>())
};
let id = data
.get("id")
.and_then(Value::as_u64)
.filter(|id| *id > 0)?;
Some(Group {
id,
name: text("name"),
description: text("description"),
library_editing: match text("libraryEditing").as_str() {
"members" => "members",
_ => "admins",
}
.to_string(),
file_editing: match text("fileEditing").as_str() {
"members" => "members",
"admins" => "admins",
_ => "none",
}
.to_string(),
owner: data.get("owner").and_then(Value::as_u64).unwrap_or(0),
admins: ids("admins").unwrap_or_default(),
members: ids("members"),
kind: match text("type").as_str() {
"PublicOpen" => "PublicOpen",
"PublicClosed" => "PublicClosed",
_ => "Private",
}
.to_string(),
library_reading: match text("libraryReading").as_str() {
"all" => "all",
_ => "members",
}
.to_string(),
url: Some(text("url"))
.filter(|u| u.starts_with("https://") || u.starts_with("http://"))
.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Json, Router, http::HeaderMap, http::StatusCode, routing::get};
use serde_json::json;
async fn mock() -> String {
let app = Router::new().route(
"/keys/current",
get(|headers: HeaderMap| async move {
match headers.get("zotero-api-key").and_then(|v| v.to_str().ok()) {
Some("good") => (
StatusCode::OK,
Json(json!({"key": "good", "userID": 4711, "username": "ada"})),
),
Some("noid") => (StatusCode::OK, Json(json!({"key": "noid"}))),
Some("broken") => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))),
_ => (StatusCode::FORBIDDEN, Json(json!("Invalid key"))),
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
url
}
#[tokio::test]
async fn asks_whose_key_it_is() {
let url = mock().await;
let http = reqwest::Client::new();
assert_eq!(
account(&http, &url, "good").await,
Ok(Account {
user_id: 4711,
username: "ada".into()
})
);
for rejected in ["wrong", "", "has space", "line\nbreak", &"a".repeat(65)] {
assert_eq!(
account(&http, &url, rejected).await,
Err(Refusal::Rejected),
"{rejected:?}"
);
}
for unreachable in ["broken", "noid"] {
assert!(matches!(
account(&http, &url, unreachable).await,
Err(Refusal::Unreachable(_))
));
}
assert!(matches!(
account(&http, "http://127.0.0.1:9/", "good").await,
Err(Refusal::Unreachable(_))
));
}
}