use reqwest::Client as HTTPClient;
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
struct ShortenResp {
shortenurl: ShortenUrl,
}
#[derive(Deserialize)]
struct ShortenUrl {
shorturl: String,
}
#[derive(Deserialize)]
struct ErrorResp {
error: MwError,
}
#[derive(Debug, Deserialize)]
pub struct MwError {
code: String,
info: String,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("{}: {}", .0.code, .0.info)]
Blocked(MwError),
#[error("{}: {}", .0.code, .0.info)]
Disabled(MwError),
#[error("{}: {}", .0.code, .0.info)]
TooLong(MwError),
#[error("{}: {}", .0.code, .0.info)]
RateLimited(MwError),
#[error("{}: {}", .0.code, .0.info)]
Deleted(MwError),
#[error("{}: {}", .0.code, .0.info)]
Malformed(MwError),
#[error("{}: {}", .0.code, .0.info)]
BadPorts(MwError),
#[error("{}: {}", .0.code, .0.info)]
NoUserPass(MwError),
#[error("{}: {}", .0.code, .0.info)]
Disallowed(MwError),
#[error("{}: {}", .0.code, .0.info)]
Unknown(MwError),
}
pub struct Client {
api_url: String,
client: HTTPClient,
}
impl Client {
pub fn new() -> Client {
Self::new_for_api("https://meta.wikimedia.org/w/api.php")
}
pub fn new_for_api(api_url: &str) -> Client {
Client {
api_url: api_url.to_string(),
client: HTTPClient::builder()
.user_agent(format!(
"https://crates.io/crates/w-wiki {}",
env!("CARGO_PKG_VERSION")
))
.build()
.unwrap(),
}
}
pub async fn shorten(&self, long: &str) -> Result<String, Error> {
let params = [
("action", "shortenurl"),
("format", "json"),
("formatversion", "2"),
("url", long),
];
let resp: Value = self
.client
.post(&self.api_url)
.form(¶ms)
.send()
.await?
.json()
.await?;
if resp.get("shortenurl").is_some() {
let sresp: ShortenResp = serde_json::from_value(resp)?;
Ok(sresp.shortenurl.shorturl)
} else {
let eresp: ErrorResp = serde_json::from_value(resp)?;
Err(code_to_error(eresp.error))
}
}
}
impl Default for Client {
fn default() -> Client {
Client::new()
}
}
pub async fn shorten(long: &str) -> Result<String, Error> {
Client::new().shorten(long).await
}
fn code_to_error(resp: MwError) -> Error {
match resp.code.as_str() {
"urlshortener-blocked" => Error::Blocked(resp),
"urlshortener-disabled" => Error::Disabled(resp),
"urlshortener-url-too-long" => Error::TooLong(resp),
"urlshortener-ratelimit" => Error::RateLimited(resp),
"urlshortener-deleted" => Error::Deleted(resp),
"urlshortener-error-malformed-url" => Error::Malformed(resp),
"urlshortener-error-badports" => Error::BadPorts(resp),
"urlshortener-error-nouserpass" => Error::NoUserPass(resp),
"urlshortener-error-disallowed-url" => Error::Disallowed(resp),
_ => Error::Unknown(resp),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_shorten() {
let resp = shorten("https://en.wikipedia.org/").await;
match resp {
Ok(short_url) => assert_eq!("https://w.wiki/G9", short_url),
Err(Error::Blocked(_)) => {}
Err(error) => panic!("{}", error.to_string()),
}
}
#[tokio::test]
async fn test_invalid_shorten() {
let resp = shorten("https://example.org/").await;
assert!(resp.is_err());
let error = resp.err().unwrap();
assert!(error
.to_string()
.starts_with("urlshortener-error-disallowed-url:"));
}
#[tokio::test]
async fn test_unknown_error() {
let client = Client::new_for_api("https://legoktm.com/w/api.php");
let resp = client.shorten("https://example.org/").await;
assert!(resp.is_err());
let error = resp.err().unwrap();
assert!(error.to_string().starts_with("badvalue:"));
}
}