mwapi_errors 0.1.1

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 `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-reqwest`
//! * `from-serde_json`
//! * `from-tokio`
use serde::Deserialize;
use std::fmt::{Display, Formatter};
use thiserror::Error as ThisError;

/// Represents a raw MediaWiki API error, with a error code
/// and error message (text).
#[derive(Clone, Debug, Deserialize)]
pub struct ApiError {
    /// Error code
    pub code: String,
    /// Error message
    pub text: String,
}

impl Display for ApiError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "API error: (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")]
    #[error("HTTP error")]
    HttpError(#[from] reqwest::Error),
    /// Invalid header value, likely if the provided OAuth2 token
    /// or User-agent are invalid
    #[cfg(feature = "from-reqwest")]
    #[error("Invalid header value")]
    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
    /// Error when decoding the JSON response from the API
    #[cfg(feature = "from-serde_json")]
    #[error("JSON error")]
    JsonError(#[from] serde_json::Error),
    /// Error if unable to get request concurrency lock
    #[cfg(feature = "from-tokio")]
    #[error("Unable to get request lock")]
    LockError(#[from] tokio::sync::AcquireError),
    /// etag header is invalid/missing
    #[error("The etag for this request is missing or invalid")]
    InvalidEtag,

    /* Integration issues */
    /// 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),

    /* Page-related issues */
    /// Page does not exist
    #[error("Page does not exist: {0}")]
    PageDoesNotExist(String),
    /// Page is protected
    #[error("Page [[{0}]] is protected: {1}")]
    ProtectedPage(String, String),
    /// Edit conflict
    #[error("Edit conflict on {0}")]
    EditConflict(String),

    /* Catchall/generic issues */
    /// Any arbitrary error returned by the MediaWiki 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(_))
    }
}

impl From<ApiError> for Error {
    fn from(apierr: ApiError) -> Self {
        match apierr.code.as_str() {
            "assertuserfailed" => Self::NotLoggedIn,
            "assertbotfailed" => Self::NotLoggedInAsBot,
            _ => 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(),
        };
        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(),
        };
        assert_eq!(
            &apierr.to_string(),
            "API error: (code: errorcode): Some description"
        );
    }
}