1use 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 #[builder(setter(each = "add_original_language"))]
89 #[serde(skip_serializing_if = "Vec::is_empty")]
90 pub original_language: Vec<Language>,
91 #[builder(setter(each = "exclude_original_language"))]
93 #[serde(skip_serializing_if = "Vec::is_empty")]
94 pub excluded_original_language: Vec<Language>,
95 #[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 #[serde(skip_serializing_if = "Option::is_none")]
111 pub created_at_since: Option<MangaDexDateTime>,
112 #[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 #[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}