use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
use crate::ids::{ArticleId, CategoryId, Doi, FileId, LicenseId};
use crate::metadata::DefinedType;
use crate::serde_util::{
deserialize_boolish, deserialize_option_boolish, deserialize_option_doiish,
deserialize_option_u64ish, deserialize_option_urlish, deserialize_u64ish,
};
macro_rules! string_enum {
($(#[$enum_meta:meta])* $name:ident { $($(#[$variant_meta:meta])* $variant:ident => $value:literal),+ $(,)? }) => {
$(#[$enum_meta])*
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum $name {
$($(#[$variant_meta])* $variant,)+
Unknown(
String
),
}
impl Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(match self {
$(Self::$variant => $value,)+
Self::Unknown(value) => value.as_str(),
})
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(match value.as_str() {
$($value => Self::$variant,)+
_ => Self::Unknown(value),
})
}
}
};
}
string_enum!(
ArticleStatus {
Draft => "draft",
Public => "public"
}
);
string_enum!(
ArticleEmbargo {
Article => "article",
File => "file"
}
);
string_enum!(
FileStatus {
Created => "created",
Available => "available"
}
);
string_enum!(
UploadStatus {
Pending => "PENDING",
Completed => "COMPLETED",
Aborted => "ABORTED"
}
);
string_enum!(
UploadPartStatus {
Pending => "PENDING",
Complete => "COMPLETE"
}
);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArticleCategory {
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub parent_id: Option<u64>,
pub id: CategoryId,
pub title: String,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArticleLicense {
#[serde(rename = "value")]
pub id: LicenseId,
pub name: String,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub url: Option<Url>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct CustomField {
pub name: String,
pub value: Value,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub is_mandatory: Option<bool>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct ArticleAuthor {
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub id: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub orcid_id: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl ArticleAuthor {
#[must_use]
pub fn display_name(&self) -> Option<&str> {
self.full_name.as_deref().or(self.name.as_deref())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ArticleFile {
pub id: FileId,
pub name: String,
#[serde(default, deserialize_with = "deserialize_u64ish")]
pub size: u64,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub is_link_only: Option<bool>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub download_url: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<FileStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub viewer_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_state: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub upload_url: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upload_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supplied_md5: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub computed_md5: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl ArticleFile {
#[must_use]
pub fn upload_session_url(&self) -> Option<&Url> {
self.upload_url.as_ref()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArticleVersion {
#[serde(default, deserialize_with = "deserialize_u64ish")]
pub version: u64,
pub url: Url,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Article {
pub id: ArticleId,
pub title: String,
#[serde(
default,
deserialize_with = "deserialize_option_doiish",
skip_serializing_if = "Option::is_none"
)]
pub doi: Option<Doi>,
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub group_id: Option<u64>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub url: Option<Url>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub url_public_html: Option<Url>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub url_public_api: Option<Url>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub url_private_html: Option<Url>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub url_private_api: Option<Url>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub figshare_url: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_date: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_option_urlish",
skip_serializing_if = "Option::is_none"
)]
pub thumb: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defined_type: Option<DefinedType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_doi: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub citation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidential_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embargo_type: Option<ArticleEmbargo>,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub is_confidential: Option<bool>,
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub funding: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub version: Option<u64>,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub is_active: Option<bool>,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub is_metadata_record: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<ArticleStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub is_embargoed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embargo_date: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub is_public: Option<bool>,
#[serde(
default,
deserialize_with = "deserialize_option_boolish",
skip_serializing_if = "Option::is_none"
)]
pub has_linked_file: Option<bool>,
#[serde(default)]
pub categories: Vec<ArticleCategory>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<ArticleLicense>,
#[serde(default)]
pub references: Vec<String>,
#[serde(default)]
pub files: Vec<ArticleFile>,
#[serde(default)]
pub authors: Vec<ArticleAuthor>,
#[serde(default)]
pub custom_fields: Vec<CustomField>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl Article {
#[must_use]
pub fn is_public_article(&self) -> bool {
self.is_public.unwrap_or_else(|| {
self.status
.as_ref()
.is_some_and(|status| matches!(status, ArticleStatus::Public))
|| self.published_date.is_some()
})
}
#[must_use]
pub fn version_number(&self) -> Option<u64> {
self.version
}
#[must_use]
pub fn file_by_name(&self, name: &str) -> Option<&ArticleFile> {
self.files.iter().find(|file| file.name == name)
}
#[must_use]
pub fn file_by_id(&self, id: FileId) -> Option<&ArticleFile> {
self.files.iter().find(|file| file.id == id)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UploadSession {
pub token: String,
pub name: String,
#[serde(default, deserialize_with = "deserialize_u64ish")]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub md5: Option<String>,
pub status: UploadStatus,
#[serde(default)]
pub parts: Vec<UploadPart>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl UploadSession {
#[must_use]
pub fn is_completed(&self) -> bool {
matches!(self.status, UploadStatus::Completed)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UploadPart {
#[serde(rename = "partNo", default, deserialize_with = "deserialize_u64ish")]
pub part_no: u64,
#[serde(
rename = "startOffset",
default,
deserialize_with = "deserialize_u64ish"
)]
pub start_offset: u64,
#[serde(rename = "endOffset", default, deserialize_with = "deserialize_u64ish")]
pub end_offset: u64,
pub status: UploadPartStatus,
#[serde(default, deserialize_with = "deserialize_boolish")]
pub locked: bool,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl UploadPart {
#[must_use]
pub fn len(&self) -> u64 {
self.end_offset - self.start_offset + 1
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.end_offset < self.start_offset
}
}
#[cfg(test)]
mod tests {
use super::{
Article, ArticleAuthor, ArticleFile, ArticleStatus, FileStatus, UploadPart,
UploadPartStatus, UploadSession, UploadStatus,
};
use crate::metadata::DefinedType;
use serde_json::json;
#[test]
fn article_preserves_unknown_fields_and_flexible_wire_types() {
let article: Article = serde_json::from_value(json!({
"id": "42",
"title": "Example",
"defined_type": 3,
"is_public": 1,
"files": [{
"id": 7,
"name": "artifact.bin",
"size": "12",
"status": "created",
"is_link_only": 0
}],
"mystery": "value"
}))
.unwrap();
assert_eq!(article.id.0, 42);
assert_eq!(article.defined_type, Some(DefinedType::Dataset));
assert!(article.is_public_article());
assert_eq!(article.files[0].size, 12);
assert_eq!(article.extra.get("mystery"), Some(&json!("value")));
}
#[test]
fn article_helpers_find_files_and_flags() {
let article: Article = serde_json::from_value(json!({
"id": 10,
"title": "Example",
"status": "public",
"version": "3",
"files": [{
"id": 8,
"name": "artifact.bin",
"size": 5
}]
}))
.unwrap();
assert!(article.is_public_article());
assert_eq!(article.version_number(), Some(3));
assert!(article.file_by_name("artifact.bin").is_some());
assert!(article.file_by_id(crate::FileId(8)).is_some());
}
#[test]
fn article_and_related_models_tolerate_empty_optional_doi_and_urls() {
let article: Article = serde_json::from_value(json!({
"id": 11,
"title": "Example",
"doi": "",
"url": "",
"url_public_html": "",
"url_public_api": "",
"url_private_html": "",
"url_private_api": "",
"figshare_url": "",
"thumb": "",
"license": {
"value": 1,
"name": "CC BY",
"url": ""
},
"files": [{
"id": 9,
"name": "artifact.bin",
"size": 3,
"download_url": "",
"upload_url": ""
}]
}))
.unwrap();
assert_eq!(article.doi, None);
assert_eq!(article.url, None);
assert_eq!(article.url_public_html, None);
assert_eq!(article.url_public_api, None);
assert_eq!(article.url_private_html, None);
assert_eq!(article.url_private_api, None);
assert_eq!(article.figshare_url, None);
assert_eq!(article.thumb, None);
assert_eq!(
article
.license
.as_ref()
.and_then(|license| license.url.clone()),
None
);
assert_eq!(article.files[0].download_url, None);
assert_eq!(article.files[0].upload_url, None);
}
#[test]
fn author_display_name_uses_best_available_field() {
let author = ArticleAuthor {
full_name: Some("Doe, Jane".into()),
..ArticleAuthor::default()
};
assert_eq!(author.display_name(), Some("Doe, Jane"));
}
#[test]
fn upload_models_deserialize_and_expose_helpers() {
let session: UploadSession = serde_json::from_value(json!({
"token": "upload-token",
"name": "artifact.bin",
"size": 4,
"md5": "abcd",
"status": "COMPLETED",
"parts": [{
"partNo": 1,
"startOffset": 0,
"endOffset": 3,
"status": "COMPLETE",
"locked": false
}]
}))
.unwrap();
assert!(session.is_completed());
assert_eq!(session.parts[0].len(), 4);
}
#[test]
fn string_enums_preserve_unknown_values() {
let status: ArticleStatus = serde_json::from_value(json!("queued")).unwrap();
let file_status: FileStatus = serde_json::from_value(json!("processing")).unwrap();
let upload_status: UploadStatus = serde_json::from_value(json!("SOMETHING")).unwrap();
let part_status: UploadPartStatus = serde_json::from_value(json!("WAITING")).unwrap();
assert!(matches!(status, ArticleStatus::Unknown(value) if value == "queued"));
assert!(matches!(file_status, FileStatus::Unknown(value) if value == "processing"));
assert!(matches!(upload_status, UploadStatus::Unknown(value) if value == "SOMETHING"));
assert!(matches!(part_status, UploadPartStatus::Unknown(value) if value == "WAITING"));
}
#[test]
fn file_and_upload_parts_accept_boolish_fields() {
let file: ArticleFile = serde_json::from_value(json!({
"id": 22,
"name": "artifact.bin",
"size": 3,
"is_link_only": "0"
}))
.unwrap();
let part: UploadPart = serde_json::from_value(json!({
"partNo": 1,
"startOffset": 4,
"endOffset": 7,
"status": "PENDING",
"locked": "1"
}))
.unwrap();
assert_eq!(file.is_link_only, Some(false));
assert!(part.locked);
}
}