mwapi_errors/
lib.rs

1/*
2Copyright (C) 2021-2022 Kunal Mehta <legoktm@debian.org>
3
4This program is free software: you can redistribute it and/or modify
5it under the terms of the GNU General Public License as published by
6the Free Software Foundation, either version 3 of the License, or
7(at your option) any later version.
8
9This program is distributed in the hope that it will be useful,
10but WITHOUT ANY WARRANTY; without even the implied warranty of
11MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12GNU General Public License for more details.
13
14You should have received a copy of the GNU General Public License
15along with this program.  If not, see <https://www.gnu.org/licenses/>.
16 */
17//! MediaWiki API error types
18//!
19//! The MediaWIki API is rather dynamic and has quite a few possible errors
20//! that you can run into. This crate aims to have dedicated types for each
21//! possible case as well as a conversion map between the API's error codes
22//! and Rust types.
23//!
24//! The `ApiError` type is serde-deserializable, and can be converted into
25//! a specific `Error` type using the API response code. Aside from serde,
26//! the library is fully library independent and should be usable by any
27//! MediaWiki library or framework.
28//!
29//! ## Features
30//! The `from-*` features can be enabled to add some dependencies that are
31//! used to implement the `From` trait. Each dependency can be individually
32//! toggled with a feature named `from-{dependency}`. Current features are:
33//! * `from-mwtitle`
34//! * `from-reqwest`
35//! * `from-tokio`
36//!
37//! ## Contributing
38//! `mwapi_errors` is a part of the [`mwbot-rs` project](https://www.mediawiki.org/wiki/Mwbot-rs).
39//! We're always looking for new contributors, please [reach out](https://www.mediawiki.org/wiki/Mwbot-rs#Contributing)
40//! if you're interested!
41#![deny(clippy::all)]
42#![cfg_attr(docs, feature(doc_cfg))]
43
44use serde::Deserialize;
45use serde_json::Value;
46use std::fmt::{Display, Formatter};
47use std::sync::Arc;
48use thiserror::Error as ThisError;
49
50/// Represents a raw MediaWiki API error, with a error code and error message
51/// (text). This is also used for warnings since they use the same format.
52#[derive(Clone, Debug, Deserialize)]
53pub struct ApiError {
54    /// Error code
55    pub code: String,
56    /// Error message
57    pub text: String,
58    /// Extra data
59    pub data: Option<Value>,
60}
61
62impl Display for ApiError {
63    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
64        write!(f, "(code: {}): {}", self.code, self.text)
65    }
66}
67
68/// Primary error class
69#[non_exhaustive]
70#[derive(ThisError, Debug, Clone)]
71pub enum Error {
72    /* Request related errors */
73    /// A HTTP error like a 4XX or 5XX status code
74    #[cfg(feature = "from-reqwest")]
75    #[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
76    #[error("HTTP error: {0}")]
77    HttpError(Arc<reqwest::Error>),
78    /// Invalid header value, likely if the provided OAuth2 token
79    /// or User-agent are invalid
80    #[cfg(feature = "from-reqwest")]
81    #[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
82    #[error("Invalid header value")]
83    InvalidHeaderValue,
84    /// Error when decoding the JSON response from the API
85    #[error("JSON error: {0}")]
86    InvalidJson(Arc<serde_json::Error>),
87    /// Error if unable to get request concurrency lock
88    #[cfg(feature = "from-tokio")]
89    #[cfg_attr(docs, doc(cfg(feature = "from-tokio")))]
90    #[error("Unable to get request lock")]
91    LockFailure,
92    #[cfg(feature = "from-mwtitle")]
93    #[cfg_attr(docs, doc(cfg(feature = "from-mwtitle")))]
94    #[error("Invalid title: {0}")]
95    InvalidTitle(#[from] mwtitle::Error),
96
97    /// etag header is invalid/missing
98    #[error("The etag for this request is missing or invalid")]
99    InvalidEtag,
100
101    /* Token issues */
102    /// Token invalid or expired
103    #[error("Invalid CSRF token")]
104    BadToken,
105    /// Unable to fetch a CSRF token
106    #[error("Unable to get token `{0}`")]
107    TokenFailure(String),
108
109    /* Wikitext/markup issues */
110    #[error("Heading levels must be between 1 and 6, '{0}' was provided")]
111    InvalidHeadingLevel(u32),
112
113    /* User-related issues */
114    /// When expected to be logged in but aren't
115    #[error("You're not logged in")]
116    NotLoggedIn,
117    /// When expected to be logged in but aren't
118    #[error("You're not logged in as a bot account")]
119    NotLoggedInAsBot,
120    #[error("Missing permission: {0}")]
121    PermissionDenied(String),
122    #[error("Blocked sitewide: {info}")]
123    Blocked { info: String, details: BlockDetails },
124    #[error("Partially blocked: {info}")]
125    PartiallyBlocked { info: String, details: BlockDetails },
126    #[error("Globally blocked: {0}")]
127    GloballyBlocked(String),
128    #[error("Globally range blocked: {0}")]
129    GloballyRangeBlocked(String),
130    #[error("Globally XFF blocked: {0}")]
131    GloballyXFFBlocked(String),
132    /// When we can't group it into a more specific block
133    #[error("Blocked: {0}")]
134    UnknownBlock(String),
135
136    /* Login-related issues */
137    #[error("You've made too many recent login attempts.")]
138    LoginThrottled,
139    #[error("Incorrect username or password entered.")]
140    WrongPassword,
141
142    /* Page-related issues */
143    #[error("The specified title is not a valid page")]
144    InvalidPage,
145    /// When {{nobots}} matches
146    #[error("{{nobots}} prevents editing this page")]
147    Nobots,
148    /// Page does not exist
149    #[error("Page does not exist: {0}")]
150    PageDoesNotExist(String),
151    /// Page is protected
152    #[error("Page is protected")]
153    ProtectedPage,
154    /// Edit conflict
155    #[error("Edit conflict")]
156    EditConflict,
157    #[error("Content too big: {0}")]
158    ContentTooBig(String),
159    /// Tripped the spam filter (aka SpamBlacklist)
160    #[error("{info}")]
161    SpamFilter { info: String, matches: Vec<String> },
162    /// Some save failure happened, but we don't know what it is
163    #[error("Unknown save failure: {0}")]
164    UnknownSaveFailure(Value),
165
166    /* MediaWiki-side issues */
167    #[error("maxlag tripped: {0}")]
168    Maxlag(String),
169    /// When MediaWiki is in readonly mode
170    #[error("MediaWiki is readonly: {0}")]
171    Readonly(String),
172    /// An internal MediaWiki exception
173    #[error("Internal MediaWiki exception: {0}")]
174    InternalException(ApiError),
175
176    /* Catchall/generic issues */
177    /// Any arbitrary error returned by the MediaWiki API
178    #[error("API error: {0}")]
179    ApiError(ApiError),
180    /// An error where we don't know what to do nor have
181    /// information to report back
182    #[error("Unknown error: {0}")]
183    Unknown(String),
184}
185
186#[cfg(feature = "from-tokio")]
187#[cfg_attr(docs, doc(cfg(feature = "from-tokio")))]
188impl From<tokio::sync::AcquireError> for Error {
189    fn from(_: tokio::sync::AcquireError) -> Self {
190        Self::LockFailure
191    }
192}
193
194#[cfg(feature = "from-reqwest")]
195#[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
196impl From<reqwest::header::InvalidHeaderValue> for Error {
197    fn from(_: reqwest::header::InvalidHeaderValue) -> Self {
198        Self::InvalidHeaderValue
199    }
200}
201
202#[cfg(feature = "from-reqwest")]
203#[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
204impl From<reqwest::Error> for Error {
205    fn from(err: reqwest::Error) -> Self {
206        Self::HttpError(Arc::new(err))
207    }
208}
209
210impl From<serde_json::Error> for Error {
211    fn from(err: serde_json::Error) -> Self {
212        Self::InvalidJson(Arc::new(err))
213    }
214}
215
216impl Error {
217    /// Whether the issue is related to a specific page
218    /// rather than a global issue
219    pub fn is_page_related(&self) -> bool {
220        matches!(
221            self,
222            Error::InvalidPage
223                | Error::ProtectedPage
224                | Error::Nobots
225                | Error::PartiallyBlocked { .. }
226                | Error::EditConflict
227                | Error::SpamFilter { .. }
228                | Error::ContentTooBig(_)
229        )
230    }
231
232    /// Whether the issue is related to a sitewide block
233    pub fn is_sitewide_block(&self) -> bool {
234        matches!(
235            self,
236            Error::Blocked { .. }
237                | Error::GloballyBlocked(_)
238                | Error::GloballyRangeBlocked(_)
239                | Error::GloballyXFFBlocked(_)
240                // We don't know 100% this is a sitewide block, but let's
241                // err on the cautious side
242                | Error::UnknownBlock(_)
243        )
244    }
245
246    /// Whether the request should be retried, after some suitable backoff
247    /// and likely with some retry limit
248    pub fn should_retry(&self) -> bool {
249        matches!(self, Error::Maxlag { .. } | Error::Readonly(_))
250    }
251}
252
253#[derive(Deserialize)]
254struct SpamFilterData {
255    matches: Vec<String>,
256}
257
258#[derive(Deserialize, Debug, Clone)]
259pub struct BlockDetails {
260    pub blockid: u32,
261    pub blockedby: String,
262    pub blockedbyid: u32,
263    pub blockreason: String,
264    // TODO timestamp type
265    pub blockedtimestamp: String,
266    pub blockpartial: bool,
267    pub blocknocreate: bool,
268    pub blockanononly: bool,
269    pub systemblocktype: Option<String>,
270}
271
272impl From<ApiError> for Error {
273    fn from(apierr: ApiError) -> Self {
274        match apierr.code.as_str() {
275            "assertuserfailed" => Self::NotLoggedIn,
276            "assertbotfailed" => Self::NotLoggedInAsBot,
277            "badtoken" => Self::BadToken,
278            "blocked" => {
279                let details = if let Some(data) = apierr.data {
280                    serde_json::from_value::<BlockDetails>(
281                        data["blockinfo"].clone(),
282                    )
283                    .ok()
284                } else {
285                    None
286                };
287                match details {
288                    Some(details) => {
289                        if details.blockpartial {
290                            Self::PartiallyBlocked {
291                                info: apierr.text,
292                                details,
293                            }
294                        } else {
295                            Self::Blocked {
296                                info: apierr.text,
297                                details,
298                            }
299                        }
300                    }
301                    None => Self::UnknownBlock(apierr.text),
302                }
303            }
304            "contenttoobig" => Self::ContentTooBig(apierr.text),
305            "editconflict" => Self::EditConflict,
306            "globalblocking-ipblocked"
307            | "wikimedia-globalblocking-ipblocked" => {
308                Self::GloballyBlocked(apierr.text)
309            }
310            "globalblocking-ipblocked-range"
311            | "wikimedia-globalblocking-ipblocked-range" => {
312                Self::GloballyRangeBlocked(apierr.text)
313            }
314            "globalblocking-ipblocked-xff"
315            | "wikimedia-globalblocking-ipblocked-xff" => {
316                Self::GloballyXFFBlocked(apierr.text)
317            }
318            "login-throttled" => Self::LoginThrottled,
319            "maxlag" => Self::Maxlag(apierr.text),
320            "protectedpage" => Self::ProtectedPage,
321            "readonly" => Self::Readonly(apierr.text),
322            "spamblacklist" => {
323                let matches = if let Some(data) = apierr.data {
324                    match serde_json::from_value::<SpamFilterData>(
325                        data["spamblacklist"].clone(),
326                    ) {
327                        Ok(data) => data.matches,
328                        // Not worth raising an error over this
329                        Err(_) => vec![],
330                    }
331                } else {
332                    vec![]
333                };
334                Self::SpamFilter {
335                    info: apierr.text,
336                    matches,
337                }
338            }
339            "wrongpassword" => Self::WrongPassword,
340            code => {
341                if code.starts_with("internal_api_error_") {
342                    Self::InternalException(apierr)
343                } else {
344                    Self::ApiError(apierr)
345                }
346            }
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_is_page_related() {
357        assert!(Error::EditConflict.is_page_related());
358        assert!(!Error::Unknown("bar".to_string()).is_page_related());
359    }
360
361    #[test]
362    fn test_from_apierror() {
363        let apierr = ApiError {
364            code: "assertbotfailed".to_string(),
365            text: "Something something".to_string(),
366            data: None,
367        };
368        let err = Error::from(apierr);
369        if let Error::NotLoggedInAsBot = err {
370            assert!(true);
371        } else {
372            panic!("Expected NotLoggedInAsBot error");
373        }
374    }
375
376    #[test]
377    fn test_to_string() {
378        let apierr = ApiError {
379            code: "errorcode".to_string(),
380            text: "Some description".to_string(),
381            data: None,
382        };
383        assert_eq!(&apierr.to_string(), "(code: errorcode): Some description");
384    }
385
386    #[test]
387    fn test_spamfilter() {
388        let apierr = ApiError {
389            code: "spamblacklist".to_string(),
390            text: "blah blah".to_string(),
391            data: Some(serde_json::json!({
392                "spamblacklist": {
393                    "matches": [
394                        "example.org"
395                    ]
396                }
397            })),
398        };
399        let err = Error::from(apierr);
400        if let Error::SpamFilter { matches, .. } = err {
401            assert_eq!(matches, vec!["example.org".to_string()]);
402        } else {
403            panic!("Unexpected error: {:?}", err);
404        }
405    }
406}