pub(crate) mod config;
mod upload;
pub use mwapi::ApiError;
use serde::Deserialize;
use serde_json::Value;
use std::io;
use thiserror::Error as ThisError;
pub use upload::UploadWarning;
#[non_exhaustive]
#[derive(ThisError, Debug)]
pub enum Error {
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Invalid header value: {0}")]
InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
#[error("JSON error: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("Unable to get request lock: {0}")]
LockFailure(#[from] tokio::sync::AcquireError),
#[error("Invalid title: {0}")]
InvalidTitle(#[from] mwtitle::Error),
#[error("I/O error: {0}")]
IoError(io::Error),
#[error("The etag for this request is missing or invalid")]
InvalidEtag,
#[error("Invalid CSRF token")]
BadToken,
#[error("Unable to get token `{0}`")]
TokenFailure(String),
#[error("Heading levels must be between 1 and 6, '{0}' was provided")]
InvalidHeadingLevel(u32),
#[error("You're not logged in")]
NotLoggedIn,
#[error("You're not logged in as a bot account")]
NotLoggedInAsBot,
#[error("Missing permission: {0}")]
PermissionDenied(String),
#[error("Blocked sitewide: {info}")]
Blocked { info: String, details: BlockDetails },
#[error("Partially blocked: {info}")]
PartiallyBlocked { info: String, details: BlockDetails },
#[error("Globally blocked: {0}")]
GloballyBlocked(String),
#[error("Globally range blocked: {0}")]
GloballyRangeBlocked(String),
#[error("Globally XFF blocked: {0}")]
GloballyXFFBlocked(String),
#[error("Blocked: {0}")]
UnknownBlock(String),
#[error("You've made too many recent login attempts.")]
LoginThrottled,
#[error("Incorrect username or password entered.")]
WrongPassword,
#[error("The specified title is not a valid page")]
InvalidPage,
#[error("{{{{nobots}}}} prevents editing this page")]
Nobots,
#[error("Page does not exist: {0}")]
PageDoesNotExist(String),
#[error("Page is protected")]
ProtectedPage,
#[error("Edit conflict")]
EditConflict,
#[error("Content too big: {0}")]
ContentTooBig(String),
#[error("{info}")]
SpamFilter { info: String, matches: Vec<String> },
#[error("Undo failure: {0}")]
UndoFailure(String),
#[error("Unknown save failure: {0}")]
UnknownSaveFailure(Value),
#[error("Upload warnings: {0:?}")]
UploadWarning(Vec<UploadWarning>),
#[error("maxlag tripped: {info}")]
Maxlag {
info: String,
retry_after: Option<u64>,
},
#[error("MediaWiki is readonly: {info}")]
Readonly {
info: String,
retry_after: Option<u64>,
},
#[error("Internal MediaWiki exception: {0}")]
InternalException(ApiError),
#[error("API error: {0}")]
ApiError(ApiError),
#[error("Unknown error: {0}")]
Unknown(String),
}
impl Error {
pub fn is_page_related(&self) -> bool {
matches!(
self,
Error::InvalidPage
| Error::ProtectedPage
| Error::Nobots
| Error::PartiallyBlocked { .. }
| Error::EditConflict
| Error::SpamFilter { .. }
| Error::ContentTooBig(_)
| Error::UndoFailure(_)
)
}
pub fn is_sitewide_block(&self) -> bool {
matches!(
self,
Error::Blocked { .. }
| Error::GloballyBlocked(_)
| Error::GloballyRangeBlocked(_)
| Error::GloballyXFFBlocked(_)
| Error::UnknownBlock(_)
)
}
pub fn retry_after(&self) -> Option<u64> {
match self {
Error::Maxlag { retry_after, .. } => {
Some((*retry_after).unwrap_or(1))
}
Error::Readonly { retry_after, .. } => {
Some((*retry_after).unwrap_or(1))
}
_ => None,
}
}
}
#[derive(Deserialize)]
struct SpamFilterData {
matches: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BlockDetails {
pub blockedby: String,
pub blockreason: String,
pub blockpartial: bool,
}
impl From<ApiError> for Error {
fn from(apierr: ApiError) -> Self {
match apierr.code.as_str() {
"assertuserfailed" => Self::NotLoggedIn,
"assertbotfailed" => Self::NotLoggedInAsBot,
"badtoken" => Self::BadToken,
"blocked" => {
let details = if let Some(data) = apierr.data {
serde_json::from_value::<BlockDetails>(
data["blockinfo"].clone(),
)
.ok()
} else {
None
};
match details {
Some(details) => {
if details.blockpartial {
Self::PartiallyBlocked {
info: apierr.text,
details,
}
} else {
Self::Blocked {
info: apierr.text,
details,
}
}
}
None => Self::UnknownBlock(apierr.text),
}
}
"contenttoobig" => Self::ContentTooBig(apierr.text),
"editconflict" => Self::EditConflict,
"globalblocking-ipblocked"
| "wikimedia-globalblocking-ipblocked" => {
Self::GloballyBlocked(apierr.text)
}
"globalblocking-ipblocked-range"
| "wikimedia-globalblocking-ipblocked-range" => {
Self::GloballyRangeBlocked(apierr.text)
}
"globalblocking-ipblocked-xff"
| "wikimedia-globalblocking-ipblocked-xff" => {
Self::GloballyXFFBlocked(apierr.text)
}
"login-throttled" => Self::LoginThrottled,
"maxlag" => Self::Maxlag {
info: apierr.text,
retry_after: None,
},
"protectedpage" => Self::ProtectedPage,
"readonly" => Self::Readonly {
info: apierr.text,
retry_after: None,
},
"spamblacklist" => {
let matches = if let Some(data) = apierr.data {
match serde_json::from_value::<SpamFilterData>(
data["spamblacklist"].clone(),
) {
Ok(data) => data.matches,
Err(_) => vec![],
}
} else {
vec![]
};
Self::SpamFilter {
info: apierr.text,
matches,
}
}
"undofailure" => Self::UndoFailure(apierr.text),
"wrongpassword" => Self::WrongPassword,
code => {
if code.starts_with("internal_api_error_") {
Self::InternalException(apierr)
} else {
Self::ApiError(apierr)
}
}
}
}
}
impl From<mwapi::Error> for Error {
fn from(value: mwapi::Error) -> Self {
match value {
mwapi::Error::HttpError(err) => Error::HttpError(err),
mwapi::Error::InvalidHeaderValue(err) => {
Error::InvalidHeaderValue(err)
}
mwapi::Error::InvalidJson(err) => Error::InvalidJson(err),
mwapi::Error::LockFailure(err) => Error::LockFailure(err),
mwapi::Error::IoError(err) => Error::IoError(err),
mwapi::Error::BadToken => Error::BadToken,
mwapi::Error::TokenFailure(token) => Error::TokenFailure(token),
mwapi::Error::NotLoggedIn => Error::NotLoggedIn,
mwapi::Error::NotLoggedInAsBot => Error::NotLoggedInAsBot,
mwapi::Error::UploadWarning(warnings) => Error::UploadWarning(
warnings.into_iter().map(UploadWarning::from_key).collect(),
),
mwapi::Error::Maxlag { info, retry_after } => {
Error::Maxlag { info, retry_after }
}
mwapi::Error::Readonly { info, retry_after } => {
Error::Readonly { info, retry_after }
}
mwapi::Error::InternalException(err) => {
Error::InternalException(err)
}
mwapi::Error::ApiError(apierr) => Error::from(apierr),
mwapi::Error::Unknown(err) => Error::Unknown(err),
err => Error::Unknown(err.to_string()),
}
}
}
impl From<parsoid::Error> for Error {
fn from(value: parsoid::Error) -> Self {
match value {
parsoid::Error::Http(err) => Error::HttpError(err),
parsoid::Error::InvalidHeaderValue(err) => {
Error::InvalidHeaderValue(err)
}
parsoid::Error::InvalidJson(err) => Error::InvalidJson(err),
parsoid::Error::LockFailure(err) => Error::LockFailure(err),
parsoid::Error::PageDoesNotExist(title) => {
Error::PageDoesNotExist(title)
}
parsoid::Error::InvalidEtag => Error::InvalidEtag,
parsoid::Error::InvalidHeadingLevel(level) => {
Error::InvalidHeadingLevel(level)
}
err => Error::Unknown(err.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_page_related() {
assert!(Error::EditConflict.is_page_related());
assert!(!Error::Unknown("bar".to_string()).is_page_related());
}
#[test]
fn test_from_apierror() {
let apierr = ApiError {
code: "assertbotfailed".to_string(),
text: "Something something".to_string(),
data: None,
};
let err = Error::from(apierr);
if let Error::NotLoggedInAsBot = err {
assert!(true);
} else {
panic!("Expected NotLoggedInAsBot error");
}
}
#[test]
fn test_to_string() {
let apierr = ApiError {
code: "errorcode".to_string(),
text: "Some description".to_string(),
data: None,
};
assert_eq!(&apierr.to_string(), "(code: errorcode): Some description");
}
#[test]
fn test_spamfilter() {
let apierr = ApiError {
code: "spamblacklist".to_string(),
text: "blah blah".to_string(),
data: Some(serde_json::json!({
"spamblacklist": {
"matches": [
"example.org"
]
}
})),
};
let err = Error::from(apierr);
if let Error::SpamFilter { matches, .. } = err {
assert_eq!(matches, vec!["example.org".to_string()]);
} else {
panic!("Unexpected error: {err:?}");
}
}
}