#![deny(missing_docs)]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::page_query::{PageQueryResult, PageQueryResultList};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtectionEntry {
#[serde(rename = "type")]
pub protection_type: String,
pub level: String,
#[serde(default)]
pub expiry: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PageInfo {
pub pageid: Option<u64>,
#[serde(default)]
pub ns: i64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub contentmodel: String,
#[serde(default)]
pub pagelanguage: String,
#[serde(default)]
pub pagelanguagehtmlcode: String,
#[serde(default)]
pub pagelanguagedir: String,
pub touched: Option<String>,
pub lastrevid: Option<u64>,
pub length: Option<u64>,
#[serde(default, deserialize_with = "deserialize_mw_bool")]
pub missing: bool,
#[serde(default, deserialize_with = "deserialize_mw_bool")]
pub new: bool,
#[serde(default, deserialize_with = "deserialize_mw_bool")]
pub redirect: bool,
#[serde(default)]
pub protection: Vec<ProtectionEntry>,
#[serde(default)]
pub restrictiontypes: Vec<String>,
pub talkid: Option<u64>,
pub subjectid: Option<u64>,
pub associatedpage: Option<String>,
pub fullurl: Option<String>,
pub editurl: Option<String>,
pub canonicalurl: Option<String>,
pub displaytitle: Option<String>,
pub varianttitles: Option<HashMap<String, String>>,
pub watched: Option<bool>,
pub watchers: Option<u64>,
pub visitingwatchers: Option<u64>,
pub notificationtimestamp: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
impl PageQueryResult for PageInfo {
fn from_page_value(page: &Value) -> Vec<Self> {
serde_json::from_value(page.clone()).into_iter().collect()
}
}
fn deserialize_mw_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match v {
Value::Bool(b) => Ok(b),
Value::String(_) => Ok(true), Value::Null => Ok(false),
_ => Ok(false),
}
}
pub type PageInfoList = PageQueryResultList<PageInfo>;
impl PageInfoList {
pub fn pages(&self) -> &[PageInfo] {
self.items()
}
pub fn pages_mut(&mut self) -> &mut Vec<PageInfo> {
self.items_mut()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deserialize_full_page() {
let j = json!({
"pageid": 22939,
"ns": 0,
"title": "Physics",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr",
"touched": "2024-01-15T10:30:00Z",
"lastrevid": 1234567,
"length": 90355,
"protection": [
{"type": "edit", "level": "autoconfirmed", "expiry": "infinity"},
{"type": "move", "level": "sysop", "expiry": "infinity"}
],
"restrictiontypes": ["edit", "move"],
"talkid": 21492466,
"associatedpage": "Talk:Physics",
"fullurl": "https://en.wikipedia.org/wiki/Physics",
"editurl": "https://en.wikipedia.org/w/index.php?title=Physics&action=edit",
"canonicalurl": "https://en.wikipedia.org/wiki/Physics",
"displaytitle": "Physics",
"varianttitles": {"en": "Physics"}
});
let info: PageInfo = serde_json::from_value(j).unwrap();
assert_eq!(info.pageid, Some(22939));
assert_eq!(info.ns, 0);
assert_eq!(info.title, "Physics");
assert_eq!(info.contentmodel, "wikitext");
assert_eq!(info.pagelanguage, "en");
assert_eq!(info.pagelanguagedir, "ltr");
assert_eq!(info.touched.as_deref(), Some("2024-01-15T10:30:00Z"));
assert_eq!(info.lastrevid, Some(1234567));
assert_eq!(info.length, Some(90355));
assert!(!info.missing);
assert!(!info.new);
assert!(!info.redirect);
assert_eq!(info.protection.len(), 2);
assert_eq!(info.protection[0].protection_type, "edit");
assert_eq!(info.protection[0].level, "autoconfirmed");
assert_eq!(info.protection[1].protection_type, "move");
assert_eq!(info.restrictiontypes, vec!["edit", "move"]);
assert_eq!(info.talkid, Some(21492466));
assert_eq!(info.associatedpage.as_deref(), Some("Talk:Physics"));
assert!(info.fullurl.as_ref().unwrap().contains("Physics"));
assert!(info.editurl.is_some());
assert!(info.canonicalurl.is_some());
assert_eq!(info.displaytitle.as_deref(), Some("Physics"));
assert_eq!(
info.varianttitles.as_ref().unwrap().get("en"),
Some(&"Physics".to_string())
);
}
#[test]
fn deserialize_minimal_page() {
let j = json!({
"pageid": 1,
"ns": 0,
"title": "Main Page",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr"
});
let info: PageInfo = serde_json::from_value(j).unwrap();
assert_eq!(info.pageid, Some(1));
assert_eq!(info.title, "Main Page");
assert!(!info.missing);
assert!(info.protection.is_empty());
assert!(info.talkid.is_none());
assert!(info.fullurl.is_none());
assert!(info.displaytitle.is_none());
}
#[test]
fn deserialize_missing_page_v1() {
let j = json!({
"ns": 0,
"title": "Nonexistent page",
"missing": "",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr"
});
let info: PageInfo = serde_json::from_value(j).unwrap();
assert!(info.missing);
assert!(info.pageid.is_none());
assert_eq!(info.title, "Nonexistent page");
}
#[test]
fn deserialize_missing_page_v2() {
let j = json!({
"ns": 0,
"title": "Nonexistent page",
"missing": true,
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr"
});
let info: PageInfo = serde_json::from_value(j).unwrap();
assert!(info.missing);
}
#[test]
fn deserialize_redirect_page() {
let j = json!({
"pageid": 42,
"ns": 0,
"title": "Some redirect",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr",
"redirect": true
});
let info: PageInfo = serde_json::from_value(j).unwrap();
assert!(info.redirect);
}
#[test]
fn unknown_fields_captured_in_extra() {
let j = json!({
"pageid": 1,
"ns": 0,
"title": "Test",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr",
"some_future_field": "hello",
"another_field": 42
});
let info: PageInfo = serde_json::from_value(j).unwrap();
assert_eq!(info.extra["some_future_field"], json!("hello"));
assert_eq!(info.extra["another_field"], json!(42));
}
#[test]
fn protection_entry_deserialize() {
let j = json!({"type": "edit", "level": "sysop", "expiry": "2025-01-01T00:00:00Z"});
let entry: ProtectionEntry = serde_json::from_value(j).unwrap();
assert_eq!(entry.protection_type, "edit");
assert_eq!(entry.level, "sysop");
assert_eq!(entry.expiry, "2025-01-01T00:00:00Z");
}
#[test]
fn protection_entry_serialize_roundtrip() {
let entry = ProtectionEntry {
protection_type: "move".to_string(),
level: "autoconfirmed".to_string(),
expiry: "infinity".to_string(),
};
let j = serde_json::to_value(&entry).unwrap();
assert_eq!(j["type"], "move");
let back: ProtectionEntry = serde_json::from_value(j).unwrap();
assert_eq!(back, entry);
}
fn sample_result_v1() -> Value {
json!({
"query": {
"pages": {
"22939": {
"pageid": 22939,
"ns": 0,
"title": "Physics",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr",
"touched": "2024-01-01T00:00:00Z",
"lastrevid": 100,
"length": 50000
},
"736": {
"pageid": 736,
"ns": 0,
"title": "Albert Einstein",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr",
"touched": "2024-01-02T00:00:00Z",
"lastrevid": 200,
"length": 80000
}
}
}
})
}
fn sample_result_v2() -> Value {
json!({
"query": {
"pages": [
{
"pageid": 22939,
"ns": 0,
"title": "Physics",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr"
},
{
"pageid": 736,
"ns": 0,
"title": "Albert Einstein",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr"
}
]
}
})
}
#[test]
fn from_result_v1() {
let list = PageInfoList::from_result(&sample_result_v1());
assert_eq!(list.len(), 2);
assert!(list.pages().iter().any(|p| p.title == "Physics"));
assert!(list.pages().iter().any(|p| p.title == "Albert Einstein"));
}
#[test]
fn from_result_v2() {
let list = PageInfoList::from_result(&sample_result_v2());
assert_eq!(list.len(), 2);
assert_eq!(list.pages()[0].title, "Physics");
assert_eq!(list.pages()[1].title, "Albert Einstein");
}
#[test]
fn add_from_result_accumulates() {
let mut list = PageInfoList::new();
assert!(list.is_empty());
list.add_from_result(&sample_result_v1());
assert_eq!(list.len(), 2);
list.add_from_result(&sample_result_v2());
assert_eq!(list.len(), 4);
}
#[test]
fn from_result_empty() {
let list = PageInfoList::from_result(&json!({}));
assert!(list.is_empty());
let list = PageInfoList::from_result(&json!({"query": {"pages": {}}}));
assert!(list.is_empty());
let list = PageInfoList::from_result(&json!({"query": {"pages": []}}));
assert!(list.is_empty());
}
#[test]
fn from_result_with_missing_page() {
let result = json!({
"query": {
"pages": {
"-1": {
"ns": 0,
"title": "Nonexistent",
"missing": "",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr"
}
}
}
});
let list = PageInfoList::from_result(&result);
assert_eq!(list.len(), 1);
assert!(list.pages()[0].missing);
assert_eq!(list.pages()[0].title, "Nonexistent");
}
#[test]
fn into_iterator() {
let list = PageInfoList::from_result(&sample_result_v2());
let titles: Vec<String> = list.into_iter().map(|p| p.title).collect();
assert_eq!(titles.len(), 2);
}
#[test]
fn ref_iterator() {
let list = PageInfoList::from_result(&sample_result_v2());
let titles: Vec<&str> = (&list).into_iter().map(|p| p.title.as_str()).collect();
assert_eq!(titles.len(), 2);
assert_eq!(list.len(), 2);
}
#[test]
fn pages_mut_allows_modification() {
let mut list = PageInfoList::from_result(&sample_result_v2());
list.pages_mut().retain(|p| p.title == "Physics");
assert_eq!(list.len(), 1);
}
#[tokio::test]
async fn integration_from_result() {
use crate::Api;
use crate::action_api::{ActionApiQuery, ActionApiQueryCommonBuilder, ActionApiRunnable};
use wiremock::matchers::query_param;
use wiremock::{Mock, ResponseTemplate};
let server = crate::test_helpers::test_helpers_mod::start_enwiki_mock().await;
Mock::given(query_param("prop", "info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"batchcomplete": "",
"query": {
"pages": {
"736": {
"pageid": 736, "ns": 0, "title": "Albert Einstein",
"contentmodel": "wikitext", "pagelanguage": "en",
"fullurl": "https://en.wikipedia.org/wiki/Albert_Einstein",
"displaytitle": "Albert Einstein",
"protection": [{"type": "edit", "level": "autoconfirmed"}]
},
"22989": {
"pageid": 22989, "ns": 0, "title": "Physics",
"contentmodel": "wikitext", "pagelanguage": "en",
"fullurl": "https://en.wikipedia.org/wiki/Physics",
"displaytitle": "Physics",
"protection": []
}
}
}
})))
.mount(&server)
.await;
let api = Api::new(&server.uri()).await.unwrap();
let result = ActionApiQuery::info()
.inprop(&["protection", "url", "displaytitle"])
.titles(&["Albert Einstein", "Physics"])
.run(&api)
.await
.unwrap();
let list = PageInfoList::from_result(&result);
assert_eq!(list.len(), 2);
for page in list.pages() {
assert!(!page.title.is_empty());
assert!(page.pageid.is_some());
assert!(page.fullurl.is_some());
assert!(page.displaytitle.is_some());
assert!(!page.protection.is_empty() || page.title == "Physics");
}
}
#[tokio::test]
async fn integration_fetch_all() {
use crate::Api;
use crate::action_api::{ActionApiQuery, ActionApiQueryCommonBuilder};
use wiremock::matchers::query_param;
use wiremock::{Mock, ResponseTemplate};
let server = crate::test_helpers::test_helpers_mod::start_enwiki_mock().await;
Mock::given(query_param("prop", "info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"batchcomplete": "",
"query": {
"pages": {
"736": {
"pageid": 736, "ns": 0, "title": "Albert Einstein",
"fullurl": "https://en.wikipedia.org/wiki/Albert_Einstein",
"displaytitle": "Albert Einstein",
"protection": [{"type": "edit", "level": "autoconfirmed"}]
},
"22989": {
"pageid": 22989, "ns": 0, "title": "Physics",
"fullurl": "https://en.wikipedia.org/wiki/Physics",
"displaytitle": "Physics",
"protection": []
}
}
}
})))
.mount(&server)
.await;
let api = Api::new(&server.uri()).await.unwrap();
let builder = ActionApiQuery::info()
.inprop(&["protection", "url", "displaytitle"])
.titles(&["Albert Einstein", "Physics"]);
let list = PageInfoList::fetch_all(&builder, &api, None).await.unwrap();
assert!(!list.is_empty());
assert_eq!(list.len(), 2);
for page in list.pages() {
assert!(!page.title.is_empty());
}
}
#[test]
fn sync_integration_fetch_all() {
use crate::ApiSync;
use crate::action_api::{ActionApiQuery, ActionApiQueryCommonBuilder};
use wiremock::matchers::query_param;
use wiremock::{Mock, ResponseTemplate};
let rt = tokio::runtime::Runtime::new().unwrap();
let server = rt.block_on(async {
let server = crate::test_helpers::test_helpers_mod::start_enwiki_mock().await;
Mock::given(query_param("prop", "info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"batchcomplete": "",
"query": {
"pages": {
"736": {
"pageid": 736, "ns": 0, "title": "Albert Einstein",
"fullurl": "https://en.wikipedia.org/wiki/Albert_Einstein",
"displaytitle": "Albert Einstein",
"protection": []
},
"22989": {
"pageid": 22989, "ns": 0, "title": "Physics",
"fullurl": "https://en.wikipedia.org/wiki/Physics",
"displaytitle": "Physics",
"protection": []
}
}
}
})))
.mount(&server)
.await;
server
});
let api = ApiSync::new(&server.uri()).unwrap();
let builder = ActionApiQuery::info()
.inprop(&["protection", "url", "displaytitle"])
.titles(&["Albert Einstein", "Physics"]);
let list = PageInfoList::fetch_all_sync(&builder, &api, None).unwrap();
assert!(!list.is_empty());
assert_eq!(list.len(), 2);
}
}