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(¶ms)
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}