use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use url::Url;
use crate::ids::{BucketUrl, ConceptRecId, DepositionFileId, DepositionId, Doi, RecordId};
use crate::metadata::RecordMetadata;
use crate::serde_util::{deserialize_option_u64ish, deserialize_stringish, 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: 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: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(match value.as_str() {
$($value => Self::$variant,)+
_ => Self::Unknown(value),
})
}
}
};
}
string_enum!(
#[derive(Default)]
DepositState {
#[default]
InProgress => "inprogress",
Unsubmitted => "unsubmitted",
Done => "done",
Error => "error"
}
);
string_enum!(
#[derive(Default)]
RecordPublicationStatus {
#[default]
Published => "published",
Draft => "draft"
}
);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DepositionStatus {
#[serde(default)]
pub submitted: bool,
#[serde(default)]
pub state: DepositState,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DepositionFile {
pub id: DepositionFileId,
pub filename: String,
#[serde(default, deserialize_with = "deserialize_u64ish")]
pub filesize: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DepositionLinks {
#[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
pub self_: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<BucketUrl>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discard: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_draft: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub versions: Option<Url>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Deposition {
pub id: DepositionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptrecid: Option<ConceptRecId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub record_id: Option<RecordId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<Doi>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptdoi: Option<Doi>,
#[serde(flatten)]
pub status: DepositionStatus,
#[serde(default)]
pub metadata: Value,
#[serde(default)]
pub files: Vec<DepositionFile>,
#[serde(default)]
pub links: DepositionLinks,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl Deposition {
#[must_use]
pub fn is_published(&self) -> bool {
self.status.submitted
}
#[must_use]
pub fn allows_metadata_edits(&self) -> bool {
matches!(
self.status.state,
DepositState::InProgress | DepositState::Unsubmitted
)
}
#[must_use]
pub fn latest_draft_url(&self) -> Option<&Url> {
self.links.latest_draft.as_ref()
}
#[must_use]
pub fn bucket_url(&self) -> Option<&BucketUrl> {
self.links.bucket.as_ref()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BucketObject {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub key: String,
#[serde(default, deserialize_with = "deserialize_u64ish")]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordFileLinks {
#[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
pub self_: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<Url>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordFile {
pub id: String,
pub key: String,
#[serde(default, deserialize_with = "deserialize_u64ish")]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
#[serde(default)]
pub links: RecordFileLinks,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl RecordFile {
#[must_use]
pub fn download_url(&self) -> Option<&Url> {
self.links.content.as_ref().or(self.links.self_.as_ref())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordLinks {
#[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
pub self_: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub self_html: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub html: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub versions: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<Url>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordStats {
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub downloads: Option<u64>,
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub views: Option<u64>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PersistentIdentifier {
pub identifier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordPids {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<PersistentIdentifier>,
#[serde(
rename = "concept-doi",
default,
skip_serializing_if = "Option::is_none"
)]
pub concept_doi: Option<PersistentIdentifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recid: Option<PersistentIdentifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oai: Option<PersistentIdentifier>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordParentCommunities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default)]
pub ids: Vec<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordParent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub communities: Option<RecordParentCommunities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pids: Option<RecordPids>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Record {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<DateTime<Utc>>,
pub id: RecordId,
#[serde(deserialize_with = "deserialize_stringish")]
pub recid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptrecid: Option<ConceptRecId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<Doi>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptdoi: Option<Doi>,
#[serde(default)]
pub metadata: RecordMetadata,
#[serde(default)]
pub files: Vec<RecordFile>,
#[serde(default)]
pub links: RecordLinks,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<RecordParent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pids: Option<RecordPids>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats: Option<RecordStats>,
#[serde(default)]
pub status: RecordPublicationStatus,
#[serde(
default,
deserialize_with = "deserialize_option_u64ish",
skip_serializing_if = "Option::is_none"
)]
pub revision: Option<u64>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Deserialize)]
struct RecordWire {
#[serde(default)]
created: Option<DateTime<Utc>>,
#[serde(default)]
modified: Option<DateTime<Utc>>,
#[serde(default)]
updated: Option<DateTime<Utc>>,
id: RecordId,
#[serde(deserialize_with = "deserialize_stringish")]
recid: String,
#[serde(default)]
conceptrecid: Option<ConceptRecId>,
#[serde(default)]
doi: Option<Doi>,
#[serde(default)]
conceptdoi: Option<Doi>,
#[serde(default)]
metadata: RecordMetadata,
#[serde(default)]
files: Vec<RecordFile>,
#[serde(default)]
links: RecordLinks,
#[serde(default)]
parent: Option<RecordParent>,
#[serde(default)]
pids: Option<RecordPids>,
#[serde(default)]
stats: Option<RecordStats>,
#[serde(default)]
status: RecordPublicationStatus,
#[serde(default, deserialize_with = "deserialize_option_u64ish")]
revision: Option<u64>,
#[serde(flatten, default)]
extra: BTreeMap<String, Value>,
}
impl<'de> Deserialize<'de> for Record {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = RecordWire::deserialize(deserializer)?;
Ok(Self {
created: wire.created,
modified: wire.modified.or(wire.updated),
id: wire.id,
recid: wire.recid,
conceptrecid: wire.conceptrecid,
doi: wire.doi,
conceptdoi: wire.conceptdoi,
metadata: wire.metadata,
files: wire.files,
links: wire.links,
parent: wire.parent,
pids: wire.pids,
stats: wire.stats,
status: wire.status,
revision: wire.revision,
extra: wire.extra,
})
}
}
impl Record {
#[must_use]
pub fn latest_url(&self) -> Option<&Url> {
self.links.latest.as_ref()
}
#[must_use]
pub fn archive_url(&self) -> Option<&Url> {
self.links.archive.as_ref()
}
#[must_use]
pub fn file_by_key(&self, key: &str) -> Option<&RecordFile> {
self.files.iter().find(|file| file.key == key)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactInfo {
pub record: Record,
pub latest: Record,
pub files_by_key: BTreeMap<String, RecordFile>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishedRecord {
pub deposition: Deposition,
pub record: Record,
}
#[cfg(test)]
mod tests {
use super::{
BucketObject, DepositState, Deposition, DepositionFile, Record, RecordFile, RecordLinks,
RecordPublicationStatus,
};
use serde_json::json;
#[test]
fn preserves_unknown_record_fields() {
let record: Record = serde_json::from_value(json!({
"created": "2026-04-03T12:00:00+00:00",
"updated": "2026-04-03T13:00:00+00:00",
"id": 42,
"recid": "42",
"metadata": { "title": "artifact" },
"files": [],
"links": {},
"parent": {
"id": "parent-1",
"communities": {
"default": "zenodo",
"ids": ["zenodo", "sandbox"]
},
"pids": {
"doi": {
"identifier": "10.5281/zenodo.42"
}
}
},
"pids": {
"doi": {
"identifier": "10.5281/zenodo.42"
},
"concept-doi": {
"identifier": "10.5281/zenodo.41"
}
},
"mystery": "value"
}))
.unwrap();
assert!(record.created.is_some());
assert!(record.modified.is_some());
assert_eq!(
record
.parent
.as_ref()
.and_then(|parent| parent.id.as_deref()),
Some("parent-1")
);
assert_eq!(
record
.pids
.as_ref()
.and_then(|pids| pids.doi.as_ref())
.map(|pid| pid.identifier.as_str()),
Some("10.5281/zenodo.42")
);
assert_eq!(record.extra.get("mystery"), Some(&json!("value")));
}
#[test]
fn record_accepts_both_modified_and_updated_timestamps() {
let record: Record = serde_json::from_value(json!({
"created": "2026-04-03T12:00:00+00:00",
"modified": "2026-04-03T14:00:00+00:00",
"updated": "2026-04-03T13:00:00+00:00",
"id": 42,
"recid": 42.0,
"metadata": { "title": "artifact" },
"files": [],
"links": {}
}))
.unwrap();
assert!(record.created.is_some());
assert_eq!(
record.modified.unwrap().to_rfc3339(),
"2026-04-03T14:00:00+00:00"
);
}
#[test]
fn model_string_enums_preserve_unknown_values() {
let state: DepositState = serde_json::from_value(json!("queued")).unwrap();
let status: RecordPublicationStatus = serde_json::from_value(json!("embargoed")).unwrap();
let unsubmitted: DepositState = serde_json::from_value(json!("unsubmitted")).unwrap();
assert_eq!(serde_json::to_value(&state).unwrap(), json!("queued"));
assert_eq!(serde_json::to_value(&status).unwrap(), json!("embargoed"));
assert_eq!(
serde_json::to_value(&unsubmitted).unwrap(),
json!("unsubmitted")
);
assert!(matches!(state, DepositState::Unknown(value) if value == "queued"));
assert_eq!(unsubmitted, DepositState::Unsubmitted);
assert!(matches!(status, RecordPublicationStatus::Unknown(value) if value == "embargoed"));
}
#[test]
fn follows_latest_draft_link() {
let deposition: Deposition = serde_json::from_value(serde_json::json!({
"id": 7,
"submitted": true,
"state": "done",
"metadata": {},
"files": [],
"links": {
"latest_draft": "https://zenodo.org/api/deposit/depositions/8"
}
}))
.unwrap();
assert_eq!(
deposition.latest_draft_url().unwrap().as_str(),
"https://zenodo.org/api/deposit/depositions/8"
);
}
#[test]
fn record_file_prefers_content_link() {
let file: RecordFile = serde_json::from_value(serde_json::json!({
"id": "f1",
"key": "artifact.bin",
"links": {
"self": "https://zenodo.org/api/files/self",
"content": "https://zenodo.org/api/files/content"
}
}))
.unwrap();
assert_eq!(
file.download_url().unwrap().as_str(),
"https://zenodo.org/api/files/content"
);
}
#[test]
fn deposition_file_accepts_integer_like_numeric_shapes() {
let float_file: DepositionFile = serde_json::from_value(serde_json::json!({
"id": "f1",
"filename": "artifact.bin",
"filesize": 14.0
}))
.unwrap();
assert_eq!(float_file.filesize, 14);
let string_file: DepositionFile = serde_json::from_value(serde_json::json!({
"id": "f2",
"filename": "artifact.bin",
"filesize": "18"
}))
.unwrap();
assert_eq!(string_file.filesize, 18);
}
#[test]
fn deposition_file_rejects_non_integral_sizes() {
let error = serde_json::from_value::<DepositionFile>(serde_json::json!({
"id": "f1",
"filename": "artifact.bin",
"filesize": 14.5
}))
.unwrap_err();
assert!(error.to_string().contains("integer-like"));
}
#[test]
fn other_numeric_response_fields_accept_integer_like_shapes() {
let uploaded: BucketObject = serde_json::from_value(serde_json::json!({
"key": "artifact.bin",
"size": "14"
}))
.unwrap();
let published_file: RecordFile = serde_json::from_value(serde_json::json!({
"id": "f1",
"key": "artifact.bin",
"size": 15.0,
"links": {}
}))
.unwrap();
let record: Record = serde_json::from_value(serde_json::json!({
"id": 42.0,
"recid": 42.0,
"metadata": { "title": "artifact" },
"files": [],
"links": {},
"stats": {
"downloads": "16",
"views": 17.0
},
"revision": "18"
}))
.unwrap();
assert_eq!(uploaded.size, 14);
assert_eq!(published_file.size, 15);
assert_eq!(record.id.0, 42);
assert_eq!(
record.stats.as_ref().and_then(|stats| stats.downloads),
Some(16)
);
assert_eq!(
record.stats.as_ref().and_then(|stats| stats.views),
Some(17)
);
assert_eq!(record.revision, Some(18));
}
#[test]
fn record_and_deposition_helpers_expose_status_and_links() {
let deposition: Deposition = serde_json::from_value(serde_json::json!({
"id": 9,
"submitted": false,
"state": "mystery-state",
"metadata": {},
"files": [],
"links": {
"bucket": "https://zenodo.org/api/files/bucket-9"
}
}))
.unwrap();
let record = Record {
created: None,
modified: None,
id: crate::RecordId(10),
recid: "10".into(),
conceptrecid: None,
doi: None,
conceptdoi: None,
metadata: crate::RecordMetadata::default(),
files: Vec::new(),
links: RecordLinks::default(),
parent: None,
pids: None,
stats: None,
status: RecordPublicationStatus::Draft,
revision: None,
extra: std::collections::BTreeMap::new(),
};
assert!(!deposition.is_published());
assert!(deposition.bucket_url().is_some());
assert!(matches!(deposition.status.state, DepositState::Unknown(_)));
assert!(record.latest_url().is_none());
assert!(record.archive_url().is_none());
}
#[test]
fn deposition_helpers_treat_unsubmitted_as_editable() {
let deposition: Deposition = serde_json::from_value(serde_json::json!({
"id": 11,
"submitted": false,
"state": "unsubmitted",
"metadata": {},
"files": [],
"links": {}
}))
.unwrap();
assert_eq!(deposition.status.state, DepositState::Unsubmitted);
assert!(deposition.allows_metadata_edits());
}
}