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 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, Anonymous, Bot, Minor, }
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 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 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); 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 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 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 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 pub async fn get_file(&self, title: &str) -> Result<File> {
175 self.get(&format!("file/{title}"), &[]).await
176 }
177
178 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 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 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 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, 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>, }
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 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 }