libpixiv 0.1.2

pixiv.net API client library
Documentation
use chrono::Local;
use reqwest::{
    header::{HeaderName, AUTHORIZATION, CONTENT_TYPE, USER_AGENT},
    IntoUrl, Method, RequestBuilder, StatusCode,
};
use serde::Deserialize;
use serde_json::Value;
use std::error::Error;
use std::sync::Arc;
use tokio::sync::Mutex;

use crate::PixivAppError;

pub struct PixivAppClient {
    /// bearer token
    pub(crate) access_token: Arc<Mutex<String>>,
    pub(crate) refresh_token: Arc<Mutex<String>>,
    pub(crate) http_client: reqwest::Client,
    pub(crate) host: String,
    pub(crate) platform: params::Platform,
}

pub mod params {
    use std::fmt::{Display, Formatter, Result};

    pub enum Platform {
        IOS,
        Android,
    }

    impl Display for Platform {
        fn fmt(&self, f: &mut Formatter<'_>) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::IOS => "ios",
                    Self::Android => "android",
                }
            )
        }
    }

    /// Visibility for content
    pub enum Visibility {
        /// Visible for everyone (given no other filters apply)
        Public,
        /// Only visible by logged in user or other "close" groups if any
        Private,
    }

    impl Display for Visibility {
        fn fmt(&self, f: &mut Formatter) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::Public => String::from("public"),
                    Self::Private => String::from("private"),
                }
            )
        }
    }

    /// Sort mode when search for content 
    ///
    /// Some api routes only work with date related modes when 
    /// the user has no premium subscription.
    pub enum SortMode {
        /// date, oldest to newest
        DateAscending,
        /// date, newest to oldest
        DateDescending,
        /// popularity, most to least popular
        PopularDescending,
    }

    impl Display for SortMode {
        fn fmt(&self, f: &mut Formatter) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::DateAscending => String::from("date_asc"),
                    Self::DateDescending => String::from("date_desc"),
                    Self::PopularDescending => String::from("popular_desc"),
                }
            )
        }
    }

    /// Seach mode for illustration search.
    pub enum SearchMode {
        /// requires match by part of the queried tags
        PartialMatchForTags,
        /// only matches exact tags
        ExactMatchForTags,
        /// searches in title and caption of the content
        TitleAndCaption,
    }

    impl Display for SearchMode {
        fn fmt(&self, f: &mut Formatter<'_>) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::PartialMatchForTags => String::from("partial_match_for_tags"),
                    Self::ExactMatchForTags => String::from("exact_match_for_tags"),
                    Self::TitleAndCaption => String::from("title_and_caption"),
                }
            )
        }
    }

    /// Period for ranking search
    pub enum RankingMode {
        /// Ranking by last day
        Day,
        /// Ranking by last week
        Week,
        /// Ranking by last month
        Month,
    }

    impl Display for RankingMode {
        fn fmt(&self, f: &mut Formatter<'_>) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::Day => String::from("day"),
                    Self::Week => String::from("week"),
                    Self::Month => String::from("month"),
                }
            )
        }
    }
}

/// The client structure for easy API access.
///
/// This struct provides bindings to the (known) pixiv API.
///
/// # Examples
///
/// This is the basic usage:
/// ```
/// # use libpixiv::client::PixivAppClient;
/// # async fn f() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = PixivAppClient::new("some_refresh_token".into());
/// client.refresh_token().await;
/// client.illust_details(25308802).await;
/// # Ok(())
/// # }
/// ```
impl PixivAppClient {
    /// Create a new client using a refresh token
    pub fn new(token: String) -> Self {
        Self {
            access_token: Arc::new(Mutex::new(String::new())),
            refresh_token: Arc::new(Mutex::new(token)),
            http_client: reqwest::Client::new(),
            host: String::from("https://app-api.pixiv.net"),
            platform: params::Platform::IOS,
        }
    }

    fn md5(input: &str) -> String {
        let result = md5::compute(input);
        format!("{:02x}", result)
    }

    /// Refresh the credentials of the API client.
    ///
    /// <div class="warning">This function panics if the authorization fails</div>
    pub async fn refresh_token(&mut self) {
        let time = Local::now().format("%y-%m-%dT%H:%m:%s+00:00");
        let time_str = format!("{}", time);
        let cloned_refresh_token = Arc::clone(&self.refresh_token);
        let cloned_refresh_token_str = &cloned_refresh_token.lock().await;

        let client_id = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
        let client_secret = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
        let hash_input = format!(
            "{}{}\n",
            &time_str, "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"
        );
        let hash = PixivAppClient::md5(hash_input.as_str());

        let req = self.http_client
            .post("https://oauth.secure.pixiv.net/auth/token")
            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
            .header(USER_AGENT, "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)")
            .header(HeaderName::from_lowercase(b"x-client-time").unwrap(), &time_str)
            .header(HeaderName::from_lowercase(b"x-client-hash").unwrap(), hash)
            .body(format!("grant_type=refresh_token&client_id={}&refresh_token={}&client_secret={}&get_secure_url=1", client_id, cloned_refresh_token_str, client_secret))
            .build()
            .expect("failed to build login request");

        let r = match self.http_client.execute(req).await {
            Ok(r) => r.text().await.unwrap(),
            Err(_e) => return,
        };

        if cfg!(debug_assertions) {
            eprintln!("{}", r);
        }

        let d: Value = serde_json::from_str(&r).unwrap();

        assert!(!d["response"]["access_token"].is_null());
        assert!(!d["response"]["refresh_token"].is_null());

        self.access_token = Arc::new(Mutex::new(String::from(
            d["response"]["access_token"].as_str().unwrap(),
        )));
        self.refresh_token = Arc::new(Mutex::new(String::from(
            d["response"]["refresh_token"].as_str().unwrap(),
        )));
    }

    /// Prepare a request builder for use with an endpoint that requires a token.
    pub(crate) async fn auth_request<U: IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
        self.http_client
            .request(method, url)
            .header(
                AUTHORIZATION,
                format!("Bearer {}", self.access_token.lock().await),
            )
            .header(HeaderName::from_lowercase(b"app-os").unwrap(), "ios")
            .header(
                HeaderName::from_lowercase(b"app-os-version").unwrap(),
                "12.2",
            )
            .header(HeaderName::from_lowercase(b"app-version").unwrap(), "7.6.2")
            .header(USER_AGENT, "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)")
    }

    /// Process a request built in another function and return the appropriate type
    pub(crate) async fn process_request<T: for<'de> Deserialize<'de>>(
        &self,
        reqb: RequestBuilder,
    ) -> Result<T, Box<dyn Error + Send + Sync>> {
        Ok(reqb
            .send()
            .await
            .map_or_else(
                |e| Err::<reqwest::Response, Box<dyn Error + Send + Sync>>(Box::new(e)),
                |r| match r.status() {
                    x if StatusCode::is_success(&x) || StatusCode::is_redirection(&x) => Ok(r),
                    x if StatusCode::is_client_error(&x) => Err(Box::new(match x {
                        StatusCode::BAD_REQUEST => PixivAppError::RequestFailed,
                        StatusCode::UNAUTHORIZED => PixivAppError::MissingLogin,
                        StatusCode::NOT_FOUND => PixivAppError::TargetNotFound,
                        StatusCode::TOO_MANY_REQUESTS => PixivAppError::RateLimitReached,
                        s => PixivAppError::UnhandledStatus(s),
                    })),
                    x => Err(Box::new(PixivAppError::UnhandledStatus(x))),
                },
            )?
            .text()
            .await
            .map(|r| async move {
                let text = r;
                if cfg!(debug_assertions) {
                    eprintln!("{}", text);
                }
                let d = &mut serde_json::Deserializer::from_str(&text);
                let r = serde_path_to_error::deserialize(d);
                r
            })
            .map_err(Box::new)?
            .await?)
    }
}

#[cfg(test)]
pub mod tests {
    use super::*;

    pub static USER: &str = "Aio";
    pub static USER_ID: u32 = 25308802;
    pub static ILLUST_ID: u32 = 132610892;

    pub async fn get_client() -> PixivAppClient {
        let mut client = PixivAppClient::new(env!("PIXIV_REFRESH_TOKEN").to_string());
        client.refresh_token().await;
        client
    }
}