1use derive_builder::Builder;
26use mangadex_api_schema::v5::ChapterCollection;
27use serde::Serialize;
28use uuid::Uuid;
29
30use crate::HttpClientRef;
31use mangadex_api_types::{
32 ChapterSortOrder, ContentRating, IncludeExternalUrl, IncludeFuturePages,
33 IncludeFuturePublishAt, IncludeFutureUpdates, IncludeUnvailable, Language, MangaDexDateTime,
34 ReferenceExpansionResource,
35};
36
37#[cfg_attr(
38 feature = "deserializable-endpoint",
39 derive(serde::Deserialize, getset::Getters, getset::Setters)
40)]
41#[derive(Debug, Serialize, Clone, Builder, Default)]
42#[serde(rename_all = "camelCase")]
43#[builder(
44 setter(into, strip_option),
45 default,
46 build_fn(error = "crate::error::BuilderError")
47)]
48#[non_exhaustive]
49pub struct ListChapter {
50 #[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(rename = "ids")]
62 #[builder(setter(each = "add_chapter_id"))]
63 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub chapter_ids: Vec<Uuid>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub title: Option<String>,
67 #[builder(setter(each = "add_group"))]
68 #[serde(skip_serializing_if = "Vec::is_empty")]
69 pub groups: Vec<Uuid>,
70 #[serde(rename = "uploader")]
71 #[builder(setter(each = "uploader"))]
72 #[serde(skip_serializing_if = "Vec::is_empty")]
73 pub uploaders: Vec<Uuid>,
74 #[serde(rename = "manga")]
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub manga_id: Option<Uuid>,
77 #[serde(rename = "volume")]
78 #[builder(setter(each = "add_volume"))]
79 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub volumes: Vec<String>,
81 #[builder(setter(each = "add_chapter"))]
83 #[serde(rename = "chapter")]
84 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub chapters: Vec<String>,
86 #[serde(rename = "translatedLanguage")]
87 #[builder(setter(each = "add_translated_language"))]
88 #[serde(skip_serializing_if = "Vec::is_empty")]
89 pub translated_languages: Vec<Language>,
90 #[serde(rename = "originalLanguage")]
91 #[builder(setter(each = "add_original_language"))]
92 #[serde(skip_serializing_if = "Vec::is_empty")]
93 pub original_languages: Vec<Language>,
94 #[serde(rename = "excludedOriginalLanguage")]
95 #[builder(setter(each = "exclude_original_language"))]
96 #[serde(skip_serializing_if = "Vec::is_empty")]
97 pub excluded_original_languages: Vec<Language>,
98 #[builder(setter(each = "add_content_rating"))]
99 #[serde(skip_serializing_if = "Vec::is_empty")]
100 pub content_rating: Vec<ContentRating>,
101 #[builder(setter(each = "excluded_group"))]
103 #[serde(skip_serializing_if = "Vec::is_empty")]
104 pub excluded_groups: Vec<Uuid>,
105 #[builder(setter(each = "excluded_uploader"))]
107 #[serde(skip_serializing_if = "Vec::is_empty")]
108 pub excluded_uploaders: Vec<Uuid>,
109 #[serde(skip_serializing_if = "Option::is_none")]
113 pub include_future_updates: Option<IncludeFutureUpdates>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub created_at_since: Option<MangaDexDateTime>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub updated_at_since: Option<MangaDexDateTime>,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub publish_at_since: Option<MangaDexDateTime>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub include_empty_pages: Option<IncludeFuturePages>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub include_external_url: Option<IncludeExternalUrl>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub include_future_publish_at: Option<IncludeFuturePublishAt>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub order: Option<ChapterSortOrder>,
134 #[builder(setter(each = "include"))]
135 #[serde(skip_serializing_if = "Vec::is_empty")]
136 pub includes: Vec<ReferenceExpansionResource>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub include_unavailable: Option<IncludeUnvailable>,
139}
140
141endpoint! {
142 GET "/chapter",
143 #[query] ListChapter,
144 #[flatten_result] crate::Result<ChapterCollection>,
145 ListChapterBuilder
146}
147
148#[cfg(test)]
149mod tests {
150 use fake::faker::name::en::Name;
151 use fake::Fake;
152 use serde_json::json;
153 use time::OffsetDateTime;
154 use url::Url;
155 use uuid::Uuid;
156 use wiremock::matchers::{method, path};
157 use wiremock::{Mock, MockServer, ResponseTemplate};
158
159 use crate::error::Error;
160 use crate::{HttpClient, MangaDexClient};
161 use mangadex_api_types::{Language, MangaDexDateTime, ResponseType};
162
163 #[tokio::test]
164 async fn list_chapter_fires_a_request_to_base_url() -> anyhow::Result<()> {
165 let mock_server = MockServer::start().await;
166 let http_client = HttpClient::builder()
167 .base_url(Url::parse(&mock_server.uri())?)
168 .build()?;
169 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
170
171 let chapter_id = Uuid::new_v4();
172 let uploader_id = Uuid::new_v4();
173 let chapter_title: String = Name().fake();
174
175 let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
176
177 let response_body = json!({
178 "result": "ok",
179 "response": "collection",
180 "data": [
181 {
182 "id": chapter_id,
183 "type": "chapter",
184 "attributes": {
185 "title": chapter_title,
186 "volume": "1",
187 "chapter": "1.5",
188 "pages": 4,
189 "translatedLanguage": "en",
190 "uploader": uploader_id,
191 "version": 1,
192 "createdAt": datetime.to_string(),
193 "updatedAt": datetime.to_string(),
194 "publishAt": datetime.to_string(),
195 "readableAt": datetime.to_string(),
196 },
197 "relationships": []
198 }
199 ],
200 "limit": 1,
201 "offset": 0,
202 "total": 1
203 });
204
205 Mock::given(method("GET"))
206 .and(path("/chapter"))
207 .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
208 .expect(1)
209 .mount(&mock_server)
210 .await;
211
212 let res = mangadex_client.chapter().get().limit(1u32).send().await?;
213
214 assert_eq!(res.response, ResponseType::Collection);
215 let chapter = &res.data[0];
216 assert_eq!(chapter.id, chapter_id);
217 assert_eq!(chapter.attributes.title, Some(chapter_title));
218 assert_eq!(chapter.attributes.volume, Some("1".to_string()));
219 assert_eq!(chapter.attributes.chapter, Some("1.5".to_string()));
220 assert_eq!(chapter.attributes.pages, 4);
221 assert_eq!(chapter.attributes.translated_language, Language::English);
222 assert_eq!(chapter.attributes.version, 1);
223 assert_eq!(
224 chapter.attributes.created_at.to_string(),
225 datetime.to_string()
226 );
227 assert_eq!(
228 chapter.attributes.updated_at.as_ref().unwrap().to_string(),
229 datetime.to_string()
230 );
231 assert_eq!(
232 chapter.attributes.publish_at.unwrap().to_string(),
233 datetime.to_string()
234 );
235
236 Ok(())
237 }
238
239 #[tokio::test]
240 async fn list_chapter_handles_400() -> anyhow::Result<()> {
241 let mock_server = MockServer::start().await;
242 let http_client: HttpClient = HttpClient::builder()
243 .base_url(Url::parse(&mock_server.uri())?)
244 .build()?;
245 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
246
247 let error_id = Uuid::new_v4();
248
249 let response_body = json!({
250 "result": "error",
251 "errors": [{
252 "id": error_id.to_string(),
253 "status": 400,
254 "title": "Invalid limit",
255 "detail": "Limit must be between 1 and 100"
256 }]
257 });
258
259 Mock::given(method("GET"))
260 .and(path("/chapter"))
261 .respond_with(ResponseTemplate::new(400).set_body_json(response_body))
262 .expect(1)
263 .mount(&mock_server)
264 .await;
265
266 let res = mangadex_client
267 .chapter()
268 .get()
269 .limit(0u32)
270 .send()
271 .await
272 .expect_err("expected error");
273
274 if let Error::Api(errors) = res {
275 assert_eq!(errors.errors.len(), 1);
276
277 assert_eq!(errors.errors[0].id, error_id);
278 assert_eq!(errors.errors[0].status, 400);
279 assert_eq!(errors.errors[0].title, Some("Invalid limit".to_string()));
280 assert_eq!(
281 errors.errors[0].detail,
282 Some("Limit must be between 1 and 100".to_string())
283 );
284 }
285
286 Ok(())
287 }
288}