dynasty_api/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4/// A wrapper around Dynasty Reader's chapter
5///
6/// # Example urls
7///
8/// - <https://dynasty-scans.com/chapters/momoiro_trance_ch01>
9/// - <https://dynasty-scans.com/chapters/liar_satsuki_can_see_death_ch54>
10pub mod chapter;
11
12/// A wrapper around Dynasty Reader's directory
13///
14/// # Example urls
15///
16/// - <https://dynasty-scans.com/tags>
17/// - <https://dynasty-scans.com/doujins>
18pub mod directory;
19
20/// A wrapper around Dynasty Reader's directory list
21///
22/// # Example urls
23///
24/// - <https://dynasty-scans.com/doujins/a_certain_scientific_railgun>
25/// - <https://dynasty-scans.com/tags/aaaaaangst>
26pub mod directory_list;
27
28/// A wrapper around Dynasty Reader's recently added chapters
29///
30/// <https://dynasty-scans.com/chapters/added>
31pub mod recent_chapter;
32
33/// A wrapper around Dynasty Reader's search
34///
35/// <https://dynasty-scans.com/search>
36#[cfg(feature = "search")]
37pub mod search;
38
39pub(crate) mod tag;
40pub(crate) mod utils;
41
42use anyhow::{Context, Result};
43use once_cell::sync::Lazy;
44use reqwest::Url;
45use serde::de::DeserializeOwned;
46
47use chapter::Chapter;
48use directory::Directory;
49use directory_list::DirectoryList;
50use recent_chapter::RecentChapter;
51
52pub use chapter::ChapterConfig;
53pub use directory::DirectoryConfig;
54pub use directory_list::DirectoryListConfig;
55pub use recent_chapter::RecentChapterConfig;
56pub use tag::TagItem;
57
58#[cfg(feature = "search")]
59pub use search::{suggestion::SearchSuggestionConfig, SearchConfig};
60
61static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
62
63/// Dynasty Reader's base url <https://dynasty-scans.com>
64///
65/// You can overwrite it by setting and exporting `DYNASTY_READER_BASE` environment variable
66///
67/// # Panics
68///
69/// Panics if `DYNASTY_READER_BASE` environment variable can't be parsed into [reqwest::Url],
70/// or if the parsed [reqwest::Url] cannot be a base
71pub static DYNASTY_READER_BASE: Lazy<Url> = Lazy::new(|| {
72    let s = std::env::var("DYNASTY_READER_BASE")
73        .unwrap_or_else(|_| "https://dynasty-scans.com".to_string());
74
75    let url = Url::parse(&s)
76        .expect("failed to parse `DYNASTY_READER_BASE` environment variable as `reqwest::Url`");
77
78    if url.cannot_be_a_base() {
79        panic!("`DYNASTY_READER_BASE` environment variable has invalid `reqwest::Url`")
80    }
81
82    url
83});
84
85/// A Dynasty Reader's client
86///
87/// [DynastyApi] only has one struct field, a [reqwest::Client] that will be used to send requests
88#[derive(Debug, Clone)]
89pub struct DynastyApi {
90    client: reqwest::Client,
91}
92
93impl Default for DynastyApi {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl DynastyApi {
100    /// Creates a Dynasty Reader's client with default [reqwest::Client]
101    ///
102    /// The default [reqwest::Client] has user agent set to `concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),)`
103    pub fn new() -> DynastyApi {
104        let client = reqwest::ClientBuilder::new()
105            .user_agent(USER_AGENT)
106            .build()
107            .unwrap();
108
109        DynastyApi::with_client(client)
110    }
111
112    /// Creates a Dynasty Reader's client
113    pub fn with_client(client: reqwest::Client) -> DynastyApi {
114        DynastyApi { client }
115    }
116
117    /// Gets a [Chapter]
118    pub async fn chapter(&self, config: ChapterConfig) -> Result<Chapter> {
119        self.execute_request_into_json(config).await
120    }
121
122    /// Clones this Dynasty Reader's [reqwest::Client]
123    pub fn clone_reqwest_client(&self) -> reqwest::Client {
124        self.client.clone()
125    }
126
127    /// Gets a [Directory]
128    pub async fn directory(&self, config: DirectoryConfig) -> Result<Directory> {
129        use directory::UntaggedDirectory;
130
131        let directory_kind = config.kind;
132
133        self.execute_request_into_json(config)
134            .await
135            .map(|untagged: UntaggedDirectory| untagged.into_tagged(directory_kind))
136    }
137
138    /// Gets a [DirectoryList]
139    pub async fn directory_list(&self, config: DirectoryListConfig) -> Result<DirectoryList> {
140        self.execute_request_into_json(config).await
141    }
142
143    /// Gets a [RecentChapter]
144    pub async fn recent(&self, config: RecentChapterConfig) -> Result<RecentChapter> {
145        self.execute_request_into_json(config).await
146    }
147
148    /// Gets a [Search]
149    #[cfg(feature = "search")]
150    pub async fn search(&self, config: SearchConfig) -> Result<search::Search> {
151        self.execute_request(config).await?.text().await?.parse()
152    }
153
154    /// Gets [SearchSuggestion]s
155    #[cfg(feature = "search")]
156    pub async fn search_suggestions(
157        &self,
158        config: SearchSuggestionConfig,
159    ) -> Result<Vec<search::suggestion::SearchSuggestion>> {
160        self.execute_request_into_json(config).await
161    }
162
163    async fn execute_request_into_json<R, T>(&self, route: R) -> Result<T>
164    where
165        R: DynastyReaderRoute,
166        T: DeserializeOwned,
167    {
168        let request_url = route.request_url();
169
170        self.execute_request(route)
171            .await?
172            .json::<T>()
173            .await
174            .with_context(|| format!("unable to parse `{}` response", request_url,))
175    }
176
177    async fn execute_request<R: DynastyReaderRoute>(&self, route: R) -> Result<reqwest::Response> {
178        let request_url = route.request_url();
179
180        route
181            .request_builder(&self.client, request_url.clone())
182            .send()
183            .await
184            .with_context(|| format!("failed to send request to `{}`", request_url))?
185            .error_for_status()
186            .map_err(|reqwest_error| {
187                anyhow::anyhow!(
188                    "request to `{}` returns an unexpected status code `{}`",
189                    request_url,
190                    reqwest_error
191                        .status()
192                        .map(|status| status.as_u16())
193                        .unwrap_or(500)
194                )
195            })
196    }
197}
198
199trait DynastyReaderRoute {
200    fn request_builder(&self, client: &reqwest::Client, url: Url) -> reqwest::RequestBuilder;
201
202    fn request_url(&self) -> reqwest::Url;
203}
204
205#[cfg(test)]
206mod test_utils {
207    use std::{future::Future, time::Duration};
208
209    use anyhow::Result;
210    use once_cell::sync::Lazy;
211    use tryhard::{backoff_strategies::ExponentialBackoff, NoOnRetry, RetryFutureConfig};
212
213    use super::DynastyApi;
214
215    pub(crate) static DEFAULT_CLIENT: Lazy<DynastyApi> = Lazy::new(DynastyApi::default);
216    pub(crate) static TRYHARD_CONFIG: Lazy<RetryFutureConfig<ExponentialBackoff, NoOnRetry>> =
217        Lazy::new(|| RetryFutureConfig::new(7).exponential_backoff(Duration::from_millis(100)));
218
219    pub async fn tryhard_configs<I, F, FF, T>(
220        configs: impl IntoIterator<Item = I>,
221        future: F,
222    ) -> Result<()>
223    where
224        I: Clone + Send + Sync + 'static,
225        F: Fn(&'static DynastyApi, I) -> FF + Send + Sync + Copy + 'static,
226        FF: Future<Output = Result<T>> + Send + 'static,
227        T: Send + 'static,
228    {
229        let mut handles = Vec::new();
230        for config in configs.into_iter() {
231            handles.push(tokio::spawn({
232                tryhard::retry_fn(move || future(&DEFAULT_CLIENT, config.clone()))
233                    .with_config(*TRYHARD_CONFIG)
234            }))
235        }
236
237        for handle in handles {
238            handle.await??;
239        }
240
241        Ok(())
242    }
243}