mangadex_api/v5/manga/id/
put.rs

1//! Builder for the chapter update endpoint.
2//!
3//! <https://api.mangadex.org/docs/swagger.html#/Manga/put-chapter-id>
4//!
5//! # Examples
6//!
7//! ```rust
8//! use std::collections::HashMap;
9//!
10//! use uuid::Uuid;
11//!
12//! use mangadex_api_types::Language;
13//! use mangadex_api::v5::MangaDexClient;
14//! // use mangadex_api_types::{Password, Username};
15//!
16//! # async fn run() -> anyhow::Result<()> {
17//! let client = MangaDexClient::default();
18//!
19//! /*
20//!     let _login_res = client
21//!         .auth()
22//!         .login()
23//!         .username(Username::parse("myusername")?)
24//!         .password(Password::parse("hunter23")?)
25//!         .build()?
26//!         .send()
27//!         .await?;
28//! */
29//!
30//! let manga_id = Uuid::new_v4();
31//! let mut manga_titles = HashMap::new();
32//! manga_titles.insert(Language::English, "Updated Manga Title".to_string());
33//! let res = client
34//!     .manga()
35//!     .id(manga_id)
36//!     .put()
37//!     .title(manga_titles)
38//!     .version(2u32)
39//!     .send()
40//!     .await?;
41//!
42//! println!("update: {:?}", res);
43//! # Ok(())
44//! # }
45//! ```
46
47use derive_builder::Builder;
48use serde::Serialize;
49use uuid::Uuid;
50
51use crate::HttpClientRef;
52use mangadex_api_schema::v5::{LocalizedString, MangaData};
53use mangadex_api_types::{ContentRating, Demographic, Language, MangaLinks, MangaStatus};
54
55/// Update a manga's information.
56///
57/// All fields that are not changing should still have the field populated with the old information
58/// so that it is not set as `null` on the server.
59#[cfg_attr(
60    feature = "deserializable-endpoint",
61    derive(serde::Deserialize, getset::Getters, getset::Setters)
62)]
63#[derive(Debug, Serialize, Clone, Builder)]
64#[serde(rename_all = "camelCase")]
65#[builder(
66    setter(into, strip_option),
67    build_fn(error = "mangadex_api_types::error::BuilderError")
68)]
69#[cfg_attr(feature = "non_exhaustive", non_exhaustive)]
70pub struct UpdateManga {
71    /// This should never be set manually as this is only for internal use.
72    #[doc(hidden)]
73    #[serde(skip)]
74    #[builder(pattern = "immutable")]
75    #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
76    pub http_client: HttpClientRef,
77
78    #[serde(skip_serializing)]
79    pub manga_id: Uuid,
80
81    #[serde(skip_serializing_if = "Option::is_none")]
82    #[builder(default)]
83    pub title: Option<LocalizedString>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    #[builder(default)]
86    pub alt_titles: Option<Vec<LocalizedString>>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    #[builder(default)]
89    pub description: Option<LocalizedString>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    #[builder(default)]
92    pub authors: Option<Vec<Uuid>>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    #[builder(default)]
95    pub artists: Option<Vec<Uuid>>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    #[builder(default)]
98    pub links: Option<MangaLinks>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    #[builder(default)]
101    pub original_language: Option<Language>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    #[builder(default)]
104    pub last_volume: Option<Option<String>>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    #[builder(default)]
107    pub last_chapter: Option<Option<String>>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    #[builder(default)]
110    pub publication_demographic: Option<Option<Demographic>>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    #[builder(default)]
113    pub status: Option<MangaStatus>,
114    /// Year the manga was released.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    #[builder(default)]
117    pub year: Option<Option<u16>>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    #[builder(default)]
120    pub content_rating: Option<ContentRating>,
121    #[builder(default)]
122    pub chapter_numbers_reset_on_new_volume: bool,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    #[builder(default)]
125    pub tags: Option<Vec<Uuid>>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    #[builder(default)]
128    pub primary_cover: Option<Option<Uuid>>,
129    /// >= 1
130    pub version: u32,
131}
132
133endpoint! {
134    PUT ("/manga/{}", manga_id),
135    #[body auth] UpdateManga,
136    #[rate_limited] MangaData,
137    UpdateMangaBuilder
138}
139
140#[cfg(test)]
141mod tests {
142    use std::collections::HashMap;
143
144    use serde_json::json;
145    use time::OffsetDateTime;
146    use url::Url;
147    use uuid::Uuid;
148    use wiremock::matchers::{body_json, header, method, path_regex};
149    use wiremock::{Mock, MockServer, ResponseTemplate};
150
151    use crate::v5::AuthTokens;
152    use crate::{HttpClient, MangaDexClient};
153    use mangadex_api_types::{
154        ContentRating, Demographic, Language, MangaDexDateTime, MangaStatus, Tag,
155    };
156
157    #[tokio::test]
158    async fn update_chapter_fires_a_request_to_base_url() -> anyhow::Result<()> {
159        let mock_server = MockServer::start().await;
160        let http_client = HttpClient::builder()
161            .base_url(Url::parse(&mock_server.uri())?)
162            .auth_tokens(AuthTokens {
163                session: "sessiontoken".to_string(),
164                refresh: "refreshtoken".to_string(),
165            })
166            .build()?;
167        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
168
169        let manga_id = Uuid::new_v4();
170        let mut title = HashMap::new();
171        title.insert(Language::English, "New Manga Title".to_string());
172        let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
173
174        let expected_body = json!({
175            "title": {
176                "en": "New Manga Title"
177            },
178            "chapterNumbersResetOnNewVolume": false,
179            "version": 2
180        });
181        let response_body = json!({
182            "result": "ok",
183            "response": "entity",
184            "data": {
185                "id": manga_id,
186                "type": "chapter",
187                "attributes": {
188                    "title": {
189                        "en": "New Manga Title"
190                    },
191                    "altTitles": [],
192                    "description": {},
193                    "isLocked": false,
194                    "links": {},
195                    "originalLanguage": "ja",
196                    "lastVolume": "1",
197                    "lastChapter": "1",
198                    "publicationDemographic": "shoujo",
199                    "status": "completed",
200                    "year": 2021,
201                    "contentRating": "safe",
202                    "chapterNumbersResetOnNewVolume": true,
203                    "availableTranslatedLanguages": ["en"],
204                    "tags": [],
205                    "state": "published",
206                    "version": 2,
207                    "createdAt": datetime.to_string(),
208                    "updatedAt": datetime.to_string(),
209                },
210                "relationships": []
211            }
212        });
213
214        Mock::given(method("PUT"))
215            .and(path_regex(r"/manga/[0-9a-fA-F-]+"))
216            .and(header("Authorization", "Bearer sessiontoken"))
217            .and(header("Content-Type", "application/json"))
218            .and(body_json(expected_body))
219            .respond_with(
220                ResponseTemplate::new(200)
221                    .insert_header("x-ratelimit-retry-after", "1698723860")
222                    .insert_header("x-ratelimit-limit", "40")
223                    .insert_header("x-ratelimit-remaining", "39")
224                    .set_body_json(response_body),
225            )
226            .expect(1)
227            .mount(&mock_server)
228            .await;
229
230        let _ = mangadex_client
231            .manga()
232            .id(manga_id)
233            .put()
234            .title(title)
235            .version(2_u32)
236            .send()
237            .await?;
238
239        Ok(())
240    }
241
242    #[tokio::test]
243    async fn update_manga_does_not_include_last_volume_when_not_used() -> anyhow::Result<()> {
244        let mock_server = MockServer::start().await;
245        let http_client: HttpClient = HttpClient::builder()
246            .base_url(Url::parse(&mock_server.uri())?)
247            .auth_tokens(AuthTokens {
248                session: "sessiontoken".to_string(),
249                refresh: "refreshtoken".to_string(),
250            })
251            .build()?;
252        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
253
254        let manga_id = Uuid::new_v4();
255        let tag_id: Uuid = Tag::Action.into();
256        let manga_title = "Test Manga".to_string();
257
258        let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
259
260        let expected_body = json!({
261            "originalLanguage": "ja",
262            "publicationDemographic": "shounen",
263            "status": "ongoing",
264            "contentRating": "safe",
265            "chapterNumbersResetOnNewVolume": false,
266            "tags": [tag_id],
267            "version": 1
268        });
269        let response_body = json!({
270            "result": "ok",
271            "response": "entity",
272            "data": {
273                "id": manga_id,
274                "type": "manga",
275                "attributes": {
276                    "title": {
277                        "en": manga_title
278                    },
279                    "altTitles": [],
280                    "description": {},
281                    "isLocked": false,
282                    "links": null,
283                    "originalLanguage": "ja",
284                    "lastVolume": null,
285                    "lastChapter": null,
286                    "publicationDemographic": "shounen",
287                    "status": "ongoing",
288                    "year": null,
289                    "contentRating": "safe",
290                    "chapterNumbersResetOnNewVolume": true,
291                    "availableTranslatedLanguages": ["en"],
292                    "tags": [
293                        {
294                            "id": tag_id,
295                            "type": "tag",
296                            "attributes": {
297                                "name": {
298                                    "en": "Action"
299                                },
300                                "description": [],
301                                "group": "genre",
302                                "version": 1
303                            },
304                            "relationships": []
305                        }
306                    ],
307                    "state": "draft",
308                    "createdAt": datetime.to_string(),
309                    "updatedAt": datetime.to_string(),
310
311                    "version": 1
312                },
313                "relationships": []
314            }
315        });
316
317        Mock::given(method("PUT"))
318            .and(path_regex(r"/manga/[0-9a-fA-F-]+"))
319            .and(header("Authorization", "Bearer sessiontoken"))
320            .and(header("Content-Type", "application/json"))
321            .and(body_json(expected_body))
322            .respond_with(
323                ResponseTemplate::new(200)
324                    .insert_header("x-ratelimit-retry-after", "1698723860")
325                    .insert_header("x-ratelimit-limit", "40")
326                    .insert_header("x-ratelimit-remaining", "39")
327                    .set_body_json(response_body),
328            )
329            .expect(1)
330            .mount(&mock_server)
331            .await;
332
333        let res = mangadex_client
334            .manga()
335            .id(manga_id)
336            .put()
337            .original_language(Language::Japanese)
338            .status(MangaStatus::Ongoing)
339            .content_rating(ContentRating::Safe)
340            .publication_demographic(Demographic::Shounen)
341            .tags(vec![Tag::Action.into()])
342            .version(1_u32)
343            .send()
344            .await?;
345
346        assert_eq!(res.data.attributes.last_volume, None);
347
348        Ok(())
349    }
350
351    #[tokio::test]
352    async fn update_manga_sends_null_last_volume() -> anyhow::Result<()> {
353        let mock_server = MockServer::start().await;
354        let http_client: HttpClient = HttpClient::builder()
355            .base_url(Url::parse(&mock_server.uri())?)
356            .auth_tokens(AuthTokens {
357                session: "sessiontoken".to_string(),
358                refresh: "refreshtoken".to_string(),
359            })
360            .build()?;
361        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
362
363        let manga_id = Uuid::new_v4();
364        let tag_id: Uuid = Tag::Action.into();
365        let manga_title = "Test Manga".to_string();
366
367        let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
368
369        let expected_body = json!({
370            "originalLanguage": "ja",
371            "lastVolume": null,
372            "publicationDemographic": "shounen",
373            "status": "ongoing",
374            "contentRating": "safe",
375            "chapterNumbersResetOnNewVolume": false,
376            "tags": [tag_id],
377            "version": 1
378        });
379        let response_body = json!({
380            "result": "ok",
381            "response": "entity",
382            "data": {
383                "id": manga_id,
384                "type": "manga",
385                "attributes": {
386                    "title": {
387                        "en": manga_title
388                    },
389                    "altTitles": [],
390                    "description": {},
391                    "isLocked": false,
392                    "links": null,
393                    "originalLanguage": "ja",
394                    "lastVolume": null,
395                    "lastChapter": null,
396                    "publicationDemographic": "shounen",
397                    "status": "ongoing",
398                    "year": null,
399                    "contentRating": "safe",
400                    "chapterNumbersResetOnNewVolume": true,
401                    "availableTranslatedLanguages": ["en"],
402                    "tags": [
403                        {
404                            "id": tag_id,
405                            "type": "tag",
406                            "attributes": {
407                                "name": {
408                                    "en": "Action"
409                                },
410                                "description": [],
411                                "group": "genre",
412                                "version": 1
413                            },
414                            "relationships": []
415                        }
416                    ],
417                    "state": "draft",
418                    "createdAt": datetime.to_string(),
419                    "updatedAt": datetime.to_string(),
420
421                    "version": 1
422                },
423                "relationships": []
424            }
425        });
426
427        Mock::given(method("PUT"))
428            .and(path_regex(r"/manga/[0-9a-fA-F-]+"))
429            .and(header("Authorization", "Bearer sessiontoken"))
430            .and(header("Content-Type", "application/json"))
431            .and(body_json(expected_body))
432            .respond_with(
433                ResponseTemplate::new(200)
434                    .insert_header("x-ratelimit-retry-after", "1698723860")
435                    .insert_header("x-ratelimit-limit", "40")
436                    .insert_header("x-ratelimit-remaining", "39")
437                    .set_body_json(response_body),
438            )
439            .expect(1)
440            .mount(&mock_server)
441            .await;
442
443        let res = mangadex_client
444            .manga()
445            .id(manga_id)
446            .put()
447            .original_language(Language::Japanese)
448            .last_volume(None)
449            .status(MangaStatus::Ongoing)
450            .content_rating(ContentRating::Safe)
451            .tags(vec![Tag::Action.into()])
452            .publication_demographic(Demographic::Shounen)
453            .version(1_u32)
454            .send()
455            .await?;
456
457        assert_eq!(res.data.attributes.last_volume, None);
458
459        Ok(())
460    }
461
462    #[tokio::test]
463    async fn update_manga_sends_last_volume_with_value() -> anyhow::Result<()> {
464        let mock_server = MockServer::start().await;
465        let http_client: HttpClient = HttpClient::builder()
466            .base_url(Url::parse(&mock_server.uri())?)
467            .auth_tokens(AuthTokens {
468                session: "sessiontoken".to_string(),
469                refresh: "refreshtoken".to_string(),
470            })
471            .build()?;
472        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
473
474        let manga_id = Uuid::new_v4();
475        let tag_id: Uuid = Tag::Action.into();
476        let manga_title = "Test Manga".to_string();
477
478        let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
479
480        let expected_body = json!({
481            "originalLanguage": "ja",
482            "lastVolume": "1",
483            "publicationDemographic": "shounen",
484            "status": "ongoing",
485            "contentRating": "safe",
486            "chapterNumbersResetOnNewVolume": false,
487            "tags": [tag_id],
488            "version": 1
489        });
490        let response_body = json!({
491            "result": "ok",
492            "response": "entity",
493            "data": {
494                "id": manga_id,
495                "type": "manga",
496                "attributes": {
497                    "title": {
498                        "en": manga_title
499                    },
500                    "altTitles": [],
501                    "description": {},
502                    "isLocked": false,
503                    "links": null,
504                    "originalLanguage": "ja",
505                    "lastVolume": "1",
506                    "lastChapter": null,
507                    "publicationDemographic": "shounen",
508                    "status": "ongoing",
509                    "year": null,
510                    "contentRating": "safe",
511                    "chapterNumbersResetOnNewVolume": true,
512                    "availableTranslatedLanguages": ["en"],
513                    "tags": [
514                        {
515                            "id": tag_id,
516                            "type": "tag",
517                            "attributes": {
518                                "name": {
519                                    "en": "Action"
520                                },
521                                "description": [],
522                                "group": "genre",
523                                "version": 1
524                            },
525                            "relationships": []
526                        }
527                    ],
528                    "state": "draft",
529                    "createdAt": datetime.to_string(),
530                    "updatedAt": datetime.to_string(),
531
532                    "version": 1
533                },
534                "relationships": []
535            }
536        });
537
538        Mock::given(method("PUT"))
539            .and(path_regex(r"/manga/[0-9a-fA-F-]+"))
540            .and(header("Authorization", "Bearer sessiontoken"))
541            .and(header("Content-Type", "application/json"))
542            .and(body_json(expected_body))
543            .respond_with(
544                ResponseTemplate::new(200)
545                    .insert_header("x-ratelimit-retry-after", "1698723860")
546                    .insert_header("x-ratelimit-limit", "40")
547                    .insert_header("x-ratelimit-remaining", "39")
548                    .set_body_json(response_body),
549            )
550            .expect(1)
551            .mount(&mock_server)
552            .await;
553
554        let res = mangadex_client
555            .manga()
556            .id(manga_id)
557            .put()
558            .original_language(Language::Japanese)
559            .last_volume(Some("1".to_string()))
560            .status(MangaStatus::Ongoing)
561            .content_rating(ContentRating::Safe)
562            .publication_demographic(Demographic::Shounen)
563            .tags(vec![Tag::Action.into()])
564            .version(1_u32)
565            .send()
566            .await?;
567
568        assert_eq!(res.data.attributes.last_volume, Some("1".to_string()));
569
570        Ok(())
571    }
572}