1#![warn(missing_docs, clippy::empty_docs, rustdoc::broken_intra_doc_links)]
2#![doc = include_str!("../README.md")]
3
4use crate::constants::SECURE_IMAGE_HOST;
5use std::collections::HashMap;
6
7use futures_util::TryStreamExt;
8use tokio::io::{self, AsyncWriteExt};
9use tosho_common::{
10 ToshoAuthError, ToshoClientError, ToshoResult, bail_on_error, parse_json_response_failable,
11};
12
13use crate::{constants::BASE_API, models::ErrorResponse};
14
15pub mod constants;
16pub mod filters;
17pub mod models;
18
19pub use filters::*;
20
21#[derive(Clone)]
40pub struct NIClient {
41 inner: reqwest::Client,
42 constants: &'static crate::constants::Constants,
43 token: Option<String>,
44}
45
46impl std::fmt::Debug for NIClient {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("NIClient")
49 .field("inner", &"reqwest::Client")
50 .field("constants", &self.constants)
51 .field("token", &self.token.as_deref().map(|_| "****"))
52 .finish()
53 }
54}
55
56impl NIClient {
57 pub fn new(
63 token: Option<&str>,
64 constants: &'static crate::constants::Constants,
65 ) -> ToshoResult<Self> {
66 Self::make_client(token, constants, None)
67 }
68
69 pub fn with_proxy(&self, proxy: reqwest::Proxy) -> ToshoResult<Self> {
76 Self::make_client(self.token.as_deref(), self.constants, Some(proxy))
77 }
78
79 fn make_client(
80 token: Option<impl Into<String>>,
81 constants: &'static crate::constants::Constants,
82 proxy: Option<reqwest::Proxy>,
83 ) -> ToshoResult<Self> {
84 let mut headers = reqwest::header::HeaderMap::new();
85 headers.insert(
86 reqwest::header::USER_AGENT,
87 reqwest::header::HeaderValue::from_static(constants.ua),
88 );
89 headers.insert(
90 reqwest::header::ORIGIN,
91 reqwest::header::HeaderValue::from_static(crate::constants::BASE_WEB),
92 );
93 headers.insert(
94 reqwest::header::REFERER,
95 reqwest::header::HeaderValue::from_static(crate::constants::BASE_WEB),
96 );
97 headers.insert(
98 reqwest::header::HOST,
99 reqwest::header::HeaderValue::from_static(crate::constants::API_HOST),
100 );
101
102 let client = reqwest::Client::builder()
103 .http2_adaptive_window(true)
104 .use_rustls_tls()
105 .default_headers(headers);
106
107 let client = match proxy {
108 Some(proxy) => client
109 .proxy(proxy)
110 .build()
111 .map_err(ToshoClientError::BuildError),
112 None => client.build().map_err(ToshoClientError::BuildError),
113 }?;
114
115 Ok(Self {
116 inner: client,
117 constants,
118 token: token.map(Into::into),
119 })
120 }
121
122 fn auth_headers(&self, prefix_bearer: bool) -> ToshoResult<reqwest::header::HeaderMap> {
126 let token = self
127 .token
128 .as_deref()
129 .ok_or(ToshoAuthError::UnknownSession)?;
130
131 let header_token = if prefix_bearer {
132 format!("Bearer {}", token)
133 } else {
134 token.to_string()
135 };
136 let mut headers = reqwest::header::HeaderMap::new();
137 headers.insert(
138 reqwest::header::AUTHORIZATION,
139 reqwest::header::HeaderValue::from_str(&header_token).map_err(|_| {
140 ToshoClientError::HeaderParseError(
141 "Invalid bearer token provided to client".to_string(),
142 )
143 })?,
144 );
145
146 Ok(headers)
147 }
148
149 async fn request<T>(
161 &self,
162 method: reqwest::Method,
163 endpoint: &str,
164 data: Option<serde_json::Value>,
165 params: Option<HashMap<String, String>>,
166 headers: Option<reqwest::header::HeaderMap>,
167 ) -> ToshoResult<T>
168 where
169 T: serde::de::DeserializeOwned,
170 {
171 let endpoint = format!("{}/api/v1{}", BASE_API, endpoint);
172 let mut extend_headers = reqwest::header::HeaderMap::new();
173 if let Some(hdrs) = headers {
175 for (key, value) in hdrs.iter() {
176 extend_headers.insert(key, value.clone());
177 }
178 }
179
180 let request = match (data.clone(), params.clone()) {
181 (None, None) => self.inner.request(method, endpoint).headers(extend_headers),
182 (Some(data), None) => {
183 extend_headers.insert(
184 reqwest::header::CONTENT_TYPE,
185 reqwest::header::HeaderValue::from_static("application/json"),
186 );
187 self.inner
188 .request(method, endpoint)
189 .json(&data)
190 .headers(extend_headers)
191 }
192 (None, Some(params)) => self
193 .inner
194 .request(method, endpoint)
195 .headers(extend_headers)
196 .query(¶ms),
197 (Some(_), Some(_)) => {
198 bail_on_error!("Cannot have both data and params")
199 }
200 };
201
202 parse_json_response_failable::<T, ErrorResponse>(request.send().await?).await
203 }
204
205 pub async fn get_issues(
210 &self,
211 filters: &filters::Filter,
212 ) -> ToshoResult<models::IssueListResponse> {
213 let params = filters.to_params();
214 self.request(reqwest::Method::GET, "/issues", None, Some(params), None)
215 .await
216 }
217
218 pub async fn get_issue(&self, issue_id: u32) -> ToshoResult<models::IssueDetail> {
223 let resp = self
224 .request::<models::IssueDetailResponse>(
225 reqwest::Method::GET,
226 &format!("/issues/{}", issue_id),
227 None,
228 None,
229 None,
230 )
231 .await?;
232
233 Ok(resp.data())
234 }
235
236 pub async fn get_series_runs(
241 &self,
242 filters: &filters::Filter,
243 ) -> ToshoResult<models::series::SeriesRunList> {
244 let params = filters.to_params();
245 self.request(
246 reqwest::Method::GET,
247 "/series_run",
248 None,
249 Some(params),
250 None,
251 )
252 .await
253 }
254
255 pub async fn get_series_run(
260 &self,
261 series_run_id: u32,
262 ) -> ToshoResult<models::series::SeriesRunWithEditions> {
263 let resp = self
264 .request::<models::series::SeriesRunWithEditionsResponse>(
265 reqwest::Method::GET,
266 &format!("/series_run/{}", series_run_id),
267 None,
268 None,
269 None,
270 )
271 .await?;
272
273 Ok(resp.data())
274 }
275
276 pub async fn get_publishers(
281 &self,
282 filters: Option<&filters::Filter>,
283 ) -> ToshoResult<models::others::PublishersList> {
284 let params = match filters {
285 Some(f) => f.to_params(),
286 None => filters::Filter::default()
287 .with_order(filters::SortBy::Name, filters::SortOrder::ASC)
288 .with_per_page(25)
289 .to_params(),
290 };
291
292 self.request(
293 reqwest::Method::GET,
294 "/publishers",
295 None,
296 Some(params),
297 None,
298 )
299 .await
300 }
301
302 pub async fn get_publisher(
307 &self,
308 publisher_slug: impl Into<String>,
309 ) -> ToshoResult<models::common::Publisher> {
310 let resp = self
311 .request::<models::others::PublisherDetailResponse>(
312 reqwest::Method::GET,
313 &format!("/publishers/{}", publisher_slug.into()),
314 None,
315 None,
316 None,
317 )
318 .await?;
319
320 Ok(resp.data())
321 }
322
323 pub async fn get_publisher_imprints(
328 &self,
329 publisher_slug: impl Into<String>,
330 ) -> ToshoResult<models::others::ImprintsList> {
331 let params = HashMap::from([("slug".to_string(), publisher_slug.into())]);
332 self.request(
333 reqwest::Method::GET,
334 "/publisher_imprints",
335 None,
336 Some(params),
337 None,
338 )
339 .await
340 }
341
342 pub async fn get_genres(
347 &self,
348 filters: Option<&filters::Filter>,
349 ) -> ToshoResult<models::others::GenresList> {
350 let params = match filters {
351 Some(f) => f.to_params(),
352 None => filters::Filter::default()
353 .with_order(filters::SortBy::Name, filters::SortOrder::ASC)
354 .with_per_page(100)
355 .to_params(),
356 };
357
358 self.request(reqwest::Method::GET, "/genres", None, Some(params), None)
359 .await
360 }
361
362 pub async fn get_creators(
367 &self,
368 filters: Option<&filters::Filter>,
369 ) -> ToshoResult<models::others::CreatorsList> {
370 let params = match filters {
371 Some(f) => f.to_params(),
372 None => filters::Filter::default()
373 .with_order(filters::SortBy::DisplayName, filters::SortOrder::ASC)
374 .with_per_page(25)
375 .to_params(),
376 };
377
378 self.request(reqwest::Method::GET, "/creators", None, Some(params), None)
379 .await
380 }
381
382 pub async fn get_marketplace_books(
387 &self,
388 filters: Option<&filters::Filter>,
389 ) -> ToshoResult<models::others::MarketplaceBooksList> {
390 let params = match filters {
391 Some(f) => f.to_params(),
392 None => filters::Filter::default()
393 .with_order(filters::SortBy::EditionPriceMin, filters::SortOrder::ASC)
394 .with_per_page(25)
395 .to_params(),
396 };
397
398 self.request(
399 reqwest::Method::GET,
400 "/marketplace/books",
401 None,
402 Some(params),
403 None,
404 )
405 .await
406 }
407
408 pub async fn get_marketplace_editions(
413 &self,
414 filters: Option<&filters::Filter>,
415 ) -> ToshoResult<models::others::MarketplaceDetailedEditionsList> {
416 let params = match filters {
417 Some(f) => f.to_params(),
418 None => filters::Filter::default()
419 .with_order(filters::SortBy::MarketplacePrice, filters::SortOrder::ASC)
420 .with_per_page(25)
421 .to_params(),
422 };
423
424 self.request(
425 reqwest::Method::GET,
426 "/marketplace/editions",
427 None,
428 Some(params),
429 None,
430 )
431 .await
432 }
433
434 pub async fn get_marketplace_book_editions(
440 &self,
441 issue_id: impl Into<String>,
442 filters: Option<&filters::Filter>,
443 ) -> ToshoResult<models::others::MarketplaceEditionsList> {
444 let mut params = match filters {
445 Some(f) => f.to_params(),
446 None => filters::Filter::default()
447 .clear_filters()
448 .with_order(filters::SortBy::BookIndex, filters::SortOrder::ASC)
449 .to_params(),
450 };
451 params.insert("book_id".to_string(), issue_id.into());
452
453 self.request(
454 reqwest::Method::GET,
455 "/marketplace/editions",
456 None,
457 Some(params),
458 None,
459 )
460 .await
461 }
462
463 pub async fn get_series_run_collections(
470 &self,
471 filters: Option<&filters::Filter>,
472 ) -> ToshoResult<models::series::SeriesRunList> {
473 let params = match filters {
474 Some(f) => f.to_params(),
475 None => filters::Filter::default()
476 .with_order(filters::SortBy::Title, filters::SortOrder::ASC)
477 .with_per_page(18)
478 .to_params(),
479 };
480
481 let headers = self.auth_headers(false)?;
482 self.request(
483 reqwest::Method::GET,
484 "/collection/series_run",
485 None,
486 Some(params),
487 Some(headers),
488 )
489 .await
490 }
491
492 pub async fn get_issue_collections(
499 &self,
500 filters: &filters::Filter,
501 ) -> ToshoResult<models::PurchasedIssuesResponse> {
502 let params = filters.to_params();
503
504 let headers = self.auth_headers(false)?;
505 self.request(
506 reqwest::Method::GET,
507 "/collection/books",
508 None,
509 Some(params),
510 Some(headers),
511 )
512 .await
513 }
514
515 pub async fn get_issue_editions_collections(
522 &self,
523 issue_id: impl Into<String>,
524 ) -> ToshoResult<models::others::CollectedEditionList> {
525 let headers = self.auth_headers(false)?;
526 self.request(
527 reqwest::Method::GET,
528 &format!("/collection/books/{}/editions", issue_id.into()),
529 None,
530 None,
531 Some(headers),
532 )
533 .await
534 }
535
536 pub async fn get_reading_history(&self) -> ToshoResult<models::others::ReadingHistoryList> {
540 let headers = self.auth_headers(false)?;
541 self.request(
542 reqwest::Method::GET,
543 "/collection/books/bookmarked",
544 None,
545 None,
546 Some(headers),
547 )
548 .await
549 }
550
551 pub async fn get_issue_reader(
558 &self,
559 issue_id: u32,
560 ) -> ToshoResult<models::reader::ReaderPagesWithMeta> {
561 let headers = self.auth_headers(false)?;
562
563 let response = self
564 .request::<models::reader::ReaderPagesResponse>(
565 reqwest::Method::GET,
566 &format!("/frameflow/{}", issue_id),
567 None,
568 None,
569 Some(headers),
570 )
571 .await?;
572
573 Ok(response.data())
575 }
576
577 pub async fn report_page_view(
585 &self,
586 issue_uuid: impl Into<String>,
587 page_number: u32,
588 ) -> ToshoResult<models::AckResponse> {
589 let data = serde_json::json!({
590 "book": {
591 "page": page_number,
592 }
593 });
594
595 let headers = self.auth_headers(true)?;
596
597 self.request(
598 reqwest::Method::PATCH,
599 &format!("/collection/books/{}/bookmark", issue_uuid.into()),
600 Some(data),
601 None,
602 Some(headers),
603 )
604 .await
605 }
606
607 pub async fn stream_download(
615 &self,
616 url: impl AsRef<str>,
617 mut writer: impl io::AsyncWrite + Unpin,
618 ) -> ToshoResult<()> {
619 let res = self
620 .inner
621 .get(url.as_ref())
622 .headers({
623 let mut headers = reqwest::header::HeaderMap::new();
624 headers.insert(
625 "Host",
626 reqwest::header::HeaderValue::from_static(SECURE_IMAGE_HOST),
627 );
628 headers.insert(
629 "User-Agent",
630 reqwest::header::HeaderValue::from_static(self.constants.image_ua),
631 );
632 headers
633 })
634 .send()
635 .await?;
636
637 if !res.status().is_success() {
639 Err(tosho_common::ToshoError::from(res.status()))
640 } else {
641 let mut stream = res.bytes_stream();
642 while let Some(item) = stream.try_next().await? {
643 writer.write_all(&item).await?;
644 writer.flush().await?;
645 }
646
647 Ok(())
648 }
649 }
650
651 pub async fn get_profile(&self) -> ToshoResult<models::others::CustomerDetail> {
655 let headers = self.auth_headers(true)?;
656
657 let resp = self
658 .request::<models::others::CustomerDetailResponse>(
659 reqwest::Method::GET,
660 "/profile",
661 None,
662 None,
663 Some(headers),
664 )
665 .await?;
666
667 Ok(resp.data())
668 }
669
670 pub async fn refresh_token(
677 &self,
678 refresh_token: impl Into<String>,
679 ) -> ToshoResult<models::common::RefreshedTokenResponse> {
680 let refresh_tok: String = refresh_token.into();
681 let data = serde_json::json!({
682 "refresh_token": refresh_tok
683 });
684 let headers = self.auth_headers(true)?;
685
686 self.request(
687 reqwest::Method::POST,
688 "/auth/refresh_token",
689 Some(data),
690 None,
691 Some(headers),
692 )
693 .await
694 }
695
696 pub async fn login(
702 email: impl Into<String>,
703 password: impl Into<String>,
704 proxy: Option<reqwest::Proxy>,
705 ) -> ToshoResult<models::others::LoginResponse> {
706 let data = serde_json::json!({
707 "customer": {
708 "email": email.into(),
709 "password": password.into(),
710 }
711 });
712
713 let client = reqwest::Client::builder()
714 .http2_adaptive_window(true)
715 .use_rustls_tls()
716 .default_headers({
717 let mut headers = reqwest::header::HeaderMap::new();
718 headers.insert(
719 reqwest::header::USER_AGENT,
720 reqwest::header::HeaderValue::from_static(constants::get_constants(1).ua),
721 );
722 headers.insert(
723 reqwest::header::ORIGIN,
724 reqwest::header::HeaderValue::from_static(crate::constants::BASE_WEB),
725 );
726 headers.insert(
727 reqwest::header::REFERER,
728 reqwest::header::HeaderValue::from_static(crate::constants::BASE_WEB),
729 );
730 headers.insert(
731 reqwest::header::HOST,
732 reqwest::header::HeaderValue::from_static(crate::constants::API_HOST),
733 );
734 headers
735 });
736
737 let client = match proxy {
738 Some(proxy) => client
739 .proxy(proxy)
740 .build()
741 .map_err(ToshoClientError::BuildError)?,
742 None => client.build().map_err(ToshoClientError::BuildError)?,
743 };
744
745 let request = client
746 .post(format!("{}/api/v1/auth/login", BASE_API))
747 .json(&data);
748
749 parse_json_response_failable::<models::others::LoginResponse, ErrorResponse>(
750 request.send().await?,
751 )
752 .await
753 }
754}
755
756pub fn format_price(price: u64) -> f64 {
760 (price as f64) / 100.0
761}