1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use url::Url;
8
9use crate::ids::{ArticleId, CategoryId, Doi, FileId, LicenseId};
10use crate::metadata::DefinedType;
11use crate::serde_util::{
12 deserialize_boolish, deserialize_option_boolish, deserialize_option_doiish,
13 deserialize_option_u64ish, deserialize_option_urlish, deserialize_u64ish,
14};
15
16macro_rules! string_enum {
17 ($(#[$enum_meta:meta])* $name:ident { $($(#[$variant_meta:meta])* $variant:ident => $value:literal),+ $(,)? }) => {
18 $(#[$enum_meta])*
19 #[derive(Clone, Debug, PartialEq, Eq)]
20 #[non_exhaustive]
21 pub enum $name {
22 $($(#[$variant_meta])* $variant,)+
23 Unknown(
25 String
27 ),
28 }
29
30 impl Serialize for $name {
31 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
32 where
33 S: serde::Serializer,
34 {
35 serializer.serialize_str(match self {
36 $(Self::$variant => $value,)+
37 Self::Unknown(value) => value.as_str(),
38 })
39 }
40 }
41
42 impl<'de> Deserialize<'de> for $name {
43 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
44 where
45 D: serde::Deserializer<'de>,
46 {
47 let value = String::deserialize(deserializer)?;
48 Ok(match value.as_str() {
49 $($value => Self::$variant,)+
50 _ => Self::Unknown(value),
51 })
52 }
53 }
54 };
55}
56
57string_enum!(
58 ArticleStatus {
60 Draft => "draft",
62 Public => "public"
64 }
65);
66
67string_enum!(
68 ArticleEmbargo {
70 Article => "article",
72 File => "file"
74 }
75);
76
77string_enum!(
78 FileStatus {
80 Created => "created",
82 Available => "available"
84 }
85);
86
87string_enum!(
88 UploadStatus {
90 Pending => "PENDING",
92 Completed => "COMPLETED",
94 Aborted => "ABORTED"
96 }
97);
98
99string_enum!(
100 UploadPartStatus {
102 Pending => "PENDING",
104 Complete => "COMPLETE"
106 }
107);
108
109#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ArticleCategory {
112 #[serde(
114 default,
115 deserialize_with = "deserialize_option_u64ish",
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub parent_id: Option<u64>,
119 pub id: CategoryId,
121 pub title: String,
123 #[serde(flatten, default)]
125 pub extra: BTreeMap<String, Value>,
126}
127
128#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ArticleLicense {
131 #[serde(rename = "value")]
133 pub id: LicenseId,
134 pub name: String,
136 #[serde(
138 default,
139 deserialize_with = "deserialize_option_urlish",
140 skip_serializing_if = "Option::is_none"
141 )]
142 pub url: Option<Url>,
143 #[serde(flatten, default)]
145 pub extra: BTreeMap<String, Value>,
146}
147
148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
150pub struct CustomField {
151 pub name: String,
153 pub value: Value,
155 #[serde(
157 default,
158 deserialize_with = "deserialize_option_boolish",
159 skip_serializing_if = "Option::is_none"
160 )]
161 pub is_mandatory: Option<bool>,
162 #[serde(flatten, default)]
164 pub extra: BTreeMap<String, Value>,
165}
166
167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
169pub struct ArticleAuthor {
170 #[serde(
172 default,
173 deserialize_with = "deserialize_option_u64ish",
174 skip_serializing_if = "Option::is_none"
175 )]
176 pub id: Option<u64>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub full_name: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub name: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub url_name: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub orcid_id: Option<String>,
189 #[serde(flatten, default)]
191 pub extra: BTreeMap<String, Value>,
192}
193
194impl ArticleAuthor {
195 #[must_use]
197 pub fn display_name(&self) -> Option<&str> {
198 self.full_name.as_deref().or(self.name.as_deref())
199 }
200}
201
202#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
204pub struct ArticleFile {
205 pub id: FileId,
207 pub name: String,
209 #[serde(default, deserialize_with = "deserialize_u64ish")]
211 pub size: u64,
212 #[serde(
214 default,
215 deserialize_with = "deserialize_option_boolish",
216 skip_serializing_if = "Option::is_none"
217 )]
218 pub is_link_only: Option<bool>,
219 #[serde(
221 default,
222 deserialize_with = "deserialize_option_urlish",
223 skip_serializing_if = "Option::is_none"
224 )]
225 pub download_url: Option<Url>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub status: Option<FileStatus>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub viewer_type: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub preview_state: Option<String>,
235 #[serde(
237 default,
238 deserialize_with = "deserialize_option_urlish",
239 skip_serializing_if = "Option::is_none"
240 )]
241 pub upload_url: Option<Url>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub upload_token: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub supplied_md5: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub computed_md5: Option<String>,
251 #[serde(flatten, default)]
253 pub extra: BTreeMap<String, Value>,
254}
255
256impl ArticleFile {
257 #[must_use]
259 pub fn upload_session_url(&self) -> Option<&Url> {
260 self.upload_url.as_ref()
261 }
262}
263
264#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ArticleVersion {
267 #[serde(default, deserialize_with = "deserialize_u64ish")]
269 pub version: u64,
270 pub url: Url,
272 #[serde(flatten, default)]
274 pub extra: BTreeMap<String, Value>,
275}
276
277#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
279pub struct Article {
280 pub id: ArticleId,
282 pub title: String,
284 #[serde(
286 default,
287 deserialize_with = "deserialize_option_doiish",
288 skip_serializing_if = "Option::is_none"
289 )]
290 pub doi: Option<Doi>,
291 #[serde(
293 default,
294 deserialize_with = "deserialize_option_u64ish",
295 skip_serializing_if = "Option::is_none"
296 )]
297 pub group_id: Option<u64>,
298 #[serde(
300 default,
301 deserialize_with = "deserialize_option_urlish",
302 skip_serializing_if = "Option::is_none"
303 )]
304 pub url: Option<Url>,
305 #[serde(
307 default,
308 deserialize_with = "deserialize_option_urlish",
309 skip_serializing_if = "Option::is_none"
310 )]
311 pub url_public_html: Option<Url>,
312 #[serde(
314 default,
315 deserialize_with = "deserialize_option_urlish",
316 skip_serializing_if = "Option::is_none"
317 )]
318 pub url_public_api: Option<Url>,
319 #[serde(
321 default,
322 deserialize_with = "deserialize_option_urlish",
323 skip_serializing_if = "Option::is_none"
324 )]
325 pub url_private_html: Option<Url>,
326 #[serde(
328 default,
329 deserialize_with = "deserialize_option_urlish",
330 skip_serializing_if = "Option::is_none"
331 )]
332 pub url_private_api: Option<Url>,
333 #[serde(
335 default,
336 deserialize_with = "deserialize_option_urlish",
337 skip_serializing_if = "Option::is_none"
338 )]
339 pub figshare_url: Option<Url>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub published_date: Option<String>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub modified_date: Option<String>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub created_date: Option<String>,
349 #[serde(
351 default,
352 deserialize_with = "deserialize_option_urlish",
353 skip_serializing_if = "Option::is_none"
354 )]
355 pub thumb: Option<Url>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub defined_type: Option<DefinedType>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub resource_title: Option<String>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub resource_doi: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub citation: Option<String>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub confidential_reason: Option<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub embargo_type: Option<ArticleEmbargo>,
374 #[serde(
376 default,
377 deserialize_with = "deserialize_option_boolish",
378 skip_serializing_if = "Option::is_none"
379 )]
380 pub is_confidential: Option<bool>,
381 #[serde(
383 default,
384 deserialize_with = "deserialize_option_u64ish",
385 skip_serializing_if = "Option::is_none"
386 )]
387 pub size: Option<u64>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub funding: Option<String>,
391 #[serde(default)]
393 pub tags: Vec<String>,
394 #[serde(
396 default,
397 deserialize_with = "deserialize_option_u64ish",
398 skip_serializing_if = "Option::is_none"
399 )]
400 pub version: Option<u64>,
401 #[serde(
403 default,
404 deserialize_with = "deserialize_option_boolish",
405 skip_serializing_if = "Option::is_none"
406 )]
407 pub is_active: Option<bool>,
408 #[serde(
410 default,
411 deserialize_with = "deserialize_option_boolish",
412 skip_serializing_if = "Option::is_none"
413 )]
414 pub is_metadata_record: Option<bool>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub metadata_reason: Option<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub status: Option<ArticleStatus>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub description: Option<String>,
424 #[serde(
426 default,
427 deserialize_with = "deserialize_option_boolish",
428 skip_serializing_if = "Option::is_none"
429 )]
430 pub is_embargoed: Option<bool>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub embargo_date: Option<String>,
434 #[serde(
436 default,
437 deserialize_with = "deserialize_option_boolish",
438 skip_serializing_if = "Option::is_none"
439 )]
440 pub is_public: Option<bool>,
441 #[serde(
443 default,
444 deserialize_with = "deserialize_option_boolish",
445 skip_serializing_if = "Option::is_none"
446 )]
447 pub has_linked_file: Option<bool>,
448 #[serde(default)]
450 pub categories: Vec<ArticleCategory>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub license: Option<ArticleLicense>,
454 #[serde(default)]
456 pub references: Vec<String>,
457 #[serde(default)]
462 pub files: Vec<ArticleFile>,
463 #[serde(default)]
465 pub authors: Vec<ArticleAuthor>,
466 #[serde(default)]
468 pub custom_fields: Vec<CustomField>,
469 #[serde(flatten, default)]
471 pub extra: BTreeMap<String, Value>,
472}
473
474impl Article {
475 #[must_use]
478 pub fn is_public_article(&self) -> bool {
479 self.is_public.unwrap_or_else(|| {
480 self.status
481 .as_ref()
482 .is_some_and(|status| matches!(status, ArticleStatus::Public))
483 || self.published_date.is_some()
484 })
485 }
486
487 #[must_use]
489 pub fn version_number(&self) -> Option<u64> {
490 self.version
491 }
492
493 #[must_use]
518 pub fn file_by_name(&self, name: &str) -> Option<&ArticleFile> {
519 self.files.iter().find(|file| file.name == name)
520 }
521
522 #[must_use]
524 pub fn file_by_id(&self, id: FileId) -> Option<&ArticleFile> {
525 self.files.iter().find(|file| file.id == id)
526 }
527}
528
529#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
531pub struct UploadSession {
532 pub token: String,
534 pub name: String,
536 #[serde(default, deserialize_with = "deserialize_u64ish")]
538 pub size: u64,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub md5: Option<String>,
542 pub status: UploadStatus,
544 #[serde(default)]
546 pub parts: Vec<UploadPart>,
547 #[serde(flatten, default)]
549 pub extra: BTreeMap<String, Value>,
550}
551
552impl UploadSession {
553 #[must_use]
555 pub fn is_completed(&self) -> bool {
556 matches!(self.status, UploadStatus::Completed)
557 }
558}
559
560#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
562pub struct UploadPart {
563 #[serde(rename = "partNo", default, deserialize_with = "deserialize_u64ish")]
565 pub part_no: u64,
566 #[serde(
568 rename = "startOffset",
569 default,
570 deserialize_with = "deserialize_u64ish"
571 )]
572 pub start_offset: u64,
573 #[serde(rename = "endOffset", default, deserialize_with = "deserialize_u64ish")]
575 pub end_offset: u64,
576 pub status: UploadPartStatus,
578 #[serde(default, deserialize_with = "deserialize_boolish")]
580 pub locked: bool,
581 #[serde(flatten, default)]
583 pub extra: BTreeMap<String, Value>,
584}
585
586impl UploadPart {
587 #[must_use]
589 pub fn len(&self) -> u64 {
590 self.end_offset - self.start_offset + 1
591 }
592
593 #[must_use]
595 pub fn is_empty(&self) -> bool {
596 self.end_offset < self.start_offset
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::{
603 Article, ArticleAuthor, ArticleFile, ArticleStatus, FileStatus, UploadPart,
604 UploadPartStatus, UploadSession, UploadStatus,
605 };
606 use crate::metadata::DefinedType;
607 use serde_json::json;
608
609 #[test]
610 fn article_preserves_unknown_fields_and_flexible_wire_types() {
611 let article: Article = serde_json::from_value(json!({
612 "id": "42",
613 "title": "Example",
614 "defined_type": 3,
615 "is_public": 1,
616 "files": [{
617 "id": 7,
618 "name": "artifact.bin",
619 "size": "12",
620 "status": "created",
621 "is_link_only": 0
622 }],
623 "mystery": "value"
624 }))
625 .unwrap();
626
627 assert_eq!(article.id.0, 42);
628 assert_eq!(article.defined_type, Some(DefinedType::Dataset));
629 assert!(article.is_public_article());
630 assert_eq!(article.files[0].size, 12);
631 assert_eq!(article.extra.get("mystery"), Some(&json!("value")));
632 }
633
634 #[test]
635 fn article_helpers_find_files_and_flags() {
636 let article: Article = serde_json::from_value(json!({
637 "id": 10,
638 "title": "Example",
639 "status": "public",
640 "version": "3",
641 "files": [{
642 "id": 8,
643 "name": "artifact.bin",
644 "size": 5
645 }]
646 }))
647 .unwrap();
648
649 assert!(article.is_public_article());
650 assert_eq!(article.version_number(), Some(3));
651 assert!(article.file_by_name("artifact.bin").is_some());
652 assert!(article.file_by_id(crate::FileId(8)).is_some());
653 }
654
655 #[test]
656 fn article_and_related_models_tolerate_empty_optional_doi_and_urls() {
657 let article: Article = serde_json::from_value(json!({
658 "id": 11,
659 "title": "Example",
660 "doi": "",
661 "url": "",
662 "url_public_html": "",
663 "url_public_api": "",
664 "url_private_html": "",
665 "url_private_api": "",
666 "figshare_url": "",
667 "thumb": "",
668 "license": {
669 "value": 1,
670 "name": "CC BY",
671 "url": ""
672 },
673 "files": [{
674 "id": 9,
675 "name": "artifact.bin",
676 "size": 3,
677 "download_url": "",
678 "upload_url": ""
679 }]
680 }))
681 .unwrap();
682
683 assert_eq!(article.doi, None);
684 assert_eq!(article.url, None);
685 assert_eq!(article.url_public_html, None);
686 assert_eq!(article.url_public_api, None);
687 assert_eq!(article.url_private_html, None);
688 assert_eq!(article.url_private_api, None);
689 assert_eq!(article.figshare_url, None);
690 assert_eq!(article.thumb, None);
691 assert_eq!(
692 article
693 .license
694 .as_ref()
695 .and_then(|license| license.url.clone()),
696 None
697 );
698 assert_eq!(article.files[0].download_url, None);
699 assert_eq!(article.files[0].upload_url, None);
700 }
701
702 #[test]
703 fn author_display_name_uses_best_available_field() {
704 let author = ArticleAuthor {
705 full_name: Some("Doe, Jane".into()),
706 ..ArticleAuthor::default()
707 };
708 assert_eq!(author.display_name(), Some("Doe, Jane"));
709 }
710
711 #[test]
712 fn upload_models_deserialize_and_expose_helpers() {
713 let session: UploadSession = serde_json::from_value(json!({
714 "token": "upload-token",
715 "name": "artifact.bin",
716 "size": 4,
717 "md5": "abcd",
718 "status": "COMPLETED",
719 "parts": [{
720 "partNo": 1,
721 "startOffset": 0,
722 "endOffset": 3,
723 "status": "COMPLETE",
724 "locked": false
725 }]
726 }))
727 .unwrap();
728
729 assert!(session.is_completed());
730 assert_eq!(session.parts[0].len(), 4);
731 }
732
733 #[test]
734 fn string_enums_preserve_unknown_values() {
735 let status: ArticleStatus = serde_json::from_value(json!("queued")).unwrap();
736 let file_status: FileStatus = serde_json::from_value(json!("processing")).unwrap();
737 let upload_status: UploadStatus = serde_json::from_value(json!("SOMETHING")).unwrap();
738 let part_status: UploadPartStatus = serde_json::from_value(json!("WAITING")).unwrap();
739
740 assert!(matches!(status, ArticleStatus::Unknown(value) if value == "queued"));
741 assert!(matches!(file_status, FileStatus::Unknown(value) if value == "processing"));
742 assert!(matches!(upload_status, UploadStatus::Unknown(value) if value == "SOMETHING"));
743 assert!(matches!(part_status, UploadPartStatus::Unknown(value) if value == "WAITING"));
744 }
745
746 #[test]
747 fn file_and_upload_parts_accept_boolish_fields() {
748 let file: ArticleFile = serde_json::from_value(json!({
749 "id": 22,
750 "name": "artifact.bin",
751 "size": 3,
752 "is_link_only": "0"
753 }))
754 .unwrap();
755 let part: UploadPart = serde_json::from_value(json!({
756 "partNo": 1,
757 "startOffset": 4,
758 "endOffset": 7,
759 "status": "PENDING",
760 "locked": "1"
761 }))
762 .unwrap();
763
764 assert_eq!(file.is_link_only, Some(false));
765 assert!(part.locked);
766 }
767}