use std::collections::HashMap;
use tokio::sync::OnceCell;
use wikibase::mediawiki::api::Api;
pub const DEFAULT_WIKIDATA_API_URL: &str = "https://www.wikidata.org/w/api.php";
pub const API_URL_ENV: &str = "PAPERS_WIKIDATA_API";
const LANGUAGE_SPARQL: &str =
"SELECT DISTINCT ?l ?q { ?q wdt:P31/wdt:P279* wd:Q20162172; (wdt:P219|wdt:P220) ?l }";
pub struct LanguageCache {
map: OnceCell<HashMap<String, String>>,
api_url: String,
}
impl LanguageCache {
pub fn new(api_url: impl Into<String>) -> Self {
Self { map: OnceCell::new(), api_url: api_url.into() }
}
pub fn wikidata() -> Self {
let url = std::env::var(API_URL_ENV)
.unwrap_or_else(|_| DEFAULT_WIKIDATA_API_URL.to_string());
Self::new(url)
}
pub async fn get(&self, language: &str) -> Option<String> {
self.map.get_or_init(|| self.load()).await.get(language).cloned()
}
async fn load(&self) -> HashMap<String, String> {
match Self::try_load(&self.api_url).await {
Ok(map) => {
tracing::info!(entries = map.len(), "LanguageCache loaded");
map
},
Err(e) => {
tracing::warn!(
error = %e,
api_url = %self.api_url,
"LanguageCache: SPARQL load failed; degrading to empty cache",
);
HashMap::new()
},
}
}
async fn try_load(api_url: &str) -> Result<HashMap<String, String>, anyhow::Error> {
let mw_api = Api::new(api_url).await?;
let json = mw_api.sparql_query(LANGUAGE_SPARQL).await?;
let bindings = json["results"]["bindings"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("LanguageCache: SPARQL response missing bindings"))?;
Ok(bindings
.iter()
.filter_map(|j| {
let l = j["l"]["value"].as_str()?;
let q = mw_api.extract_entity_from_uri(j["q"]["value"].as_str()?).ok()?;
Some((l.to_string(), q.to_string()))
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const SITEINFO: &str = include_str!("../test_data/api_siteinfo.json");
#[tokio::test]
async fn get_returns_none_when_sparql_fails_without_panic() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(query_param("meta", "siteinfo"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json; charset=utf-8")
.set_body_string(SITEINFO),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let cache = LanguageCache::new(server.uri());
assert!(cache.get("en").await.is_none(), "expected None on SPARQL failure");
assert!(cache.get("de").await.is_none());
}
#[test]
fn wikidata_constructor_honours_env_var_and_default() {
let prev = std::env::var(API_URL_ENV).ok();
std::env::set_var(API_URL_ENV, "http://example.test/api.php");
let c = LanguageCache::wikidata();
assert_eq!(c.api_url, "http://example.test/api.php");
std::env::remove_var(API_URL_ENV);
let c = LanguageCache::wikidata();
assert_eq!(c.api_url, DEFAULT_WIKIDATA_API_URL);
match prev {
Some(v) => std::env::set_var(API_URL_ENV, v),
None => std::env::remove_var(API_URL_ENV),
}
}
}