w-wiki 0.2.0

Shorten URLs using w.wiki service
Documentation
/*
Copyright (C) 2020-2021 Kunal Mehta <legoktm@debian.org>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
//! # w-wiki
//! Conveniently shorten URLs using the [`w.wiki`](https://w.wiki/) service.
//! Only some [Wikimedia](https://www.wikimedia.org/) projects are supported,
//! see the [documentation](https://meta.wikimedia.org/wiki/Wikimedia_URL_Shortener)
//! for more details.
//!
//! `w-wiki`'s primary [`shorten`] function is asynchronous.
//!
//! ```
//! # async fn run() {
//! let short_url = w_wiki::shorten("https://www.wikidata.org/wiki/Q575650")
//!     .await.unwrap();
//! # }
//! ```
//!
//! If you plan on making multiple requests, a [`Client`] is available that holds
//! connections.
//!
//! ```
//! # async fn run() {
//! let client = w_wiki::Client::new();
//! let short_url = client.shorten("https://www.wikidata.org/wiki/Q575650")
//!     .await.unwrap();
//! # }
//! ```
//!
//! This library can be used by any MediaWiki wiki that has the
//! [UrlShortener extension](https://www.mediawiki.org/wiki/Extension:UrlShortener)
//! installed.
//!
//! ```
//! # async fn run() {
//! let client = w_wiki::Client::new_for_api("https://example.org/w/api.php");
//! # }
//! ```

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,
}

/// API error info
#[derive(Debug, Deserialize)]
pub struct MwError {
    /// Error code
    code: String,
    /// Error description
    info: String,
}

#[derive(thiserror::Error, Debug)]
pub enum Error {
    /// HTTP request errors, forwarded from `reqwest`
    #[error("HTTP error: {0}")]
    HttpError(#[from] reqwest::Error),
    /// HTTP request errors, forwarded from `serde_json`
    #[error("JSON error: {0}")]
    JsonError(#[from] serde_json::Error),
    /// Account/IP address is blocked
    #[error("{}: {}", .0.code, .0.info)]
    Blocked(MwError),
    /// UrlShortener is disabled
    #[error("{}: {}", .0.code, .0.info)]
    Disabled(MwError),
    /// URL is too long to be shortened
    #[error("{}: {}", .0.code, .0.info)]
    TooLong(MwError),
    /// Rate limit exceeded
    #[error("{}: {}", .0.code, .0.info)]
    RateLimited(MwError),
    /// Long URL has already been deleted
    #[error("{}: {}", .0.code, .0.info)]
    Deleted(MwError),
    /// Invalid URL provided
    #[error("{}: {}", .0.code, .0.info)]
    Malformed(MwError),
    /// Invalid ports provided
    #[error("{}: {}", .0.code, .0.info)]
    BadPorts(MwError),
    /// A username/password was provided in the URL
    #[error("{}: {}", .0.code, .0.info)]
    NoUserPass(MwError),
    /// URL domain not allowed
    #[error("{}: {}", .0.code, .0.info)]
    Disallowed(MwError),
    /// Unknown error
    #[error("{}: {}", .0.code, .0.info)]
    Unknown(MwError),
}

/// Client if you need to customize the MediaWiki api.php endpoint
pub struct Client {
    /// URL to api.php
    api_url: String,
    client: HTTPClient,
}

impl Client {
    /// Get a new instance
    pub fn new() -> Client {
        Self::new_for_api("https://meta.wikimedia.org/w/api.php")
    }

    /// Get an instance for a custom MediaWiki api.php endpoint
    ///
    /// # Example
    /// ```
    /// # use w_wiki::Client;
    /// let client = Client::new_for_api("https://example.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(),
        }
    }

    /// Shorten a URL
    ///
    /// # Example
    /// ```
    /// # use w_wiki::Client;
    /// # async fn run() {
    /// let client = Client::new();
    /// let short_url = client.shorten("https://example.org/wiki/Main_Page")
    ///     .await.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(&params)
            .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()
    }
}

/// Shorten a URL using [`w.wiki`](https://w.wiki/)
///
/// # Example
/// ```
/// # async fn run() {
/// let short_url = w_wiki::shorten("https://example.org/wiki/Main_Page")
///     .await.unwrap();
/// # }
/// ```
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),
            // Some CI servers are blocked :(
            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:"));
    }
}