tosho_nids/
lib.rs

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/// Main client for interacting with the NI API.
22///
23/// # Examples
24/// ```rust,no_run
25/// use tosho_nids::{Filter, NIClient};
26///
27/// #[tokio::main]
28/// async fn main() {
29///     let constants = tosho_nids::constants::get_constants(1); // Web
30///     let client = NIClient::new(None, constants).unwrap();
31///
32///     let filter = Filter::default()
33///        .add_filter(tosho_nids::FilterType::Title, "Attack on Titan")
34///        .with_per_page(18);
35///     let issues = client.get_issues(&filter).await.unwrap();
36///     println!("Issues: {:?}", issues);
37/// }
38/// ```
39#[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    /// Create a new client instance.
58    ///
59    /// # Parameters
60    /// * `token` - JWT token for download requests, if `None` you will only be able to make non-authenticated requests.
61    /// * `constants` - Constants to use for the client, see [`crate::constants::get_constants`].
62    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    /// Attach a proxy to the client.
70    ///
71    /// This will clone the client and return a new client with the proxy attached.
72    ///
73    /// # Arguments
74    /// * `proxy` - The proxy to attach to the client
75    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    /// Create an authenticated headers map.
123    ///
124    /// Has `prefix_bearer` to prefix the token with `Bearer ` since most endpoints does not require it.
125    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    /// Make an authenticated request to the API.
150    ///
151    /// This request will automatically add all the required headers/cookies/auth method
152    /// to the request.
153    ///
154    /// # Arguments
155    /// * `method` - The HTTP method to use
156    /// * `endpoint` - The endpoint to request (e.g. `/list`) - without the `/api/v1` prefix
157    /// * `data` - The data to send in the request body (as form data)
158    /// * `params` - The query params to send in the request
159    /// * `authenticated` - Whether to make an authenticated request or not
160    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        // Check ir provided a custom headers
174        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(&params),
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    /// Get the list of issues
206    ///
207    /// # Arguments
208    /// * `filter` - The filter to apply to the request
209    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    /// Get single issue detail
219    ///
220    /// # Arguments
221    /// * `issue_id` - The issue UUID to get the detail for
222    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    /// Get the list of series runs
237    ///
238    /// # Arguments
239    /// * `filter` - The filter to apply to the request
240    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    /// Get single series run detail via ID
256    ///
257    /// # Arguments
258    /// * `series_run_id` - The series run ID to get the detail for
259    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    /// Get the list of publishers
277    ///
278    /// # Arguments
279    /// * `filter` - The filter to apply to the request
280    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    /// Get single publisher detail via slug
303    ///
304    /// # Arguments
305    /// * `publisher_slug` - The publisher slug to get the detail for
306    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    /// Get a list of publisher imprints for a publisher
324    ///
325    /// # Arguments
326    /// * `publisher_slug` - The publisher slug to get the imprints for
327    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    /// Get the list of genres
343    ///
344    /// # Arguments
345    /// * `filter` - The filter to apply to the request
346    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    /// Get the list of creators
363    ///
364    /// # Arguments
365    /// * `filter` - The filter to apply to the request
366    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    /// Get the list of books/issues sold in the marketplace
383    ///
384    /// # Arguments
385    /// * `filter` - The filter to apply to the request
386    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    /// Get the list of editions in the marketplaces
409    ///
410    /// # Arguments
411    /// * `filters` - The filter to apply to the request
412    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    /// Get the list of editions sold for an issue in the marketplace
435    ///
436    /// # Arguments
437    /// * `issue_id` - The issue UUID to get the editions for
438    /// * `filter` - The filter to apply to the request
439    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    /// Get the list of series run in your collections
464    ///
465    /// This needs authentication.
466    ///
467    /// # Arguments
468    /// * `filter` - The filter to apply to the request
469    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    /// Get the list of issues in your collection
493    ///
494    /// This needs authentication.
495    ///
496    /// # Arguments
497    /// * `filter` - The filter to apply to the request
498    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    /// Get the list of editions for an issue in your collection
516    ///
517    /// This needs authentication.
518    ///
519    /// # Arguments
520    /// * `issue_id` - The issue UUID to get the editions for
521    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    /// Get your reading history list
537    ///
538    /// This needs authentication.
539    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    /// Get issue reader information
552    ///
553    /// This needs authentication.
554    ///
555    /// # Arguments
556    /// * `issue_id` - The issue ID to get the reader info for
557    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        // Instant deref clone
574        Ok(response.data())
575    }
576
577    /// Report a page as being viewed/read
578    ///
579    /// This needs authentication.
580    ///
581    /// # Arguments
582    /// * `issue_uuid` - The issue UUID to report the page for
583    /// * `page_number` - The page number to report as being viewed/read, this is 1-based from the pages list
584    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    /// Stream download the image from the given URL.
608    ///
609    /// The URL can be obtained from [`get_issue_reader`].
610    ///
611    /// # Parameters
612    /// * `url` - The URL to download the image from.
613    /// * `writer` - The writer to write the image to.
614    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        // bail if not success
638        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    /// Get the customer profile
652    ///
653    /// This needs authentication.
654    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    /// Refresh the JWT token
671    ///
672    /// This needs authentication and needs refresh token
673    ///
674    /// # Arguments
675    /// * `refresh_token` - The refresh token to use for refreshing the JWT token
676    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    /// Login to NI and get the auth tokens
697    ///
698    /// # Arguments
699    /// * `email` - The email to use for login
700    /// * `password` - The password to use for login
701    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
756/// Format a price in USD from the API format to a human-readable format.
757///
758/// This follows the Stripe currency convention (i.e. 199 = $1.99).
759pub fn format_price(price: u64) -> f64 {
760    (price as f64) / 100.0
761}