mwapi_errors 0.1.7

Possible MediaWiki error types
Documentation
/*
Copyright (C) 2021 Kunal Mehta <legoktm@debian.org>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
//! MediaWiki API error types
//!
//! The MediaWIki API is rather dynamic and has quite a few possible errors
//! that you can run into. This crate aims to have dedicated types for each
//! possible case as well as a conversion map between the API's error codes
//! and Rust types.
//!
//! The `ApiError` type is serde-deserializable, and can be converted into
//! a specific `Error` type using the API response code. Aside from serde,
//! the library is fully library independent and should be usable by any
//! MediaWiki library or framework.
//!
//! ## Features
//! The `from` feature can be disabled to remove some dependencies that are
//! used to implement the `From` trait. Each dependency can be individually
//! toggled with a feature named `from-{dependency}`. Current features are:
//! * `from-mwtitle`
//! * `from-reqwest`
//! * `from-tokio`
//!
//! ## Contributing
//! `mwapi_errors` is a part of the [`mwbot-rs` project](https://www.mediawiki.org/wiki/Mwbot-rs).
//! We're always looking for new contributors, please [reach out](https://www.mediawiki.org/wiki/Mwbot-rs#Contributing)
//! if you're interested!

#![cfg_attr(docs, feature(doc_cfg))]

use serde::Deserialize;
use serde_json::Value;
use std::fmt::{Display, Formatter};
use thiserror::Error as ThisError;

/// Represents a raw MediaWiki API error, with a error code and error message
/// (text). This is also used for warnings since they use the same format.
#[derive(Clone, Debug, Deserialize)]
pub struct ApiError {
    /// Error code
    pub code: String,
    /// Error message
    pub text: String,
    /// Extra data
    pub data: Option<Value>,
}

impl Display for ApiError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "(code: {}): {}", self.code, self.text)
    }
}

/// Primary error class
#[non_exhaustive]
#[derive(ThisError, Debug)]
pub enum Error {
    /* Request related errors */
    /// A HTTP error like a 4XX or 5XX status code
    #[cfg(feature = "from-reqwest")]
    #[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
    #[error("HTTP error: {0}")]
    HttpError(#[from] reqwest::Error),
    /// Invalid header value, likely if the provided OAuth2 token
    /// or User-agent are invalid
    #[cfg(feature = "from-reqwest")]
    #[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
    #[error("Invalid header value: {0}")]
    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
    /// Error when decoding the JSON response from the API
    #[error("JSON error: {0}")]
    JsonError(#[from] serde_json::Error),
    /// Error if unable to get request concurrency lock
    #[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),

    /// etag header is invalid/missing
    #[error("The etag for this request is missing or invalid")]
    InvalidEtag,

    /* Token issues */
    /// Token invalid or expired
    #[error("Invalid CSRF token")]
    BadToken,
    /// Unable to fetch a CSRF token
    #[error("Unable to get token `{0}`")]
    TokenError(String),

    /* Wikitext/markup issues */
    #[error("Heading levels must be between 1 and 6, '{0}' was provided")]
    InvalidHeadingLevel(u32),

    /* User-related issues */
    /// When expected to be logged in but aren't
    #[error("You're not logged in")]
    NotLoggedIn,
    /// When expected to be logged in but aren't
    #[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),
    /// When we can't group it into a more specific block
    #[error("Blocked: {0}")]
    UnknownBlock(String),

    /* Login-related issues */
    #[error("You've made too many recent login attempts.")]
    LoginThrottled,
    #[error("Incorrect username or password entered.")]
    WrongPassword,

    /* Page-related issues */
    #[error("The specified title is not a valid page")]
    InvalidPage,
    /// When {{nobots}} matches
    #[error("{{nobots}} prevents editing this page")]
    Nobots,
    /// Page does not exist
    #[error("Page does not exist: {0}")]
    PageDoesNotExist(String),
    /// Page is protected
    #[error("Page is protected")]
    ProtectedPage,
    /// Edit conflict (string field is deprecated)
    #[error("Edit conflict")]
    EditConflict(String),
    #[error("Content too big: {0}")]
    ContentTooBig(String),
    /// Tripped the spam filter (aka SpamBlacklist)
    #[error("{info}")]
    SpamFilter { info: String, matches: Vec<String> },
    /// Some save failure happened, but we don't know what it is
    #[error("Unknown save failure: {0}")]
    UnknownSaveFailure(Value),

    /* MediaWiki-side issues */
    #[error("{info}, retry-after: {retry_after}")]
    MaxlagError {
        info: String,
        // Deprecated, will always be 0. Look at the HTTP header to get the
        // retry-after value.
        retry_after: u32,
    },
    /// When MediaWiki is in readonly mode
    #[error("MediaWiki is readonly: {0}")]
    Readonly(String),
    /// An internal MediaWiki exception
    #[error("Internal error: {0}")]
    InternalError(ApiError),

    /* Catchall/generic issues */
    /// Any arbitrary error returned by the MediaWiki API
    #[error("API error: {0}")]
    ApiError(ApiError),
    /// An error where we don't know what to do nor have
    /// information to report back
    #[error("Unknown error: {0}")]
    UnknownError(String),
}

impl Error {
    /// Whether the issue is related to saving a page
    pub fn is_save_error(&self) -> bool {
        matches!(
            self,
            Error::ProtectedPage
                | Error::EditConflict(_)
                | Error::SpamFilter { .. }
                | Error::ContentTooBig(_)
        )
    }

    /// Whether the issue is related to a block
    pub fn is_block_error(&self) -> bool {
        matches!(
            self,
            Error::Blocked { .. }
                | Error::PartiallyBlocked { .. }
                | Error::GloballyBlocked(_)
                | Error::GloballyRangeBlocked(_)
                | Error::GloballyXFFBlocked(_)
                | Error::UnknownBlock(_)
        )
    }

    /// Whether the request should be retried, after some suitable backoff
    /// and likely with some retry limit
    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,
    // TODO timestamp type
    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,
                // Deprecated, always set to 0
                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,
                        // Not worth raising an error over this
                        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);
        }
    }
}