wikimedia-api 0.1.1

Interact with Wikimedia REST API.
Documentation
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 {
    // See page::Page for more information
    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,  // Revisions that revert an earlier edit
    Anonymous, // Revisions made by anonymous users
    Bot,       // Revisions made by bots
    Minor,     // Revisions marked as minor edits
}

#[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,
        })
    }

    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Search/Search_titles
    pub async fn search_title(
        &self,
        query: &str,
        limit: Option<usize>,
    ) -> Result<Vec<SearchResult>> {
        self.search_title_or_content(query, limit, true).await
    }

    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Search/Search_content
    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); // Wikimedia default
        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)
    }

    // Get page, get page offline, and get page source -- in one function.
    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_page
    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_page_offline
    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_page_source
    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(),
            },
        })
    }

    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_languages
    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
    }

    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_files
    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)
    }

    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Media_files/Get_file
    pub async fn get_file(&self, title: &str) -> Result<File> {
        self.get(&format!("file/{title}"), &[]).await
    }

    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Get_page_history
    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");
        // ?filter=bot&older_than=981126172
        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
    }

    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Get_revision
    pub async fn get_revision(&self, id: usize) -> Result<Revision> {
        let request_url = format!("revision/{id}/bare");
        self.get(&request_url, &[]).await
    }

    // TODO: https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Get_revision_stats
    // TODO: https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Compare_revisions

    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,                   // Page identifier
    pub key: String,                        // Page title in URL-friendly format
    pub title: String,                      // Page title in reading-friendly format
    pub latest: super::PageLatest,          // Latest revision of the page
    pub content_model: super::ContentModel, // Content model used for the page
    pub license: super::License,            // Information about the wiki's license
    // Get page only:
    pub html_url: Option<String>, // API route to fetch the content of the page in HTML
    // Get page offline only:
    pub html: Option<String>, // Latest page content in HTML
    //  Get page source, create page, and edit page only:
    pub source: Option<String>, // Latest page content in the format specified by the content_model property
}

#[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());
        // The page "earth" on wikipedia has something like 292 languages
        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());
    }

    // TODO: test get_page_history, get_revision
}