mangadex_api/v5/manga/
post.rs

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