#![cfg_attr(docs, feature(doc_cfg))]
use serde::Deserialize;
use serde_json::Value;
use std::fmt::{Display, Formatter};
use thiserror::Error as ThisError;
#[derive(Clone, Debug, Deserialize)]
pub struct ApiError {
pub code: String,
pub text: String,
pub data: Option<Value>,
}
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "(code: {}): {}", self.code, self.text)
}
}
#[non_exhaustive]
#[derive(ThisError, Debug)]
pub enum Error {
#[cfg(feature = "from-reqwest")]
#[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[cfg(feature = "from-reqwest")]
#[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
#[error("Invalid header value: {0}")]
InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
#[cfg(feature = "from-tokio")]
#[cfg_attr(docs, doc(cfg(feature = "from-tokio")))]
#[error("Unable to get request lock: {0}")]
LockError(#[from] tokio::sync::AcquireError),
#[cfg(feature = "from-mwtitle")]
#[cfg_attr(docs, doc(cfg(feature = "from-mwtitle")))]
#[error("Invalid title: {0}")]
InvalidTitle(#[from] mwtitle::Error),
#[error("The etag for this request is missing or invalid")]
InvalidEtag,
#[error("Invalid CSRF token")]
BadToken,
#[error("Unable to get token `{0}`")]
TokenError(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}")]
PermissionsError(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(String),
#[error("Content too big: {0}")]
ContentTooBig(String),
#[error("{info}")]
SpamFilter { info: String, matches: Vec<String> },
#[error("Unknown save failure: {0}")]
UnknownSaveFailure(Value),
#[error("{info}, retry-after: {retry_after}")]
MaxlagError {
info: String,
retry_after: u32,
},
#[error("MediaWiki is readonly: {0}")]
Readonly(String),
#[error("Internal error: {0}")]
InternalError(ApiError),
#[error("API error: {0}")]
ApiError(ApiError),
#[error("Unknown error: {0}")]
UnknownError(String),
}
impl Error {
pub fn is_save_error(&self) -> bool {
matches!(
self,
Error::ProtectedPage
| Error::EditConflict(_)
| Error::SpamFilter { .. }
| Error::ContentTooBig(_)
)
}
pub fn is_block_error(&self) -> bool {
matches!(
self,
Error::Blocked { .. }
| Error::PartiallyBlocked { .. }
| Error::GloballyBlocked(_)
| Error::GloballyRangeBlocked(_)
| Error::GloballyXFFBlocked(_)
| Error::UnknownBlock(_)
)
}
pub fn should_retry(&self) -> bool {
matches!(self, Error::MaxlagError { .. } | Error::Readonly(_))
}
}
#[derive(Deserialize)]
struct SpamFilterData {
matches: Vec<String>,
}
#[derive(Deserialize, Debug)]
pub struct BlockDetails {
pub blockid: u32,
pub blockedby: String,
pub blockedbyid: u32,
pub blockreason: String,
pub blockedtimestamp: String,
pub blockpartial: bool,
pub blocknocreate: bool,
pub blockanononly: bool,
pub systemblocktype: Option<String>,
}
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("".to_string()),
"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::MaxlagError {
info: apierr.text,
retry_after: 0,
},
"protectedpage" => Self::ProtectedPage,
"readonly" => Self::Readonly(apierr.text),
"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,
}
}
"wrongpassword" => Self::WrongPassword,
code => {
if code.starts_with("internal_api_error_") {
Self::InternalError(apierr)
} else {
Self::ApiError(apierr)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_save_error() {
assert!(Error::EditConflict("foo".to_string()).is_save_error());
assert!(!Error::UnknownError("bar".to_string()).is_save_error());
}
#[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);
}
}
}