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 })
}
#[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(_))
));
}
}