mwapi 0.6.1

A MediaWiki API client library
Documentation
/*
Copyright (C) 2021-2023 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/>.
 */

use serde::Deserialize;
use serde_json::Value;
use std::fmt::{Display, Formatter};
use std::io;
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)
    }
}

/// Possible errors
#[non_exhaustive]
#[derive(ThisError, Debug)]
pub enum Error {
    /* Request related errors */
    /// A HTTP error like a 4XX or 5XX status code
    #[error("HTTP error: {0}")]
    HttpError(#[from] reqwest::Error),
    /// Invalid header value, likely if the provided OAuth2 token
    /// or User-agent are invalid
    #[error("Invalid header value: {0}")]
    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
    /// Error when decoding the JSON response from the API
    #[error("JSON error: {0}")]
    InvalidJson(#[from] serde_json::Error),
    /// Error if unable to get request concurrency lock
    #[error("Unable to get request lock: {0}")]
    LockFailure(#[from] tokio::sync::AcquireError),
    #[error("I/O error: {0}")]
    IoError(#[from] io::Error),

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

    /* 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,

    /* File-related issues */
    #[error("Upload warnings: {0:?}")]
    UploadWarning(Vec<String>),

    /* MediaWiki-side issues */
    #[error("maxlag tripped: {info}")]
    Maxlag {
        info: String,
        retry_after: Option<u64>,
    },
    /// When MediaWiki is in readonly mode
    #[error("MediaWiki is readonly: {info}")]
    Readonly {
        info: String,
        retry_after: Option<u64>,
    },
    /// An internal MediaWiki exception
    #[error("Internal MediaWiki exception: {0}")]
    InternalException(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}")]
    Unknown(String),
}

impl Error {
    /// Store the value of the retry-after header, if one was present, for
    /// error types that are safe to retry
    pub fn with_retry_after(self, value: u64) -> Self {
        match self {
            Error::Maxlag { info, .. } => Error::Maxlag {
                info,
                retry_after: Some(value),
            },
            Error::Readonly { info, .. } => Error::Readonly {
                info,
                retry_after: Some(value),
            },
            err => err,
        }
    }

    /// If the error merits a retry, how long should we wait?
    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,
        }
    }
}

impl From<ApiError> for Error {
    fn from(apierr: ApiError) -> Self {
        match apierr.code.as_str() {
            "assertuserfailed" => Self::NotLoggedIn,
            "assertbotfailed" => Self::NotLoggedInAsBot,
            "badtoken" => Self::BadToken,
            "maxlag" => Self::Maxlag {
                info: apierr.text,
                retry_after: None,
            },
            "readonly" => Self::Readonly {
                info: apierr.text,
                retry_after: None,
            },
            code => {
                if code.starts_with("internal_api_error_") {
                    Self::InternalException(apierr)
                } else {
                    Self::ApiError(apierr)
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[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");
    }
}