1use derive_builder::Builder;
26use serde::Serialize;
27use uuid::Uuid;
28
29use crate::HttpClientRef;
30use mangadex_api_schema::v5::ChapterListResponse;
31use mangadex_api_types::{
32 ChapterSortOrder, ContentRating, IncludeExternalUrl, IncludeFuturePages,
33 IncludeFuturePublishAt, IncludeFutureUpdates, 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 = "mangadex_api_types::error::BuilderError")
47)]
48#[cfg_attr(feature = "non_exhaustive", 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}
138
139endpoint! {
140 GET "/chapter",
141 #[query] ListChapter,
142 #[flatten_result] ChapterListResponse,
143 ListChapterBuilder
144}
145
146#[cfg(test)]
147mod tests {
148 use fake::faker::name::en::Name;
149 use fake::Fake;
150 use serde_json::json;
151 use time::OffsetDateTime;
152 use url::Url;
153 use uuid::Uuid;
154 use wiremock::matchers::{method, path};
155 use wiremock::{Mock, MockServer, ResponseTemplate};
156
157 use crate::{HttpClient, MangaDexClient};
158 use mangadex_api_types::error::Error;
159 use mangadex_api_types::{Language, MangaDexDateTime, ResponseType};
160
161 #[tokio::test]
162 async fn list_chapter_fires_a_request_to_base_url() -> anyhow::Result<()> {
163 let mock_server = MockServer::start().await;
164 let http_client = HttpClient::builder()
165 .base_url(Url::parse(&mock_server.uri())?)
166 .build()?;
167 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
168
169 let chapter_id = Uuid::new_v4();
170 let uploader_id = Uuid::new_v4();
171 let chapter_title: String = Name().fake();
172
173 let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
174
175 let response_body = json!({
176 "result": "ok",
177 "response": "collection",
178 "data": [
179 {
180 "id": chapter_id,
181 "type": "chapter",
182 "attributes": {
183 "title": chapter_title,
184 "volume": "1",
185 "chapter": "1.5",
186 "pages": 4,
187 "translatedLanguage": "en",
188 "uploader": uploader_id,
189 "version": 1,
190 "createdAt": datetime.to_string(),
191 "updatedAt": datetime.to_string(),
192 "publishAt": datetime.to_string(),
193 "readableAt": datetime.to_string(),
194 },
195 "relationships": []
196 }
197 ],
198 "limit": 1,
199 "offset": 0,
200 "total": 1
201 });
202
203 Mock::given(method("GET"))
204 .and(path("/chapter"))
205 .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
206 .expect(1)
207 .mount(&mock_server)
208 .await;
209
210 let res = mangadex_client.chapter().get().limit(1u32).send().await?;
211
212 assert_eq!(res.response, ResponseType::Collection);
213 let chapter = &res.data[0];
214 assert_eq!(chapter.id, chapter_id);
215 assert_eq!(chapter.attributes.title, Some(chapter_title));
216 assert_eq!(chapter.attributes.volume, Some("1".to_string()));
217 assert_eq!(chapter.attributes.chapter, Some("1.5".to_string()));
218 assert_eq!(chapter.attributes.pages, 4);
219 assert_eq!(chapter.attributes.translated_language, Language::English);
220 assert_eq!(chapter.attributes.version, 1);
221 assert_eq!(
222 chapter.attributes.created_at.to_string(),
223 datetime.to_string()
224 );
225 assert_eq!(
226 chapter.attributes.updated_at.as_ref().unwrap().to_string(),
227 datetime.to_string()
228 );
229 assert_eq!(
230 chapter.attributes.publish_at.unwrap().to_string(),
231 datetime.to_string()
232 );
233
234 Ok(())
235 }
236
237 #[tokio::test]
238 async fn list_chapter_handles_400() -> anyhow::Result<()> {
239 let mock_server = MockServer::start().await;
240 let http_client: HttpClient = HttpClient::builder()
241 .base_url(Url::parse(&mock_server.uri())?)
242 .build()?;
243 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
244
245 let error_id = Uuid::new_v4();
246
247 let response_body = json!({
248 "result": "error",
249 "errors": [{
250 "id": error_id.to_string(),
251 "status": 400,
252 "title": "Invalid limit",
253 "detail": "Limit must be between 1 and 100"
254 }]
255 });
256
257 Mock::given(method("GET"))
258 .and(path("/chapter"))
259 .respond_with(ResponseTemplate::new(400).set_body_json(response_body))
260 .expect(1)
261 .mount(&mock_server)
262 .await;
263
264 let res = mangadex_client
265 .chapter()
266 .get()
267 .limit(0u32)
268 .send()
269 .await
270 .expect_err("expected error");
271
272 if let Error::Api(errors) = res {
273 assert_eq!(errors.errors.len(), 1);
274
275 assert_eq!(errors.errors[0].id, error_id);
276 assert_eq!(errors.errors[0].status, 400);
277 assert_eq!(errors.errors[0].title, Some("Invalid limit".to_string()));
278 assert_eq!(
279 errors.errors[0].detail,
280 Some("Limit must be between 1 and 100".to_string())
281 );
282 }
283
284 Ok(())
285 }
286}