mangadex_api/v5/upload/upload_session_id/commit/
post.rs

1//! Builder for committing an active upload session.
2//!
3//! <https://api.mangadex.org/docs/swagger.html#/Upload/commit-upload-session>
4//!
5//! # Examples
6//!
7//! ```rust
8//! use uuid::Uuid;
9//!
10//! use mangadex_api_types::Language;
11//! use mangadex_api::v5::MangaDexClient;
12//! // use mangadex_api_types::{Password, Username};
13//!
14//! # async fn run() -> anyhow::Result<()> {
15//! let client = MangaDexClient::default();
16//!
17//! /*
18//!
19//!     let _login_res = client
20//!         .auth()
21//!         .login()
22//!         .post()
23//!         .username(Username::parse("myusername")?)
24//!         .password(Password::parse("hunter23")?)
25//!         .send()
26//!         .await?;
27//!
28//!  */
29//!
30//! let session_id = Uuid::new_v4();
31//! let res = client
32//!     .upload()
33//!     .upload_session_id(session_id)
34//!     .commit()
35//!     .post()
36//!     .volume(Some("1".to_string()))
37//!     .chapter(Some("1".to_string()))
38//!     .title(Some("Chapter Title".to_string()))
39//!     .translated_language(Language::English)
40//!     .send()
41//!     .await?;
42//!
43//! println!("commit upload session: {:?}", res);
44//! # Ok(())
45//! # }
46//! ```
47
48use mangadex_api_schema::v5::ChapterData;
49use serde::Serialize;
50use url::Url;
51use uuid::Uuid;
52
53use crate::HttpClientRef;
54use mangadex_api_types::error::{Error, Result};
55use mangadex_api_types::{Language, MangaDexDateTime};
56
57#[cfg_attr(
58    feature = "deserializable-endpoint",
59    derive(serde::Deserialize, getset::Getters, getset::Setters)
60)]
61#[derive(Debug, Serialize, Clone)]
62#[serde(rename_all = "camelCase")]
63pub struct CommitUploadSession {
64    /// This should never be set manually as this is only for internal use.
65    #[serde(skip)]
66    #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
67    pub http_client: HttpClientRef,
68
69    #[serde(skip_serializing)]
70    pub session_id: Uuid,
71
72    #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
73    chapter_draft: ChapterDraft,
74    /// Ordered list of Upload Session File IDs.
75    ///
76    /// Any uploaded files that are not included in this list will be deleted.
77    pub page_order: Vec<Uuid>,
78}
79
80#[cfg_attr(feature = "deserializable-endpoint", derive(serde::Deserialize))]
81#[derive(Debug, Serialize, Clone)]
82#[serde(rename_all = "camelCase")]
83pub struct ChapterDraft {
84    /// Nullable
85    pub volume: Option<String>,
86    /// Nullable
87    pub chapter: Option<String>,
88    /// Nullable
89    pub title: Option<String>,
90    pub translated_language: Language,
91    /// Must be a URL with "http(s)://".
92    ///
93    /// Nullable
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub external_url: Option<Url>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub publish_at: Option<MangaDexDateTime>,
98}
99
100#[cfg_attr(
101    feature = "deserializable-endpoint",
102    derive(serde::Deserialize, getset::Getters, getset::Setters)
103)]
104/// Custom request builder to handle nested struct.
105#[derive(Debug, Serialize, Clone, Default)]
106pub struct CommitUploadSessionBuilder {
107    #[serde(skip)]
108    #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
109    pub http_client: Option<HttpClientRef>,
110
111    pub session_id: Option<Uuid>,
112    /// Ordered list of Upload Session File IDs.
113    pub page_order: Vec<Uuid>,
114
115    /// Nullable
116    pub volume: Option<String>,
117    /// Nullable
118    pub chapter: Option<String>,
119    /// Nullable
120    pub title: Option<String>,
121    pub translated_language: Option<Language>,
122    /// Must be a URL with "http(s)://".
123    ///
124    /// Nullable
125    pub external_url: Option<Url>,
126    pub publish_at: Option<MangaDexDateTime>,
127}
128
129impl CommitUploadSessionBuilder {
130    pub fn new(http_client: HttpClientRef) -> Self {
131        Self {
132            http_client: Some(http_client),
133            ..Default::default()
134        }
135    }
136
137    #[doc(hidden)]
138    pub fn http_client(mut self, http_client: HttpClientRef) -> Self {
139        self.http_client = Some(http_client);
140        self
141    }
142
143    /// Specify the upload session ID to commit.
144    pub fn session_id(mut self, session_id: Uuid) -> Self {
145        self.session_id = Some(session_id);
146        self
147    }
148
149    /// Specify the Upload Session File IDs to commit, ordered.
150    pub fn page_order(mut self, page_order: Vec<Uuid>) -> Self {
151        self.page_order = page_order;
152        self
153    }
154
155    /// Add an Upload Session File ID to commit, adds to the end of the `pageOrder` list.
156    pub fn add_page(mut self, page: Uuid) -> Self {
157        self.page_order.push(page);
158        self
159    }
160
161    /// Specify the volume the chapter belongs to.
162    ///
163    /// Nullable
164    pub fn volume(mut self, volume: Option<String>) -> Self {
165        self.volume = volume;
166        self
167    }
168
169    /// Specify the chapter number the session is for.
170    ///
171    /// Nullable
172    pub fn chapter(mut self, chapter: Option<String>) -> Self {
173        self.chapter = chapter;
174        self
175    }
176
177    /// Specify the title for the chapter.
178    ///
179    /// Nullable
180    pub fn title(mut self, title: Option<String>) -> Self {
181        self.title = title;
182        self
183    }
184
185    /// Specify the chapter number the session is for.
186    ///
187    /// Nullable
188    pub fn translated_language(mut self, translated_language: Language) -> Self {
189        self.translated_language = Some(translated_language);
190        self
191    }
192
193    /// Specify the URL where the chapter can be found.
194    ///
195    /// Nullable
196    ///
197    /// This should not be used if chapter has images uploaded to MangaDex.
198    pub fn external_url(mut self, external_url: Option<Url>) -> Self {
199        self.external_url = external_url;
200        self
201    }
202
203    /// Specify the date and time the chapter was originally published at.
204    pub fn publish_at<DT: Into<MangaDexDateTime>>(mut self, publish_at: DT) -> Self {
205        self.publish_at = Some(publish_at.into());
206        self
207    }
208
209    /// Validate the field values. Use this before building.
210    fn validate(&self) -> std::result::Result<(), String> {
211        if self.session_id.is_none() {
212            return Err("session_id cannot be None".to_string());
213        }
214
215        if self.translated_language.is_none() {
216            return Err("translated_language cannot be None".to_string());
217        }
218
219        Ok(())
220    }
221
222    /// Finalize the changes to the request struct and return the new struct.
223    pub fn build(&self) -> Result<CommitUploadSession> {
224        if let Err(error) = self.validate() {
225            return Err(Error::RequestBuilderError(error));
226        }
227
228        let session_id = self
229            .session_id
230            .ok_or(Error::RequestBuilderError(String::from(
231                "session_id must be provided",
232            )))?;
233        let translated_language =
234            self.translated_language
235                .ok_or(Error::RequestBuilderError(String::from(
236                    "translated_language must be provided",
237                )))?;
238
239        Ok(CommitUploadSession {
240            http_client: self
241                .http_client
242                .to_owned()
243                .ok_or(Error::RequestBuilderError(String::from(
244                    "http_client must be provided",
245                )))?,
246
247            session_id,
248            chapter_draft: ChapterDraft {
249                volume: self.volume.to_owned(),
250                chapter: self.chapter.to_owned(),
251                title: self.title.to_owned(),
252                translated_language,
253                external_url: self.external_url.to_owned(),
254                publish_at: self.publish_at,
255            },
256            page_order: self.page_order.to_owned(),
257        })
258    }
259}
260
261endpoint! {
262    POST ("/upload/{}/commit", session_id),
263    #[body auth] CommitUploadSession,
264    #[rate_limited] ChapterData,
265    CommitUploadSessionBuilder
266}
267
268#[cfg(test)]
269mod tests {
270    use fake::faker::name::en::Name;
271    use fake::Fake;
272    use serde_json::json;
273    use time::OffsetDateTime;
274    use url::Url;
275    use uuid::Uuid;
276    use wiremock::matchers::{body_json, header, method, path_regex};
277    use wiremock::{Mock, MockServer, ResponseTemplate};
278
279    use crate::v5::upload::upload_session_id::commit::post::ChapterDraft;
280    use crate::v5::AuthTokens;
281    use crate::{HttpClient, MangaDexClient};
282    use mangadex_api_types::{Language, MangaDexDateTime, RelationshipType};
283
284    use serde::Serialize;
285
286    #[derive(Clone, Serialize, Debug)]
287    #[serde(rename_all = "camelCase")]
288    struct ExceptedBody {
289        chapter_draft: ChapterDraft,
290        /// Ordered list of Upload Session File IDs.
291        ///
292        /// Any uploaded files that are not included in this list will be deleted.
293        page_order: Vec<Uuid>,
294    }
295
296    #[tokio::test]
297    async fn commit_upload_session_fires_a_request_to_base_url() -> anyhow::Result<()> {
298        let mock_server = MockServer::start().await;
299        let http_client = HttpClient::builder()
300            .base_url(Url::parse(&mock_server.uri())?)
301            .auth_tokens(AuthTokens {
302                session: "sessiontoken".to_string(),
303                refresh: "refreshtoken".to_string(),
304            })
305            .build()?;
306        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
307
308        let session_id = Uuid::new_v4();
309        let session_file_id = Uuid::new_v4();
310        let chapter_id = Uuid::new_v4();
311        let uploader_id = Uuid::new_v4();
312        let chapter_title: String = Name().fake();
313
314        let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
315
316        let expected_body = ExceptedBody {
317            chapter_draft: ChapterDraft {
318                volume: Some(String::from("1")),
319                chapter: Some(String::from("2.5")),
320                title: Some(chapter_title.clone()),
321                translated_language: Language::English,
322                external_url: None,
323                publish_at: None,
324            },
325            page_order: vec![session_file_id],
326        };
327
328        let response_body = json!({
329            "result": "ok",
330            "response": "entity",
331            "data": {
332                "id": chapter_id,
333                "type": "chapter",
334                "attributes": {
335                    "title": chapter_title,
336                    "volume": "1",
337                    "chapter": "2.5",
338                    "pages": 4,
339                    "translatedLanguage": "en",
340                    "uploader": uploader_id,
341                    "version": 1,
342                    "createdAt": datetime.to_string(),
343                    "updatedAt": datetime.to_string(),
344                    "publishAt": datetime.to_string(),
345                    "readableAt": datetime.to_string(),
346                },
347                "relationships": [],
348            }
349
350        });
351        Mock::given(method("POST"))
352            .and(path_regex(r"/upload/[0-9a-fA-F-]+/commit"))
353            .and(header("authorization", "Bearer sessiontoken"))
354            .and(header("content-type", "application/json"))
355            .and(body_json(expected_body))
356            .respond_with(
357                ResponseTemplate::new(200)
358                    .insert_header("x-ratelimit-retry-after", "1698723860")
359                    .insert_header("x-ratelimit-limit", "40")
360                    .insert_header("x-ratelimit-remaining", "39")
361                    .set_body_json(response_body),
362            )
363            .expect(1)
364            .mount(&mock_server)
365            .await;
366
367        let res = mangadex_client
368            .upload()
369            .upload_session_id(session_id)
370            .commit()
371            .post()
372            .volume(Some("1".to_string()))
373            .chapter(Some("2.5".to_string()))
374            .title(Some(chapter_title.clone()))
375            .translated_language(Language::English)
376            .page_order(vec![session_file_id])
377            .send()
378            .await?;
379
380        let res = &res.data;
381
382        assert_eq!(res.id, chapter_id);
383        assert_eq!(res.type_, RelationshipType::Chapter);
384        assert_eq!(res.attributes.title, Some(chapter_title.clone()));
385        assert_eq!(res.attributes.volume, Some("1".to_string()));
386        assert_eq!(res.attributes.chapter, Some("2.5".to_string()));
387        assert_eq!(res.attributes.pages, 4);
388        assert_eq!(res.attributes.translated_language, Language::English);
389        assert_eq!(res.attributes.external_url, None);
390        assert_eq!(res.attributes.version, 1);
391        assert_eq!(res.attributes.created_at.to_string(), datetime.to_string());
392        assert_eq!(
393            res.attributes.updated_at.as_ref().unwrap().to_string(),
394            datetime.to_string()
395        );
396        assert_eq!(
397            res.attributes.publish_at.unwrap().to_string(),
398            datetime.to_string()
399        );
400        assert_eq!(
401            res.attributes.readable_at.unwrap().to_string(),
402            datetime.to_string()
403        );
404
405        Ok(())
406    }
407}