mangadex_api/v5/manga/
get.rs

1//! Builder for the manga list endpoint.
2//!
3//! <https://api.mangadex.org/docs/swagger.html#/Manga/get-search-manga>
4//!
5//! # Examples
6//!
7//! ```rust
8//! use mangadex_api_types::MangaStatus;
9//! use mangadex_api::v5::MangaDexClient;
10//!
11//! # async fn run() -> anyhow::Result<()> {
12//! let client = MangaDexClient::default();
13//!
14//! let manga_res = client
15//!     .manga()
16//!     .get()
17//!     .title("full metal")
18//!     .add_status(MangaStatus::Completed)
19//!     .send()
20//!     .await?;
21//!
22//! println!("manga: {:?}", manga_res);
23//! # Ok(())
24//! # }
25//! ```
26
27use derive_builder::Builder;
28use serde::Serialize;
29use uuid::Uuid;
30
31use crate::HttpClientRef;
32use mangadex_api_schema::v5::MangaListResponse;
33use mangadex_api_types::{
34    ContentRating, Demographic, Language, MangaDexDateTime, MangaSortOrder, MangaStatus,
35    ReferenceExpansionResource, TagSearchMode,
36};
37
38#[cfg_attr(
39    feature = "deserializable-endpoint",
40    derive(serde::Deserialize, getset::Getters, getset::Setters)
41)]
42#[derive(Debug, Serialize, Clone, Builder, Default)]
43#[serde(rename_all = "camelCase")]
44#[builder(
45    setter(into, strip_option),
46    default,
47    build_fn(error = "mangadex_api_types::error::BuilderError")
48)]
49#[cfg_attr(feature = "non_exhaustive", non_exhaustive)]
50pub struct ListManga {
51    #[doc(hidden)]
52    #[serde(skip)]
53    #[builder(pattern = "immutable")]
54    #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
55    pub http_client: HttpClientRef,
56
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub limit: Option<u32>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub offset: Option<u32>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub title: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    #[builder(default)]
65    pub author_or_artist: Option<Uuid>,
66    #[builder(setter(each = "add_author"))]
67    #[serde(skip_serializing_if = "Vec::is_empty")]
68    pub authors: Vec<Uuid>,
69    #[builder(setter(each = "add_artist"))]
70    #[serde(skip_serializing_if = "Vec::is_empty")]
71    pub artists: Vec<Uuid>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub year: Option<u16>,
74    #[builder(setter(each = "include_tag"))]
75    #[serde(skip_serializing_if = "Vec::is_empty")]
76    pub included_tags: Vec<Uuid>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub included_tags_mode: Option<TagSearchMode>,
79    #[builder(setter(each = "exclude_tag"))]
80    #[serde(skip_serializing_if = "Vec::is_empty")]
81    pub excluded_tags: Vec<Uuid>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub excluded_tags_mode: Option<TagSearchMode>,
84    #[builder(setter(each = "add_status"))]
85    #[serde(skip_serializing_if = "Vec::is_empty")]
86    pub status: Vec<MangaStatus>,
87    /// Languages the manga results are originally published in.
88    #[builder(setter(each = "add_original_language"))]
89    #[serde(skip_serializing_if = "Vec::is_empty")]
90    pub original_language: Vec<Language>,
91    /// A list of original languages to exclude.
92    #[builder(setter(each = "exclude_original_language"))]
93    #[serde(skip_serializing_if = "Vec::is_empty")]
94    pub excluded_original_language: Vec<Language>,
95    /// A list of languages that the manga is translated into.
96    #[builder(setter(each = "add_available_translated_language"))]
97    #[serde(skip_serializing_if = "Vec::is_empty")]
98    pub available_translated_language: Vec<Language>,
99    #[builder(setter(each = "add_publication_demographic"))]
100    #[serde(skip_serializing_if = "Vec::is_empty")]
101    pub publication_demographic: Vec<Demographic>,
102    #[builder(setter(each = "add_manga_id"))]
103    #[serde(rename = "ids")]
104    #[serde(skip_serializing_if = "Vec::is_empty")]
105    pub manga_ids: Vec<Uuid>,
106    #[builder(setter(each = "add_content_rating"))]
107    #[serde(skip_serializing_if = "Vec::is_empty")]
108    pub content_rating: Vec<ContentRating>,
109    /// DateTime string with following format: `YYYY-MM-DDTHH:MM:SS`.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub created_at_since: Option<MangaDexDateTime>,
112    /// DateTime string with following format: `YYYY-MM-DDTHH:MM:SS`.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub updated_at_since: Option<MangaDexDateTime>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub order: Option<MangaSortOrder>,
117    #[builder(setter(each = "include"))]
118    pub includes: Vec<ReferenceExpansionResource>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub has_available_chapters: Option<bool>,
121    /// Scanlation group ID.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub group: Option<Uuid>,
124}
125
126endpoint! {
127    GET "/manga",
128    #[query] ListManga,
129    #[flatten_result] MangaListResponse,
130    ListMangaBuilder
131}
132
133#[cfg(test)]
134mod tests {
135    use serde_json::json;
136    use time::OffsetDateTime;
137    use url::Url;
138    use uuid::Uuid;
139    use wiremock::matchers::{method, path};
140    use wiremock::{Mock, MockServer, ResponseTemplate};
141
142    use crate::{HttpClient, MangaDexClient};
143    use mangadex_api_types::error::Error;
144    use mangadex_api_types::{
145        ContentRating, Demographic, Language, MangaDexDateTime, MangaStatus, ResponseType,
146    };
147
148    #[tokio::test]
149    async fn list_manga_fires_a_request_to_base_url() -> anyhow::Result<()> {
150        let mock_server = MockServer::start().await;
151        let http_client = HttpClient::builder()
152            .base_url(Url::parse(&mock_server.uri())?)
153            .build()?;
154        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
155
156        let manga_id = Uuid::new_v4();
157        let manga_title = "Test Manga".to_string();
158
159        let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
160
161        let response_body = json!({
162            "result": "ok",
163            "response": "collection",
164            "data": [
165                {
166                    "id": manga_id,
167                    "type": "manga",
168                    "attributes": {
169                        "title": {
170                            "en": manga_title
171                        },
172                        "altTitles": [],
173                        "description": {},
174                        "isLocked": false,
175                        "links": null,
176                        "originalLanguage": "ja",
177                        "lastVolume": null,
178                        "lastChapter": null,
179                        "publicationDemographic": "shoujo",
180                        "status": "ongoing",
181                        "year": null,
182                        "contentRating": "safe",
183                        "chapterNumbersResetOnNewVolume": true,
184                        "availableTranslatedLanguages": ["en"],
185                        "tags": [],
186                        "state": "published",
187                        "createdAt": datetime.to_string(),
188                        "updatedAt": datetime.to_string(),
189
190                        "version": 1
191                    },
192                    "relationships": []
193                }
194            ],
195            "limit": 1,
196            "offset": 0,
197            "total": 1
198        });
199
200        Mock::given(method("GET"))
201            .and(path("/manga"))
202            .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
203            .expect(1)
204            .mount(&mock_server)
205            .await;
206
207        let res = mangadex_client.manga().get().limit(1u32).send().await?;
208
209        assert_eq!(res.response, ResponseType::Collection);
210        let manga = &res.data[0];
211        assert_eq!(manga.id, manga_id);
212        assert_eq!(
213            manga.attributes.title.get(&Language::English).unwrap(),
214            &manga_title
215        );
216        assert!(manga.attributes.alt_titles.is_empty());
217        assert!(manga.attributes.description.is_empty());
218        assert!(!manga.attributes.is_locked);
219        assert_eq!(manga.attributes.links, None);
220        assert_eq!(manga.attributes.original_language, Language::Japanese);
221        assert_eq!(manga.attributes.last_volume, None);
222        assert_eq!(manga.attributes.last_chapter, None);
223        assert_eq!(
224            manga.attributes.publication_demographic.unwrap(),
225            Demographic::Shoujo
226        );
227        assert_eq!(manga.attributes.status, MangaStatus::Ongoing);
228        assert_eq!(manga.attributes.year, None);
229        assert_eq!(
230            manga.attributes.content_rating.unwrap(),
231            ContentRating::Safe
232        );
233        assert!(manga.attributes.chapter_numbers_reset_on_new_volume);
234        assert!(manga.attributes.tags.is_empty());
235        assert_eq!(
236            manga.attributes.created_at.to_string(),
237            datetime.to_string()
238        );
239        assert_eq!(
240            manga.attributes.updated_at.as_ref().unwrap().to_string(),
241            datetime.to_string()
242        );
243        assert_eq!(manga.attributes.version, 1);
244
245        Ok(())
246    }
247
248    #[tokio::test]
249    async fn list_manga_handles_400() -> anyhow::Result<()> {
250        let mock_server = MockServer::start().await;
251        let http_client: HttpClient = HttpClient::builder()
252            .base_url(Url::parse(&mock_server.uri())?)
253            .build()?;
254        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
255
256        let error_id = Uuid::new_v4();
257
258        let response_body = json!({
259            "result": "error",
260            "errors": [{
261                "id": error_id.to_string(),
262                "status": 400,
263                "title": "Invalid limit",
264                "detail": "Limit must be between 1 and 100"
265            }]
266        });
267
268        Mock::given(method("GET"))
269            .and(path("/manga"))
270            .respond_with(ResponseTemplate::new(400).set_body_json(response_body))
271            .expect(1)
272            .mount(&mock_server)
273            .await;
274
275        let res = mangadex_client
276            .manga()
277            .get()
278            .limit(0u32)
279            .send()
280            .await
281            .expect_err("expected error");
282
283        if let Error::Api(errors) = res {
284            assert_eq!(errors.errors.len(), 1);
285
286            assert_eq!(errors.errors[0].id, error_id);
287            assert_eq!(errors.errors[0].status, 400);
288            assert_eq!(errors.errors[0].title, Some("Invalid limit".to_string()));
289            assert_eq!(
290                errors.errors[0].detail,
291                Some("Limit must be between 1 and 100".to_string())
292            );
293        }
294
295        Ok(())
296    }
297}