w_wiki/
lib.rs

1/*
2Copyright (C) 2020-2021 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//! # w-wiki
18//! Conveniently shorten URLs using the [`w.wiki`](https://w.wiki/) service.
19//! Only some [Wikimedia](https://www.wikimedia.org/) projects are supported,
20//! see the [documentation](https://meta.wikimedia.org/wiki/Wikimedia_URL_Shortener)
21//! for more details.
22//!
23//! `w-wiki`'s primary [`shorten`] function is asynchronous.
24//!
25//! ```
26//! # async fn run() {
27//! let short_url = w_wiki::shorten("https://www.wikidata.org/wiki/Q575650")
28//!     .await.unwrap();
29//! # }
30//! ```
31//!
32//! If you plan on making multiple requests, a [`Client`] is available that holds
33//! connections.
34//!
35//! ```
36//! # async fn run() {
37//! let client = w_wiki::Client::new();
38//! let short_url = client.shorten("https://www.wikidata.org/wiki/Q575650")
39//!     .await.unwrap();
40//! # }
41//! ```
42//!
43//! This library can be used by any MediaWiki wiki that has the
44//! [UrlShortener extension](https://www.mediawiki.org/wiki/Extension:UrlShortener)
45//! installed.
46//!
47//! ```
48//! # async fn run() {
49//! let client = w_wiki::Client::new_for_api("https://example.org/w/api.php");
50//! # }
51//! ```
52
53use reqwest::Client as HTTPClient;
54use serde::Deserialize;
55use serde_json::Value;
56
57#[derive(Deserialize)]
58struct ShortenResp {
59    shortenurl: ShortenUrl,
60}
61
62#[derive(Deserialize)]
63struct ShortenUrl {
64    shorturl: String,
65}
66
67#[derive(Deserialize)]
68struct ErrorResp {
69    error: MwError,
70}
71
72/// API error info
73#[derive(Debug, Deserialize)]
74pub struct MwError {
75    /// Error code
76    code: String,
77    /// Error description
78    info: String,
79}
80
81#[derive(thiserror::Error, Debug)]
82pub enum Error {
83    /// HTTP request errors, forwarded from `reqwest`
84    #[error("HTTP error: {0}")]
85    HttpError(#[from] reqwest::Error),
86    /// HTTP request errors, forwarded from `serde_json`
87    #[error("JSON error: {0}")]
88    JsonError(#[from] serde_json::Error),
89    /// Account/IP address is blocked
90    #[error("{}: {}", .0.code, .0.info)]
91    Blocked(MwError),
92    /// UrlShortener is disabled
93    #[error("{}: {}", .0.code, .0.info)]
94    Disabled(MwError),
95    /// URL is too long to be shortened
96    #[error("{}: {}", .0.code, .0.info)]
97    TooLong(MwError),
98    /// Rate limit exceeded
99    #[error("{}: {}", .0.code, .0.info)]
100    RateLimited(MwError),
101    /// Long URL has already been deleted
102    #[error("{}: {}", .0.code, .0.info)]
103    Deleted(MwError),
104    /// Invalid URL provided
105    #[error("{}: {}", .0.code, .0.info)]
106    Malformed(MwError),
107    /// Invalid ports provided
108    #[error("{}: {}", .0.code, .0.info)]
109    BadPorts(MwError),
110    /// A username/password was provided in the URL
111    #[error("{}: {}", .0.code, .0.info)]
112    NoUserPass(MwError),
113    /// URL domain not allowed
114    #[error("{}: {}", .0.code, .0.info)]
115    Disallowed(MwError),
116    /// Unknown error
117    #[error("{}: {}", .0.code, .0.info)]
118    Unknown(MwError),
119}
120
121/// Client if you need to customize the MediaWiki api.php endpoint
122pub struct Client {
123    /// URL to api.php
124    api_url: String,
125    client: HTTPClient,
126}
127
128impl Client {
129    /// Get a new instance
130    pub fn new() -> Client {
131        Self::new_for_api("https://meta.wikimedia.org/w/api.php")
132    }
133
134    /// Get an instance for a custom MediaWiki api.php endpoint
135    ///
136    /// # Example
137    /// ```
138    /// # use w_wiki::Client;
139    /// let client = Client::new_for_api("https://example.org/w/api.php");
140    /// ```
141    pub fn new_for_api(api_url: &str) -> Client {
142        Client {
143            api_url: api_url.to_string(),
144            client: HTTPClient::builder()
145                .user_agent(format!(
146                    "https://crates.io/crates/w-wiki {}",
147                    env!("CARGO_PKG_VERSION")
148                ))
149                .build()
150                .unwrap(),
151        }
152    }
153
154    /// Shorten a URL
155    ///
156    /// # Example
157    /// ```
158    /// # use w_wiki::Client;
159    /// # async fn run() {
160    /// let client = Client::new();
161    /// let short_url = client.shorten("https://example.org/wiki/Main_Page")
162    ///     .await.unwrap();
163    /// # }
164    /// ```
165    pub async fn shorten(&self, long: &str) -> Result<String, Error> {
166        let params = [
167            ("action", "shortenurl"),
168            ("format", "json"),
169            ("formatversion", "2"),
170            ("url", long),
171        ];
172        let resp: Value = self
173            .client
174            .post(&self.api_url)
175            .form(&params)
176            .send()
177            .await?
178            .json()
179            .await?;
180        if resp.get("shortenurl").is_some() {
181            let sresp: ShortenResp = serde_json::from_value(resp)?;
182            Ok(sresp.shortenurl.shorturl)
183        } else {
184            let eresp: ErrorResp = serde_json::from_value(resp)?;
185            Err(code_to_error(eresp.error))
186        }
187    }
188}
189
190impl Default for Client {
191    fn default() -> Client {
192        Client::new()
193    }
194}
195
196/// Shorten a URL using [`w.wiki`](https://w.wiki/)
197///
198/// # Example
199/// ```
200/// # async fn run() {
201/// let short_url = w_wiki::shorten("https://example.org/wiki/Main_Page")
202///     .await.unwrap();
203/// # }
204/// ```
205pub async fn shorten(long: &str) -> Result<String, Error> {
206    Client::new().shorten(long).await
207}
208
209fn code_to_error(resp: MwError) -> Error {
210    match resp.code.as_str() {
211        "urlshortener-blocked" => Error::Blocked(resp),
212        "urlshortener-disabled" => Error::Disabled(resp),
213        "urlshortener-url-too-long" => Error::TooLong(resp),
214        "urlshortener-ratelimit" => Error::RateLimited(resp),
215        "urlshortener-deleted" => Error::Deleted(resp),
216        "urlshortener-error-malformed-url" => Error::Malformed(resp),
217        "urlshortener-error-badports" => Error::BadPorts(resp),
218        "urlshortener-error-nouserpass" => Error::NoUserPass(resp),
219        "urlshortener-error-disallowed-url" => Error::Disallowed(resp),
220        _ => Error::Unknown(resp),
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[tokio::test]
229    async fn test_shorten() {
230        let resp = shorten("https://en.wikipedia.org/").await;
231        match resp {
232            Ok(short_url) => assert_eq!("https://w.wiki/G9", short_url),
233            // Some CI servers are blocked :(
234            Err(Error::Blocked(_)) => {}
235            Err(error) => panic!("{}", error.to_string()),
236        }
237    }
238
239    #[tokio::test]
240    async fn test_invalid_shorten() {
241        let resp = shorten("https://example.org/").await;
242        assert!(resp.is_err());
243        let error = resp.err().unwrap();
244        assert!(error
245            .to_string()
246            .starts_with("urlshortener-error-disallowed-url:"));
247    }
248
249    #[tokio::test]
250    async fn test_unknown_error() {
251        let client = Client::new_for_api("https://legoktm.com/w/api.php");
252        let resp = client.shorten("https://example.org/").await;
253        assert!(resp.is_err());
254        let error = resp.err().unwrap();
255        assert!(error.to_string().starts_with("badvalue:"));
256    }
257}