1#![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#[derive(Clone, Debug, Deserialize)]
53pub struct ApiError {
54 pub code: String,
56 pub text: String,
58 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#[non_exhaustive]
70#[derive(ThisError, Debug, Clone)]
71pub enum Error {
72 #[cfg(feature = "from-reqwest")]
75 #[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
76 #[error("HTTP error: {0}")]
77 HttpError(Arc<reqwest::Error>),
78 #[cfg(feature = "from-reqwest")]
81 #[cfg_attr(docs, doc(cfg(feature = "from-reqwest")))]
82 #[error("Invalid header value")]
83 InvalidHeaderValue,
84 #[error("JSON error: {0}")]
86 InvalidJson(Arc<serde_json::Error>),
87 #[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 #[error("The etag for this request is missing or invalid")]
99 InvalidEtag,
100
101 #[error("Invalid CSRF token")]
104 BadToken,
105 #[error("Unable to get token `{0}`")]
107 TokenFailure(String),
108
109 #[error("Heading levels must be between 1 and 6, '{0}' was provided")]
111 InvalidHeadingLevel(u32),
112
113 #[error("You're not logged in")]
116 NotLoggedIn,
117 #[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 #[error("Blocked: {0}")]
134 UnknownBlock(String),
135
136 #[error("You've made too many recent login attempts.")]
138 LoginThrottled,
139 #[error("Incorrect username or password entered.")]
140 WrongPassword,
141
142 #[error("The specified title is not a valid page")]
144 InvalidPage,
145 #[error("{{nobots}} prevents editing this page")]
147 Nobots,
148 #[error("Page does not exist: {0}")]
150 PageDoesNotExist(String),
151 #[error("Page is protected")]
153 ProtectedPage,
154 #[error("Edit conflict")]
156 EditConflict,
157 #[error("Content too big: {0}")]
158 ContentTooBig(String),
159 #[error("{info}")]
161 SpamFilter { info: String, matches: Vec<String> },
162 #[error("Unknown save failure: {0}")]
164 UnknownSaveFailure(Value),
165
166 #[error("maxlag tripped: {0}")]
168 Maxlag(String),
169 #[error("MediaWiki is readonly: {0}")]
171 Readonly(String),
172 #[error("Internal MediaWiki exception: {0}")]
174 InternalException(ApiError),
175
176 #[error("API error: {0}")]
179 ApiError(ApiError),
180 #[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 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 pub fn is_sitewide_block(&self) -> bool {
234 matches!(
235 self,
236 Error::Blocked { .. }
237 | Error::GloballyBlocked(_)
238 | Error::GloballyRangeBlocked(_)
239 | Error::GloballyXFFBlocked(_)
240 | Error::UnknownBlock(_)
243 )
244 }
245
246 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 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 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}