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 crate::{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")]
63#[non_exhaustive]
64pub struct CommitUploadSession {
65    /// This should never be set manually as this is only for internal use.
66    #[serde(skip)]
67    #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
68    pub http_client: HttpClientRef,
69
70    #[serde(skip_serializing)]
71    pub session_id: Uuid,
72
73    #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
74    chapter_draft: ChapterDraft,
75    /// Ordered list of Upload Session File IDs.
76    ///
77    /// Any uploaded files that are not included in this list will be deleted.
78    pub page_order: Vec<Uuid>,
79    pub terms_accepted: bool,
80}
81
82#[cfg_attr(feature = "deserializable-endpoint", derive(serde::Deserialize))]
83#[derive(Debug, Serialize, Clone)]
84#[serde(rename_all = "camelCase")]
85#[non_exhaustive]
86pub struct ChapterDraft {
87    /// Nullable
88    pub volume: Option<String>,
89    /// Nullable
90    pub chapter: Option<String>,
91    /// Nullable
92    pub title: Option<String>,
93    pub translated_language: Language,
94    /// Must be a URL with "http(s)://".
95    ///
96    /// Nullable
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub external_url: Option<Url>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub publish_at: Option<MangaDexDateTime>,
101}
102
103#[cfg_attr(feature = "deserializable-endpoint", derive(serde::Deserialize))]
104/// Custom request builder to handle nested struct.
105#[derive(Debug, Serialize, Clone, Default)]
106#[non_exhaustive]
107pub struct CommitUploadSessionBuilder {
108    #[serde(skip)]
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    pub terms_accepted: bool,
128}
129
130impl CommitUploadSessionBuilder {
131    pub fn new(http_client: HttpClientRef) -> Self {
132        Self {
133            http_client: Some(http_client),
134            ..Default::default()
135        }
136    }
137
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    pub fn terms_accepted(mut self, accepted: bool) -> Self {
210        self.terms_accepted = accepted;
211        self
212    }
213
214    /// Validate the field values. Use this before building.
215    fn validate(&self) -> std::result::Result<(), String> {
216        if self.session_id.is_none() {
217            return Err("session_id cannot be None".to_string());
218        }
219
220        if self.translated_language.is_none() {
221            return Err("translated_language cannot be None".to_string());
222        }
223
224        Ok(())
225    }
226
227    /// Finalize the changes to the request struct and return the new struct.
228    // TODO support limit size
229    pub fn build(&self) -> Result<CommitUploadSession> {
230        if let Err(error) = self.validate() {
231            return Err(Error::RequestBuilderError(error));
232        }
233
234        let session_id = self
235            .session_id
236            .ok_or(Error::RequestBuilderError(String::from(
237                "session_id must be provided",
238            )))?;
239        let translated_language =
240            self.translated_language
241                .ok_or(Error::RequestBuilderError(String::from(
242                    "translated_language must be provided",
243                )))?;
244
245        let chapter_draft = ChapterDraft {
246            volume: self.volume.to_owned(),
247            chapter: self.chapter.to_owned(),
248            title: self.title.to_owned(),
249            translated_language,
250            external_url: self.external_url.to_owned(),
251            publish_at: self.publish_at,
252        };
253        Ok(CommitUploadSession {
254            http_client: self
255                .http_client
256                .to_owned()
257                .ok_or(Error::RequestBuilderError(String::from(
258                    "http_client must be provided",
259                )))?,
260
261            session_id,
262            chapter_draft,
263            page_order: self.page_order.to_owned(),
264            terms_accepted: self.terms_accepted,
265        })
266    }
267}
268
269endpoint! {
270    POST ("/upload/{}/commit", session_id),
271    #[body auth] CommitUploadSession,
272    #[rate_limited] ChapterData,
273    CommitUploadSessionBuilder
274}
275
276#[cfg(test)]
277mod tests {
278    use fake::faker::name::en::Name;
279    use fake::Fake;
280    use serde_json::json;
281    use time::OffsetDateTime;
282    use url::Url;
283    use uuid::Uuid;
284    use wiremock::matchers::{body_json, header, method, path_regex};
285    use wiremock::{Mock, MockServer, ResponseTemplate};
286
287    use crate::v5::upload::upload_session_id::commit::post::ChapterDraft;
288    use crate::v5::AuthTokens;
289    use crate::{HttpClient, MangaDexClient};
290    use mangadex_api_types::{Language, MangaDexDateTime, RelationshipType};
291
292    use serde::Serialize;
293
294    #[derive(Clone, Serialize, Debug)]
295    #[serde(rename_all = "camelCase")]
296    struct ExceptedBody {
297        chapter_draft: ChapterDraft,
298        /// Ordered list of Upload Session File IDs.
299        ///
300        /// Any uploaded files that are not included in this list will be deleted.
301        page_order: Vec<Uuid>,
302        terms_accepted: bool,
303    }
304
305    #[tokio::test]
306    async fn commit_upload_session_fires_a_request_to_base_url() -> anyhow::Result<()> {
307        let mock_server = MockServer::start().await;
308        let http_client = HttpClient::builder()
309            .base_url(Url::parse(&mock_server.uri())?)
310            .auth_tokens(non_exhaustive::non_exhaustive!(AuthTokens {
311                session: "sessiontoken".to_string(),
312                refresh: "refreshtoken".to_string(),
313            }))
314            .build()?;
315        let mangadex_client = MangaDexClient::new_with_http_client(http_client);
316
317        let session_id = Uuid::new_v4();
318        let session_file_id = Uuid::new_v4();
319        let chapter_id = Uuid::new_v4();
320        let uploader_id = Uuid::new_v4();
321        let chapter_title: String = Name().fake();
322
323        let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
324
325        let expected_body = ExceptedBody {
326            chapter_draft: ChapterDraft {
327                volume: Some(String::from("1")),
328                chapter: Some(String::from("2.5")),
329                title: Some(chapter_title.clone()),
330                translated_language: Language::English,
331                external_url: None,
332                publish_at: None,
333            },
334            page_order: vec![session_file_id],
335            terms_accepted: true,
336        };
337
338        let response_body = json!({
339            "result": "ok",
340            "response": "entity",
341            "data": {
342                "id": chapter_id,
343                "type": "chapter",
344                "attributes": {
345                    "title": chapter_title,
346                    "volume": "1",
347                    "chapter": "2.5",
348                    "pages": 4,
349                    "translatedLanguage": "en",
350                    "uploader": uploader_id,
351                    "version": 1,
352                    "createdAt": datetime.to_string(),
353                    "updatedAt": datetime.to_string(),
354                    "publishAt": datetime.to_string(),
355                    "readableAt": datetime.to_string(),
356                },
357                "relationships": [],
358            }
359
360        });
361        Mock::given(method("POST"))
362            .and(path_regex(r"/upload/[0-9a-fA-F-]+/commit"))
363            .and(header("authorization", "Bearer sessiontoken"))
364            .and(header("content-type", "application/json"))
365            .and(body_json(expected_body))
366            .respond_with(
367                ResponseTemplate::new(200)
368                    .insert_header("x-ratelimit-retry-after", "1698723860")
369                    .insert_header("x-ratelimit-limit", "40")
370                    .insert_header("x-ratelimit-remaining", "39")
371                    .set_body_json(response_body),
372            )
373            .expect(1)
374            .mount(&mock_server)
375            .await;
376
377        let res = mangadex_client
378            .upload()
379            .upload_session_id(session_id)
380            .commit()
381            .post()
382            .volume(Some("1".to_string()))
383            .chapter(Some("2.5".to_string()))
384            .title(Some(chapter_title.clone()))
385            .translated_language(Language::English)
386            .page_order(vec![session_file_id])
387            .terms_accepted(true)
388            .send()
389            .await?;
390
391        let res = &res.data;
392
393        assert_eq!(res.id, chapter_id);
394        assert_eq!(res.type_, RelationshipType::Chapter);
395        assert_eq!(res.attributes.title, Some(chapter_title.clone()));
396        assert_eq!(res.attributes.volume, Some("1".to_string()));
397        assert_eq!(res.attributes.chapter, Some("2.5".to_string()));
398        assert_eq!(res.attributes.pages, 4);
399        assert_eq!(res.attributes.translated_language, Language::English);
400        assert_eq!(res.attributes.external_url, None);
401        assert_eq!(res.attributes.version, 1);
402        assert_eq!(res.attributes.created_at.to_string(), datetime.to_string());
403        assert_eq!(
404            res.attributes.updated_at.as_ref().unwrap().to_string(),
405            datetime.to_string()
406        );
407        assert_eq!(
408            res.attributes.publish_at.unwrap().to_string(),
409            datetime.to_string()
410        );
411        assert_eq!(
412            res.attributes.readable_at.unwrap().to_string(),
413            datetime.to_string()
414        );
415
416        Ok(())
417    }
418}