use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde_json::Value;
use crate::{CANONICAL, Linearization};
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("cannot fetch {url}")]
Fetch {
url: String,
#[source]
source: reqwest::Error,
},
#[error("{url} did not return the expected JSON")]
Shape {
url: String,
},
#[error("cannot write {path}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub struct Client {
base: String,
release: String,
http: reqwest::blocking::Client,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Downloaded {
pub fetched: usize,
pub cached: usize,
}
#[must_use]
pub fn entity_file(
cache: &Path,
linearization: Linearization,
language: &str,
id: &str,
) -> PathBuf {
cache
.join(linearization.name())
.join(language)
.join(format!("{}.json", id.replace('/', "~")))
}
#[must_use]
pub fn root_file(cache: &Path, linearization: Linearization, language: &str) -> PathBuf {
cache
.join(linearization.name())
.join(language)
.join("_root.json")
}
impl Client {
pub fn new(base: &str, release: &str) -> Result<Self, ApiError> {
let http = reqwest::blocking::Client::builder()
.build()
.map_err(|source| ApiError::Fetch {
url: base.to_owned(),
source,
})?;
Ok(Self {
base: base.trim_end_matches('/').to_owned(),
release: release.to_owned(),
http,
})
}
#[must_use]
pub fn release(&self) -> &str {
&self.release
}
fn local(&self, uri: &str) -> String {
uri.replacen(CANONICAL, &self.base, 1)
}
pub fn get(&self, url: &str, language: &str) -> Result<Value, ApiError> {
let local = self.local(url);
let fetch = |source| ApiError::Fetch {
url: local.clone(),
source,
};
self.http
.get(&local)
.header("Accept", "application/json")
.header("API-Version", "v2")
.header("Accept-Language", language)
.send()
.map_err(fetch)?
.error_for_status()
.map_err(fetch)?
.json()
.map_err(fetch)
}
pub fn root(&self, linearization: Linearization, language: &str) -> Result<Value, ApiError> {
let url = format!("{}{}", self.base, linearization.root_path(&self.release));
self.get(&url, language)
}
pub fn ids(&self, linearization: Linearization) -> Result<Vec<String>, ApiError> {
let root = self.root(linearization, "en")?;
let url = format!("{}{}", self.base, linearization.root_path(&self.release));
let children = root
.get("child")
.and_then(Value::as_array)
.ok_or_else(|| ApiError::Shape { url: url.clone() })?;
let mut ids = Vec::new();
for child in children.iter().filter_map(Value::as_str) {
let Some(id) = linearization.id_of(child) else {
continue;
};
ids.push(id);
let subtree_url = format!("{}?include=descendant", self.local(child));
let subtree = self.get(&subtree_url, "en")?;
for uri in subtree
.get("descendant")
.and_then(Value::as_array)
.ok_or_else(|| ApiError::Shape {
url: subtree_url.clone(),
})?
.iter()
.filter_map(Value::as_str)
{
if let Some(id) = linearization.id_of(uri) {
ids.push(id);
}
}
}
ids.sort();
ids.dedup();
Ok(ids)
}
fn cache_root(
&self,
cache: &Path,
linearization: Linearization,
language: &str,
) -> Result<(), ApiError> {
let dir = cache.join(linearization.name()).join(language);
std::fs::create_dir_all(&dir).map_err(|source| ApiError::Io {
path: dir.clone(),
source,
})?;
let root_path = root_file(cache, linearization, language);
if !root_path.exists() {
let root = self.root(linearization, language)?;
write_json(&root_path, &root)?;
}
Ok(())
}
fn missing(
&self,
cache: &Path,
linearization: Linearization,
ids: &[String],
languages: &[String],
) -> Result<(VecDeque<(String, String)>, usize), ApiError> {
let mut queue: VecDeque<(String, String)> = VecDeque::new();
let mut cached = 0usize;
for language in languages {
self.cache_root(cache, linearization, language)?;
for id in ids {
if entity_file(cache, linearization, language, id).exists() {
cached = cached.saturating_add(1);
} else {
queue.push_back((language.clone(), id.clone()));
}
}
}
Ok((queue, cached))
}
fn worker(
&self,
cache: &Path,
linearization: Linearization,
queue: &Mutex<VecDeque<(String, String)>>,
failure: &Mutex<Option<ApiError>>,
) {
loop {
if failure.lock().is_ok_and(|f| f.is_some()) {
return;
}
let Some((language, id)) = queue.lock().ok().and_then(|mut q| q.pop_front()) else {
return;
};
let url = format!(
"{}{}/{}",
self.base,
linearization.root_path(&self.release),
id
);
let result = self.get(&url, &language).and_then(|json| {
write_json(&entity_file(cache, linearization, &language, &id), &json)
});
if let Err(error) = result
&& let Ok(mut slot) = failure.lock()
&& slot.is_none()
{
*slot = Some(error);
}
}
}
pub fn download(
&self,
cache: &Path,
linearization: Linearization,
ids: &[String],
languages: &[String],
threads: usize,
) -> Result<Downloaded, ApiError> {
let (queue, cached) = self.missing(cache, linearization, ids, languages)?;
let total = queue.len();
let queue = Mutex::new(queue);
let failure: Mutex<Option<ApiError>> = Mutex::new(None);
std::thread::scope(|scope| {
for _ in 0..threads.max(1) {
scope.spawn(|| self.worker(cache, linearization, &queue, &failure));
}
});
if let Some(error) = failure.into_inner().ok().flatten() {
return Err(error);
}
Ok(Downloaded {
fetched: total,
cached,
})
}
}
fn write_json(path: &Path, json: &Value) -> Result<(), ApiError> {
let io = |source| ApiError::Io {
path: path.to_path_buf(),
source,
};
let text = serde_json::to_string(json).map_err(|e| io(std::io::Error::other(e)))?;
std::fs::write(path, text).map_err(io)
}