Skip to main content

figshare_rs/
model.rs

1//! Core data models for articles, files, licenses, uploads, and related payloads.
2
3use 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            /// A server value unknown to this crate version.
24            Unknown(
25                /// Raw server value.
26                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    /// Private/public article status.
59    ArticleStatus {
60        /// Draft article.
61        Draft => "draft",
62        /// Published article.
63        Public => "public"
64    }
65);
66
67string_enum!(
68    /// Article-level or file-level embargo mode.
69    ArticleEmbargo {
70        /// Whole-article embargo.
71        Article => "article",
72        /// File-level embargo.
73        File => "file"
74    }
75);
76
77string_enum!(
78    /// File status as reported by Figshare.
79    FileStatus {
80        /// Newly created file entry.
81        Created => "created",
82        /// Available file.
83        Available => "available"
84    }
85);
86
87string_enum!(
88    /// Upload session status from the upload service.
89    UploadStatus {
90        /// Waiting for parts to be uploaded.
91        Pending => "PENDING",
92        /// Upload assembly completed.
93        Completed => "COMPLETED",
94        /// Upload aborted.
95        Aborted => "ABORTED"
96    }
97);
98
99string_enum!(
100    /// Upload part status from the upload service.
101    UploadPartStatus {
102        /// Waiting for part bytes.
103        Pending => "PENDING",
104        /// Part bytes uploaded successfully.
105        Complete => "COMPLETE"
106    }
107);
108
109/// Category representation returned by Figshare.
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ArticleCategory {
112    /// Parent category ID, when present.
113    #[serde(
114        default,
115        deserialize_with = "deserialize_option_u64ish",
116        skip_serializing_if = "Option::is_none"
117    )]
118    pub parent_id: Option<u64>,
119    /// Category identifier.
120    pub id: CategoryId,
121    /// Category title.
122    pub title: String,
123    /// Additional untyped fields preserved for forward compatibility.
124    #[serde(flatten, default)]
125    pub extra: BTreeMap<String, Value>,
126}
127
128/// License representation returned by Figshare.
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ArticleLicense {
131    /// License identifier.
132    #[serde(rename = "value")]
133    pub id: LicenseId,
134    /// License short name.
135    pub name: String,
136    /// License documentation URL, when present.
137    #[serde(
138        default,
139        deserialize_with = "deserialize_option_urlish",
140        skip_serializing_if = "Option::is_none"
141    )]
142    pub url: Option<Url>,
143    /// Additional untyped fields preserved for forward compatibility.
144    #[serde(flatten, default)]
145    pub extra: BTreeMap<String, Value>,
146}
147
148/// Custom field entry returned on detailed article payloads.
149#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
150pub struct CustomField {
151    /// Custom field name.
152    pub name: String,
153    /// Custom field value.
154    pub value: Value,
155    /// Whether the field is mandatory, when present.
156    #[serde(
157        default,
158        deserialize_with = "deserialize_option_boolish",
159        skip_serializing_if = "Option::is_none"
160    )]
161    pub is_mandatory: Option<bool>,
162    /// Additional untyped fields preserved for forward compatibility.
163    #[serde(flatten, default)]
164    pub extra: BTreeMap<String, Value>,
165}
166
167/// Author representation returned on detailed article payloads.
168#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
169pub struct ArticleAuthor {
170    /// Author identifier, when present.
171    #[serde(
172        default,
173        deserialize_with = "deserialize_option_u64ish",
174        skip_serializing_if = "Option::is_none"
175    )]
176    pub id: Option<u64>,
177    /// Full display name, when present.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub full_name: Option<String>,
180    /// Alternate display name field, when present.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub name: Option<String>,
183    /// Figshare URL slug, when present.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub url_name: Option<String>,
186    /// ORCID identifier, when present.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub orcid_id: Option<String>,
189    /// Additional untyped fields preserved for forward compatibility.
190    #[serde(flatten, default)]
191    pub extra: BTreeMap<String, Value>,
192}
193
194impl ArticleAuthor {
195    /// Returns the best display name available for the author.
196    #[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/// Public/private file representation attached to an article.
203#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
204pub struct ArticleFile {
205    /// File identifier.
206    pub id: FileId,
207    /// File name.
208    pub name: String,
209    /// File size in bytes.
210    #[serde(default, deserialize_with = "deserialize_u64ish")]
211    pub size: u64,
212    /// Whether the file is only linked and not stored on Figshare.
213    #[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    /// Public or private file download URL, when present.
220    #[serde(
221        default,
222        deserialize_with = "deserialize_option_urlish",
223        skip_serializing_if = "Option::is_none"
224    )]
225    pub download_url: Option<Url>,
226    /// Private file status, when present.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub status: Option<FileStatus>,
229    /// Viewer type hint, when present.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub viewer_type: Option<String>,
232    /// Preview state hint, when present.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub preview_state: Option<String>,
235    /// Upload session URL, when present.
236    #[serde(
237        default,
238        deserialize_with = "deserialize_option_urlish",
239        skip_serializing_if = "Option::is_none"
240    )]
241    pub upload_url: Option<Url>,
242    /// Upload token, when present.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub upload_token: Option<String>,
245    /// Client-provided MD5 used when initiating the upload, when present.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub supplied_md5: Option<String>,
248    /// Server-computed MD5, when present.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub computed_md5: Option<String>,
251    /// Additional untyped fields preserved for forward compatibility.
252    #[serde(flatten, default)]
253    pub extra: BTreeMap<String, Value>,
254}
255
256impl ArticleFile {
257    /// Returns the upload session URL, when the file is hosted on Figshare.
258    #[must_use]
259    pub fn upload_session_url(&self) -> Option<&Url> {
260        self.upload_url.as_ref()
261    }
262}
263
264/// One public article version pointer.
265#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ArticleVersion {
267    /// Version number.
268    #[serde(default, deserialize_with = "deserialize_u64ish")]
269    pub version: u64,
270    /// API URL for the version resource.
271    pub url: Url,
272    /// Additional untyped fields preserved for forward compatibility.
273    #[serde(flatten, default)]
274    pub extra: BTreeMap<String, Value>,
275}
276
277/// Article payload shared across public and own article endpoints.
278#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
279pub struct Article {
280    /// Article identifier.
281    pub id: ArticleId,
282    /// Article title.
283    pub title: String,
284    /// Version-specific DOI, when present.
285    #[serde(
286        default,
287        deserialize_with = "deserialize_option_doiish",
288        skip_serializing_if = "Option::is_none"
289    )]
290    pub doi: Option<Doi>,
291    /// Group identifier, when present.
292    #[serde(
293        default,
294        deserialize_with = "deserialize_option_u64ish",
295        skip_serializing_if = "Option::is_none"
296    )]
297    pub group_id: Option<u64>,
298    /// Figshare-provided article URL, when present.
299    #[serde(
300        default,
301        deserialize_with = "deserialize_option_urlish",
302        skip_serializing_if = "Option::is_none"
303    )]
304    pub url: Option<Url>,
305    /// Public HTML URL, when present.
306    #[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    /// Public API URL, when present.
313    #[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    /// Private HTML URL, when present.
320    #[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    /// Private API URL, when present.
327    #[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    /// Public Figshare landing page, when present.
334    #[serde(
335        default,
336        deserialize_with = "deserialize_option_urlish",
337        skip_serializing_if = "Option::is_none"
338    )]
339    pub figshare_url: Option<Url>,
340    /// Publication timestamp as returned by Figshare, when present.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub published_date: Option<String>,
343    /// Last modification timestamp as returned by Figshare, when present.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub modified_date: Option<String>,
346    /// Creation timestamp as returned by Figshare, when present.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub created_date: Option<String>,
349    /// Preview thumbnail URL, when present.
350    #[serde(
351        default,
352        deserialize_with = "deserialize_option_urlish",
353        skip_serializing_if = "Option::is_none"
354    )]
355    pub thumb: Option<Url>,
356    /// Typed article kind, when present.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub defined_type: Option<DefinedType>,
359    /// Related resource title, when present.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub resource_title: Option<String>,
362    /// Related resource DOI, when present.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub resource_doi: Option<String>,
365    /// Citation string, when present.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub citation: Option<String>,
368    /// Confidentiality reason, when present.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub confidential_reason: Option<String>,
371    /// Embargo mode, when present.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub embargo_type: Option<ArticleEmbargo>,
374    /// Whether the article is confidential, when present.
375    #[serde(
376        default,
377        deserialize_with = "deserialize_option_boolish",
378        skip_serializing_if = "Option::is_none"
379    )]
380    pub is_confidential: Option<bool>,
381    /// Total article size in bytes, when present.
382    #[serde(
383        default,
384        deserialize_with = "deserialize_option_u64ish",
385        skip_serializing_if = "Option::is_none"
386    )]
387    pub size: Option<u64>,
388    /// Funding string, when present.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub funding: Option<String>,
391    /// Tags, when present.
392    #[serde(default)]
393    pub tags: Vec<String>,
394    /// Version number, when present.
395    #[serde(
396        default,
397        deserialize_with = "deserialize_option_u64ish",
398        skip_serializing_if = "Option::is_none"
399    )]
400    pub version: Option<u64>,
401    /// Whether the article is active, when present.
402    #[serde(
403        default,
404        deserialize_with = "deserialize_option_boolish",
405        skip_serializing_if = "Option::is_none"
406    )]
407    pub is_active: Option<bool>,
408    /// Whether the article is only a metadata record, when present.
409    #[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    /// Metadata reason, when present.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub metadata_reason: Option<String>,
418    /// Article status, when present.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub status: Option<ArticleStatus>,
421    /// Description, when present.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub description: Option<String>,
424    /// Whether the article is embargoed, when present.
425    #[serde(
426        default,
427        deserialize_with = "deserialize_option_boolish",
428        skip_serializing_if = "Option::is_none"
429    )]
430    pub is_embargoed: Option<bool>,
431    /// Embargo end timestamp, when present.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub embargo_date: Option<String>,
434    /// Whether the article is public, when present.
435    #[serde(
436        default,
437        deserialize_with = "deserialize_option_boolish",
438        skip_serializing_if = "Option::is_none"
439    )]
440    pub is_public: Option<bool>,
441    /// Whether the article contains a linked file, when present.
442    #[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    /// Attached categories.
449    #[serde(default)]
450    pub categories: Vec<ArticleCategory>,
451    /// Attached license, when present.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub license: Option<ArticleLicense>,
454    /// Attached references.
455    #[serde(default)]
456    pub references: Vec<String>,
457    /// Attached files embedded in the article payload, when present.
458    ///
459    /// Figshare caps embedded article file lists, so use dedicated file-list
460    /// endpoints or the download helpers when complete enumeration matters.
461    #[serde(default)]
462    pub files: Vec<ArticleFile>,
463    /// Attached authors, when present.
464    #[serde(default)]
465    pub authors: Vec<ArticleAuthor>,
466    /// Attached custom fields, when present.
467    #[serde(default)]
468    pub custom_fields: Vec<CustomField>,
469    /// Additional untyped fields preserved for forward compatibility.
470    #[serde(flatten, default)]
471    pub extra: BTreeMap<String, Value>,
472}
473
474impl Article {
475    /// Returns `true` when the article is public according to the available
476    /// flags in the payload.
477    #[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    /// Returns the best version number visible in the payload.
488    #[must_use]
489    pub fn version_number(&self) -> Option<u64> {
490        self.version
491    }
492
493    /// Finds a file by exact file name within the embedded article payload.
494    ///
495    /// Figshare may return only a partial embedded file list, so prefer the
496    /// dedicated file-list endpoints when completeness matters.
497    ///
498    /// # Examples
499    ///
500    /// ```
501    /// use figshare_rs::{Article, FileId};
502    ///
503    /// let article: Article = serde_json::from_value(serde_json::json!({
504    ///     "id": 1,
505    ///     "title": "Example",
506    ///     "files": [{
507    ///         "id": 7,
508    ///         "name": "artifact.bin",
509    ///         "size": 12
510    ///     }]
511    /// }))?;
512    ///
513    /// let file = article.file_by_name("artifact.bin").expect("embedded file");
514    /// assert_eq!(file.id, FileId(7));
515    /// # Ok::<(), Box<dyn std::error::Error>>(())
516    /// ```
517    #[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    /// Finds a file by ID.
523    #[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/// Upload session information returned by the upload service.
530#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
531pub struct UploadSession {
532    /// Upload token.
533    pub token: String,
534    /// Target file name.
535    pub name: String,
536    /// Total file size in bytes.
537    #[serde(default, deserialize_with = "deserialize_u64ish")]
538    pub size: u64,
539    /// Expected MD5.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub md5: Option<String>,
542    /// Upload status.
543    pub status: UploadStatus,
544    /// Upload parts.
545    #[serde(default)]
546    pub parts: Vec<UploadPart>,
547    /// Additional untyped fields preserved for forward compatibility.
548    #[serde(flatten, default)]
549    pub extra: BTreeMap<String, Value>,
550}
551
552impl UploadSession {
553    /// Returns `true` when the upload completed successfully.
554    #[must_use]
555    pub fn is_completed(&self) -> bool {
556        matches!(self.status, UploadStatus::Completed)
557    }
558}
559
560/// One upload part.
561#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
562pub struct UploadPart {
563    /// Part number.
564    #[serde(rename = "partNo", default, deserialize_with = "deserialize_u64ish")]
565    pub part_no: u64,
566    /// Inclusive start offset.
567    #[serde(
568        rename = "startOffset",
569        default,
570        deserialize_with = "deserialize_u64ish"
571    )]
572    pub start_offset: u64,
573    /// Inclusive end offset.
574    #[serde(rename = "endOffset", default, deserialize_with = "deserialize_u64ish")]
575    pub end_offset: u64,
576    /// Part status.
577    pub status: UploadPartStatus,
578    /// Whether the part is locked.
579    #[serde(default, deserialize_with = "deserialize_boolish")]
580    pub locked: bool,
581    /// Additional untyped fields preserved for forward compatibility.
582    #[serde(flatten, default)]
583    pub extra: BTreeMap<String, Value>,
584}
585
586impl UploadPart {
587    /// Returns the exact byte length of the part.
588    #[must_use]
589    pub fn len(&self) -> u64 {
590        self.end_offset - self.start_offset + 1
591    }
592
593    /// Returns whether the part describes an empty byte range.
594    #[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}