Skip to main content

wikimedia_api/endpoints/
requester.rs

1use std::{collections::HashMap, error::Error, fmt::Display};
2
3use super::{File, Language, Page, PageHistory, Revision, SearchResult};
4use anyhow::{bail, Result};
5use serde::Deserialize;
6use strum::{Display, IntoStaticStr};
7
8#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display)]
9pub enum GetPageMode {
10    // See page::Page for more information
11    Url,
12    HTML,
13    Source,
14}
15
16#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display, IntoStaticStr)]
17pub enum WikiProject {
18    Wikipedia,
19    Wiktionary,
20    Wikiquote,
21    Wikivoyage,
22    Wikinews,
23    Wikibooks,
24    WikiSource,
25    Wikiversity,
26    Wikispecies,
27    Commons,
28}
29
30#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display, IntoStaticStr)]
31pub enum RevisionType {
32    Reverted,  // Revisions that revert an earlier edit
33    Anonymous, // Revisions made by anonymous users
34    Bot,       // Revisions made by bots
35    Minor,     // Revisions marked as minor edits
36}
37
38#[derive(Clone, Eq, PartialEq, Debug, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct WikimediaError {
41    message_translations: HashMap<String, String>,
42    http_code: usize,
43    http_reason: String,
44}
45
46impl Display for WikimediaError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        writeln!(
49            f,
50            "WikimediaError ({}) {}:",
51            self.http_code, self.http_reason
52        )?;
53        for (lang, msg) in &self.message_translations {
54            writeln!(f, "  {}: {}", lang, msg)?;
55        }
56        Ok(())
57    }
58}
59
60impl Error for WikimediaError {}
61
62#[derive(Clone, Debug)]
63pub struct Requester {
64    pub project: String,
65    pub language: Option<String>,
66    client: reqwest::Client,
67}
68
69impl Requester {
70    pub fn new(project: WikiProject, language: Option<&str>) -> Result<Self> {
71        let project_name = Into::<&str>::into(project).to_lowercase();
72        match project {
73            WikiProject::Commons | WikiProject::Wikispecies => {
74                if language.is_some() {
75                    bail!(
76                        "Multilingual project {} does not support language",
77                        project_name
78                    );
79                }
80            }
81            _ => {
82                if language.is_none() {
83                    bail!("Project {} requires language", project_name);
84                }
85            }
86        }
87        let client = reqwest::Client::new();
88        Ok(Self {
89            project: project_name,
90            language: language.map(|s| s.to_string()),
91            client,
92        })
93    }
94
95    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Search/Search_titles
96    pub async fn search_title(
97        &self,
98        query: &str,
99        limit: Option<usize>,
100    ) -> Result<Vec<SearchResult>> {
101        self.search_title_or_content(query, limit, true).await
102    }
103
104    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Search/Search_content
105    pub async fn search_page_content(
106        &self,
107        query: &str,
108        limit: Option<usize>,
109    ) -> Result<Vec<SearchResult>> {
110        self.search_title_or_content(query, limit, false).await
111    }
112
113    async fn search_title_or_content(
114        &self,
115        query: &str,
116        limit: Option<usize>,
117        is_title: bool,
118    ) -> Result<Vec<SearchResult>> {
119        let limit = limit.unwrap_or(50); // Wikimedia default
120        if !(1..=100).contains(&limit) {
121            bail!("Wikimedia search limit must be between 1 and 100");
122        }
123        if query.is_empty() {
124            bail!("Wikimedia search query cannot be empty");
125        }
126        let endp = if is_title { "title" } else { "page" };
127        let request_url = format!("search/{endp}");
128        let query = [("q", query.to_string()), ("limit", limit.to_string())];
129        let result: SearchResultContainer = self.get(&request_url, &query).await?;
130        Ok(result.pages)
131    }
132
133    // Get page, get page offline, and get page source -- in one function.
134    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_page
135    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_page_offline
136    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_page_source
137    pub async fn get_page(&self, title: &str, mode: GetPageMode) -> Result<Page> {
138        let mode_ = match mode {
139            GetPageMode::Url => "/bare",
140            GetPageMode::HTML => "/with_html",
141            GetPageMode::Source => "",
142        };
143        let request_url = format!("page/{title}{mode_}");
144        let result: _Page = self.get(&request_url, &[]).await?;
145        Ok(super::Page {
146            id: result.id,
147            key: result.key,
148            title: result.title,
149            latest: result.latest,
150            content_model: result.content_model,
151            license: result.license,
152            source_or_url: match mode {
153                GetPageMode::Url => result.html_url.unwrap(),
154                GetPageMode::HTML => result.html.unwrap(),
155                GetPageMode::Source => result.source.unwrap(),
156            },
157        })
158    }
159
160    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_languages
161    pub async fn get_page_languages(&self, title: &str) -> Result<Vec<Language>> {
162        let request_url = format!("page/{title}/links/language");
163        self.get(&request_url, &[]).await
164    }
165
166    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Pages/Get_files
167    pub async fn get_page_files(&self, title: &str) -> Result<Vec<File>> {
168        let request_url = format!("page/{title}/links/media");
169        let files: FilesContainer = self.get(&request_url, &[]).await?;
170        Ok(files.files)
171    }
172
173    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Media_files/Get_file
174    pub async fn get_file(&self, title: &str) -> Result<File> {
175        self.get(&format!("file/{title}"), &[]).await
176    }
177
178    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Get_page_history
179    pub async fn get_page_history(
180        &self,
181        title: &str,
182        older_than: Option<usize>,
183        newer_than: Option<usize>,
184        filter: Option<RevisionType>,
185    ) -> Result<PageHistory> {
186        let request_url = format!("page/{title}/history");
187        // ?filter=bot&older_than=981126172
188        let mut query = vec![];
189        if let Some(older_than) = older_than {
190            query.push(("older_than", older_than.to_string()));
191        }
192        if let Some(newer_than) = newer_than {
193            query.push(("newer_than", newer_than.to_string()));
194        }
195        if let Some(filter) = filter {
196            query.push(("filter", Into::<&str>::into(filter).to_string()));
197        }
198        self.get(&request_url, &query).await
199    }
200
201    // https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Get_revision
202    pub async fn get_revision(&self, id: usize) -> Result<Revision> {
203        let request_url = format!("revision/{id}/bare");
204        self.get(&request_url, &[]).await
205    }
206
207    // TODO: https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Get_revision_stats
208    // TODO: https://api.wikimedia.org/wiki/Core_REST_API/Reference/Revisions/Compare_revisions
209
210    fn get_prefix(&self) -> String {
211        let lang_path = match self.language {
212            Some(ref lang) => format!("/{lang}"),
213            None => "".to_string(),
214        };
215        format!(
216            "https://api.wikimedia.org/core/v1/{project}{lang_path}",
217            project = self.project,
218        )
219    }
220
221    async fn get<T>(&self, url: &str, query: &[(&str, String)]) -> Result<T>
222    where
223        T: for<'de> Deserialize<'de>,
224    {
225        let url = format!("{}/{}", self.get_prefix(), url);
226        let request = self.client.get(url).query(query);
227        let response = request.send().await?;
228        if response.status().as_u16() != 200 {
229            Err(response.json::<WikimediaError>().await?)?
230        } else {
231            Ok(response.json::<T>().await?)
232        }
233    }
234}
235
236#[derive(Deserialize)]
237struct SearchResultContainer {
238    pages: Vec<SearchResult>,
239}
240
241#[derive(Deserialize)]
242struct FilesContainer {
243    files: Vec<File>,
244}
245
246#[derive(Deserialize)]
247struct _Page {
248    pub(crate) id: usize,                   // Page identifier
249    pub key: String,                        // Page title in URL-friendly format
250    pub title: String,                      // Page title in reading-friendly format
251    pub latest: super::PageLatest,          // Latest revision of the page
252    pub content_model: super::ContentModel, // Content model used for the page
253    pub license: super::License,            // Information about the wiki's license
254    // Get page only:
255    pub html_url: Option<String>, // API route to fetch the content of the page in HTML
256    // Get page offline only:
257    pub html: Option<String>, // Latest page content in HTML
258    //  Get page source, create page, and edit page only:
259    pub source: Option<String>, // Latest page content in the format specified by the content_model property
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[tokio::test]
267    async fn search_title_ok() {
268        let req = Requester::new(super::WikiProject::Wiktionary, Some("en")).unwrap();
269        let found = req.search_title("test", Some(50)).await;
270        assert!(found.is_ok());
271        assert!(found.unwrap().len() == 50);
272        let not_found = req.search_title("asdalhaoksnbdkjssad", Some(50)).await;
273        assert!(not_found.is_ok());
274        assert!(not_found.unwrap().len() == 0);
275    }
276
277    #[tokio::test]
278    async fn search_title_errs() {
279        let req = Requester::new(super::WikiProject::Commons, None).unwrap();
280        let empty_q_err = req.search_title("", None).await;
281        assert!(empty_q_err.is_err());
282        let over_limit_err = req.search_title("", Some(200)).await;
283        assert!(over_limit_err.is_err());
284    }
285
286    #[tokio::test]
287    async fn get_page_ok() {
288        let req = Requester::new(super::WikiProject::Wikibooks, Some("en")).unwrap();
289        let oe_url = req.get_page("Old_English", GetPageMode::Url).await;
290        assert!(oe_url.is_ok());
291        assert!(
292            oe_url.unwrap().source_or_url
293                == "https://en.wikibooks.org/w/rest.php/v1/page/Old%20English/html"
294        );
295        let oe_html = req.get_page("Old_English", GetPageMode::HTML).await;
296        assert!(oe_html.is_ok());
297        let source_html = req.get_page("Old_English", GetPageMode::Source).await;
298        assert!(source_html.is_ok());
299    }
300
301    #[tokio::test]
302    async fn get_page_errs() {
303        let req = Requester::new(super::WikiProject::Wikivoyage, Some("de")).unwrap();
304        let not_found_err = req.get_page("nonexistent_crap", GetPageMode::Source).await;
305        assert!(not_found_err.is_err());
306    }
307
308    #[tokio::test]
309    async fn get_page_languages() {
310        let req = Requester::new(super::WikiProject::Wikipedia, Some("en")).unwrap();
311        let langs = req.get_page_languages("earth").await;
312        assert!(langs.is_ok());
313        // The page "earth" on wikipedia has something like 292 languages
314        assert!(langs.unwrap().len() > 200);
315        let not_found_err = req.get_page_languages("asdadvcvh").await;
316        assert!(not_found_err.is_err());
317    }
318
319    #[tokio::test]
320    async fn get_page_files() {
321        let req = Requester::new(super::WikiProject::Commons, None).unwrap();
322        let file = req.get_page_files("storm").await;
323        assert!(file.is_ok());
324        let too_many_files = req.get_page_files("earth").await;
325        assert!(too_many_files.is_err());
326    }
327
328    // TODO: test get_page_history, get_revision
329}