dynasty-api 1.1.0

Dynasty Reader's wrappers
Documentation
#![doc = include_str!("../README.md")]
#![warn(missing_docs)]

/// A wrapper around Dynasty Reader's chapter
///
/// # Example urls
///
/// - <https://dynasty-scans.com/chapters/momoiro_trance_ch01>
/// - <https://dynasty-scans.com/chapters/liar_satsuki_can_see_death_ch54>
pub mod chapter;

/// A wrapper around Dynasty Reader's directory
///
/// # Example urls
///
/// - <https://dynasty-scans.com/tags>
/// - <https://dynasty-scans.com/doujins>
pub mod directory;

/// A wrapper around Dynasty Reader's directory list
///
/// # Example urls
///
/// - <https://dynasty-scans.com/doujins/a_certain_scientific_railgun>
/// - <https://dynasty-scans.com/tags/aaaaaangst>
pub mod directory_list;

/// A wrapper around Dynasty Reader's recently added chapters
///
/// <https://dynasty-scans.com/chapters/added>
pub mod recent_chapter;

/// A wrapper around Dynasty Reader's search
///
/// <https://dynasty-scans.com/search>
#[cfg(feature = "search")]
pub mod search;

pub(crate) mod tag;
pub(crate) mod utils;

use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use reqwest::Url;
use serde::de::DeserializeOwned;

use chapter::Chapter;
use directory::Directory;
use directory_list::DirectoryList;
use recent_chapter::RecentChapter;

pub use chapter::ChapterConfig;
pub use directory::DirectoryConfig;
pub use directory_list::DirectoryListConfig;
pub use recent_chapter::RecentChapterConfig;
pub use tag::TagItem;

#[cfg(feature = "search")]
pub use search::{suggestion::SearchSuggestionConfig, SearchConfig};

static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);

/// Dynasty Reader's base url <https://dynasty-scans.com>
///
/// You can overwrite it by setting and exporting `DYNASTY_READER_BASE` environment variable
///
/// # Panics
///
/// Panics if `DYNASTY_READER_BASE` environment variable can't be parsed into [reqwest::Url],
/// or if the parsed [reqwest::Url] cannot be a base
pub static DYNASTY_READER_BASE: Lazy<Url> = Lazy::new(|| {
    let s = std::env::var("DYNASTY_READER_BASE")
        .unwrap_or_else(|_| "https://dynasty-scans.com".to_string());

    let url = Url::parse(&s)
        .expect("failed to parse `DYNASTY_READER_BASE` environment variable as `reqwest::Url`");

    if url.cannot_be_a_base() {
        panic!("`DYNASTY_READER_BASE` environment variable has invalid `reqwest::Url`")
    }

    url
});

/// A Dynasty Reader's client
///
/// [DynastyApi] only has one struct field, a [reqwest::Client] that will be used to send requests
#[derive(Debug, Clone)]
pub struct DynastyApi {
    client: reqwest::Client,
}

impl Default for DynastyApi {
    fn default() -> Self {
        Self::new()
    }
}

impl DynastyApi {
    /// Creates a Dynasty Reader's client with default [reqwest::Client]
    ///
    /// The default [reqwest::Client] has user agent set to `concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),)`
    pub fn new() -> DynastyApi {
        let client = reqwest::ClientBuilder::new()
            .user_agent(USER_AGENT)
            .build()
            .unwrap();

        DynastyApi::with_client(client)
    }

    /// Creates a Dynasty Reader's client
    pub fn with_client(client: reqwest::Client) -> DynastyApi {
        DynastyApi { client }
    }

    /// Gets a [Chapter]
    pub async fn chapter(&self, config: ChapterConfig) -> Result<Chapter> {
        self.execute_request_into_json(config).await
    }

    /// Clones this Dynasty Reader's [reqwest::Client]
    pub fn clone_reqwest_client(&self) -> reqwest::Client {
        self.client.clone()
    }

    /// Gets a [Directory]
    pub async fn directory(&self, config: DirectoryConfig) -> Result<Directory> {
        use directory::UntaggedDirectory;

        let directory_kind = config.kind;

        self.execute_request_into_json(config)
            .await
            .map(|untagged: UntaggedDirectory| untagged.into_tagged(directory_kind))
    }

    /// Gets a [DirectoryList]
    pub async fn directory_list(&self, config: DirectoryListConfig) -> Result<DirectoryList> {
        self.execute_request_into_json(config).await
    }

    /// Gets a [RecentChapter]
    pub async fn recent(&self, config: RecentChapterConfig) -> Result<RecentChapter> {
        self.execute_request_into_json(config).await
    }

    /// Gets a [Search]
    #[cfg(feature = "search")]
    pub async fn search(&self, config: SearchConfig) -> Result<search::Search> {
        self.execute_request(config).await?.text().await?.parse()
    }

    /// Gets [SearchSuggestion]s
    #[cfg(feature = "search")]
    pub async fn search_suggestions(
        &self,
        config: SearchSuggestionConfig,
    ) -> Result<Vec<search::suggestion::SearchSuggestion>> {
        self.execute_request_into_json(config).await
    }

    async fn execute_request_into_json<R, T>(&self, route: R) -> Result<T>
    where
        R: DynastyReaderRoute,
        T: DeserializeOwned,
    {
        let request_url = route.request_url();

        self.execute_request(route)
            .await?
            .json::<T>()
            .await
            .with_context(|| format!("unable to parse `{}` response", request_url,))
    }

    async fn execute_request<R: DynastyReaderRoute>(&self, route: R) -> Result<reqwest::Response> {
        let request_url = route.request_url();

        route
            .request_builder(&self.client, request_url.clone())
            .send()
            .await
            .with_context(|| format!("failed to send request to `{}`", request_url))?
            .error_for_status()
            .map_err(|reqwest_error| {
                anyhow::anyhow!(
                    "request to `{}` returns an unexpected status code `{}`",
                    request_url,
                    reqwest_error
                        .status()
                        .map(|status| status.as_u16())
                        .unwrap_or(500)
                )
            })
    }
}

trait DynastyReaderRoute {
    fn request_builder(&self, client: &reqwest::Client, url: Url) -> reqwest::RequestBuilder;

    fn request_url(&self) -> reqwest::Url;
}

#[cfg(test)]
mod test_utils {
    use std::{future::Future, time::Duration};

    use anyhow::Result;
    use once_cell::sync::Lazy;
    use tryhard::{backoff_strategies::ExponentialBackoff, NoOnRetry, RetryFutureConfig};

    use super::DynastyApi;

    pub(crate) static DEFAULT_CLIENT: Lazy<DynastyApi> = Lazy::new(DynastyApi::default);
    pub(crate) static TRYHARD_CONFIG: Lazy<RetryFutureConfig<ExponentialBackoff, NoOnRetry>> =
        Lazy::new(|| RetryFutureConfig::new(7).exponential_backoff(Duration::from_millis(100)));

    pub async fn tryhard_configs<I, F, FF, T>(
        configs: impl IntoIterator<Item = I>,
        future: F,
    ) -> Result<()>
    where
        I: Clone + Send + Sync + 'static,
        F: Fn(&'static DynastyApi, I) -> FF + Send + Sync + Copy + 'static,
        FF: Future<Output = Result<T>> + Send + 'static,
        T: Send + 'static,
    {
        let mut handles = Vec::new();
        for config in configs.into_iter() {
            handles.push(tokio::spawn({
                tryhard::retry_fn(move || future(&DEFAULT_CLIENT, config.clone()))
                    .with_config(*TRYHARD_CONFIG)
            }))
        }

        for handle in handles {
            handle.await??;
        }

        Ok(())
    }
}