use std::{collections::HashMap, error::Error, fmt::Display};
use super::{File, Language, Page, PageHistory, Revision, SearchResult};
use anyhow::{bail, Result};
use serde::Deserialize;
use strum::{Display, IntoStaticStr};
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display)]
pub enum GetPageMode {
Url,
HTML,
Source,
}
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display, IntoStaticStr)]
pub enum WikiProject {
Wikipedia,
Wiktionary,
Wikiquote,
Wikivoyage,
Wikinews,
Wikibooks,
WikiSource,
Wikiversity,
Wikispecies,
Commons,
}
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display, IntoStaticStr)]
pub enum RevisionType {
Reverted, Anonymous, Bot, Minor, }
#[derive(Clone, Eq, PartialEq, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WikimediaError {
message_translations: HashMap<String, String>,
http_code: usize,
http_reason: String,
}
impl Display for WikimediaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
"WikimediaError ({}) {}:",
self.http_code, self.http_reason
)?;
for (lang, msg) in &self.message_translations {
writeln!(f, " {}: {}", lang, msg)?;
}
Ok(())
}
}
impl Error for WikimediaError {}
#[derive(Clone, Debug)]
pub struct Requester {
pub project: String,
pub language: Option<String>,
client: reqwest::Client,
}
impl Requester {
pub fn new(project: WikiProject, language: Option<&str>) -> Result<Self> {
let project_name = Into::<&str>::into(project).to_lowercase();
match project {
WikiProject::Commons | WikiProject::Wikispecies => {
if language.is_some() {
bail!(
"Multilingual project {} does not support language",
project_name
);
}
}
_ => {
if language.is_none() {
bail!("Project {} requires language", project_name);
}
}
}
let client = reqwest::Client::new();
Ok(Self {
project: project_name,
language: language.map(|s| s.to_string()),
client,
})
}
pub async fn search_title(
&self,
query: &str,
limit: Option<usize>,
) -> Result<Vec<SearchResult>> {
self.search_title_or_content(query, limit, true).await
}
pub async fn search_page_content(
&self,
query: &str,
limit: Option<usize>,
) -> Result<Vec<SearchResult>> {
self.search_title_or_content(query, limit, false).await
}
async fn search_title_or_content(
&self,
query: &str,
limit: Option<usize>,
is_title: bool,
) -> Result<Vec<SearchResult>> {
let limit = limit.unwrap_or(50); if !(1..=100).contains(&limit) {
bail!("Wikimedia search limit must be between 1 and 100");
}
if query.is_empty() {
bail!("Wikimedia search query cannot be empty");
}
let endp = if is_title { "title" } else { "page" };
let request_url = format!("search/{endp}");
let query = [("q", query.to_string()), ("limit", limit.to_string())];
let result: SearchResultContainer = self.get(&request_url, &query).await?;
Ok(result.pages)
}
pub async fn get_page(&self, title: &str, mode: GetPageMode) -> Result<Page> {
let mode_ = match mode {
GetPageMode::Url => "/bare",
GetPageMode::HTML => "/with_html",
GetPageMode::Source => "",
};
let request_url = format!("page/{title}{mode_}");
let result: _Page = self.get(&request_url, &[]).await?;
Ok(super::Page {
id: result.id,
key: result.key,
title: result.title,
latest: result.latest,
content_model: result.content_model,
license: result.license,
source_or_url: match mode {
GetPageMode::Url => result.html_url.unwrap(),
GetPageMode::HTML => result.html.unwrap(),
GetPageMode::Source => result.source.unwrap(),
},
})
}
pub async fn get_page_languages(&self, title: &str) -> Result<Vec<Language>> {
let request_url = format!("page/{title}/links/language");
self.get(&request_url, &[]).await
}
pub async fn get_page_files(&self, title: &str) -> Result<Vec<File>> {
let request_url = format!("page/{title}/links/media");
let files: FilesContainer = self.get(&request_url, &[]).await?;
Ok(files.files)
}
pub async fn get_file(&self, title: &str) -> Result<File> {
self.get(&format!("file/{title}"), &[]).await
}
pub async fn get_page_history(
&self,
title: &str,
older_than: Option<usize>,
newer_than: Option<usize>,
filter: Option<RevisionType>,
) -> Result<PageHistory> {
let request_url = format!("page/{title}/history");
let mut query = vec![];
if let Some(older_than) = older_than {
query.push(("older_than", older_than.to_string()));
}
if let Some(newer_than) = newer_than {
query.push(("newer_than", newer_than.to_string()));
}
if let Some(filter) = filter {
query.push(("filter", Into::<&str>::into(filter).to_string()));
}
self.get(&request_url, &query).await
}
pub async fn get_revision(&self, id: usize) -> Result<Revision> {
let request_url = format!("revision/{id}/bare");
self.get(&request_url, &[]).await
}
fn get_prefix(&self) -> String {
let lang_path = match self.language {
Some(ref lang) => format!("/{lang}"),
None => "".to_string(),
};
format!(
"https://api.wikimedia.org/core/v1/{project}{lang_path}",
project = self.project,
)
}
async fn get<T>(&self, url: &str, query: &[(&str, String)]) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
let url = format!("{}/{}", self.get_prefix(), url);
let request = self.client.get(url).query(query);
let response = request.send().await?;
if response.status().as_u16() != 200 {
Err(response.json::<WikimediaError>().await?)?
} else {
Ok(response.json::<T>().await?)
}
}
}
#[derive(Deserialize)]
struct SearchResultContainer {
pages: Vec<SearchResult>,
}
#[derive(Deserialize)]
struct FilesContainer {
files: Vec<File>,
}
#[derive(Deserialize)]
struct _Page {
pub(crate) id: usize, pub key: String, pub title: String, pub latest: super::PageLatest, pub content_model: super::ContentModel, pub license: super::License, pub html_url: Option<String>, pub html: Option<String>, pub source: Option<String>, }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn search_title_ok() {
let req = Requester::new(super::WikiProject::Wiktionary, Some("en")).unwrap();
let found = req.search_title("test", Some(50)).await;
assert!(found.is_ok());
assert!(found.unwrap().len() == 50);
let not_found = req.search_title("asdalhaoksnbdkjssad", Some(50)).await;
assert!(not_found.is_ok());
assert!(not_found.unwrap().len() == 0);
}
#[tokio::test]
async fn search_title_errs() {
let req = Requester::new(super::WikiProject::Commons, None).unwrap();
let empty_q_err = req.search_title("", None).await;
assert!(empty_q_err.is_err());
let over_limit_err = req.search_title("", Some(200)).await;
assert!(over_limit_err.is_err());
}
#[tokio::test]
async fn get_page_ok() {
let req = Requester::new(super::WikiProject::Wikibooks, Some("en")).unwrap();
let oe_url = req.get_page("Old_English", GetPageMode::Url).await;
assert!(oe_url.is_ok());
assert!(
oe_url.unwrap().source_or_url
== "https://en.wikibooks.org/w/rest.php/v1/page/Old%20English/html"
);
let oe_html = req.get_page("Old_English", GetPageMode::HTML).await;
assert!(oe_html.is_ok());
let source_html = req.get_page("Old_English", GetPageMode::Source).await;
assert!(source_html.is_ok());
}
#[tokio::test]
async fn get_page_errs() {
let req = Requester::new(super::WikiProject::Wikivoyage, Some("de")).unwrap();
let not_found_err = req.get_page("nonexistent_crap", GetPageMode::Source).await;
assert!(not_found_err.is_err());
}
#[tokio::test]
async fn get_page_languages() {
let req = Requester::new(super::WikiProject::Wikipedia, Some("en")).unwrap();
let langs = req.get_page_languages("earth").await;
assert!(langs.is_ok());
assert!(langs.unwrap().len() > 200);
let not_found_err = req.get_page_languages("asdadvcvh").await;
assert!(not_found_err.is_err());
}
#[tokio::test]
async fn get_page_files() {
let req = Requester::new(super::WikiProject::Commons, None).unwrap();
let file = req.get_page_files("storm").await;
assert!(file.is_ok());
let too_many_files = req.get_page_files("earth").await;
assert!(too_many_files.is_err());
}
}