1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4pub mod chapter;
11
12pub mod directory;
19
20pub mod directory_list;
27
28pub mod recent_chapter;
32
33#[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
63pub 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#[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 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 pub fn with_client(client: reqwest::Client) -> DynastyApi {
114 DynastyApi { client }
115 }
116
117 pub async fn chapter(&self, config: ChapterConfig) -> Result<Chapter> {
119 self.execute_request_into_json(config).await
120 }
121
122 pub fn clone_reqwest_client(&self) -> reqwest::Client {
124 self.client.clone()
125 }
126
127 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 pub async fn directory_list(&self, config: DirectoryListConfig) -> Result<DirectoryList> {
140 self.execute_request_into_json(config).await
141 }
142
143 pub async fn recent(&self, config: RecentChapterConfig) -> Result<RecentChapter> {
145 self.execute_request_into_json(config).await
146 }
147
148 #[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 #[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}