use std::collections::BTreeMap;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};
use thiserror::Error;
use crate::ids::{CategoryId, Doi, LicenseId};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DefinedType {
Figure,
Dataset,
Media,
Poster,
JournalContribution,
Presentation,
Thesis,
Software,
OnlineResource,
Preprint,
Book,
ConferenceContribution,
Chapter,
PeerReview,
EducationalResource,
Report,
Standard,
Composition,
Funding,
PhysicalObject,
DataManagementPlan,
Workflow,
Monograph,
Performance,
Event,
Service,
Model,
Unknown(String),
}
impl DefinedType {
#[must_use]
pub fn api_name(&self) -> &str {
match self {
Self::Figure => "figure",
Self::Dataset => "dataset",
Self::Media => "media",
Self::Poster => "poster",
Self::JournalContribution => "journal contribution",
Self::Presentation => "presentation",
Self::Thesis => "thesis",
Self::Software => "software",
Self::OnlineResource => "online resource",
Self::Preprint => "preprint",
Self::Book => "book",
Self::ConferenceContribution => "conference contribution",
Self::Chapter => "chapter",
Self::PeerReview => "peer review",
Self::EducationalResource => "educational resource",
Self::Report => "report",
Self::Standard => "standard",
Self::Composition => "composition",
Self::Funding => "funding",
Self::PhysicalObject => "physical object",
Self::DataManagementPlan => "data management plan",
Self::Workflow => "workflow",
Self::Monograph => "monograph",
Self::Performance => "performance",
Self::Event => "event",
Self::Service => "service",
Self::Model => "model",
Self::Unknown(value) => value.as_str(),
}
}
#[must_use]
pub fn api_id(&self) -> Option<u64> {
match self {
Self::Figure => Some(1),
Self::Dataset => Some(3),
Self::Media => Some(2),
Self::Poster => Some(5),
Self::JournalContribution => Some(6),
Self::Presentation => Some(7),
Self::Thesis => Some(8),
Self::Software => Some(9),
Self::OnlineResource => Some(11),
Self::Preprint => Some(12),
Self::Book => Some(13),
Self::ConferenceContribution => Some(14),
Self::Chapter => Some(15),
Self::PeerReview => Some(16),
Self::EducationalResource => Some(17),
Self::Report => Some(18),
Self::Standard => Some(19),
Self::Composition => Some(20),
Self::Funding => Some(21),
Self::PhysicalObject => Some(22),
Self::DataManagementPlan => Some(23),
Self::Workflow => Some(24),
Self::Monograph => Some(25),
Self::Performance => Some(26),
Self::Event => Some(27),
Self::Service => Some(28),
Self::Model => Some(29),
Self::Unknown(value) => value.parse().ok(),
}
}
#[must_use]
pub fn from_api_id(id: u64) -> Self {
match id {
1 => Self::Figure,
2 => Self::Media,
3 => Self::Dataset,
5 => Self::Poster,
6 => Self::JournalContribution,
7 => Self::Presentation,
8 => Self::Thesis,
9 => Self::Software,
11 => Self::OnlineResource,
12 => Self::Preprint,
13 => Self::Book,
14 => Self::ConferenceContribution,
15 => Self::Chapter,
16 => Self::PeerReview,
17 => Self::EducationalResource,
18 => Self::Report,
19 => Self::Standard,
20 => Self::Composition,
21 => Self::Funding,
22 => Self::PhysicalObject,
23 => Self::DataManagementPlan,
24 => Self::Workflow,
25 => Self::Monograph,
26 => Self::Performance,
27 => Self::Event,
28 => Self::Service,
29 => Self::Model,
other => Self::Unknown(other.to_string()),
}
}
#[must_use]
pub fn from_api_name(value: impl Into<String>) -> Self {
let value = value.into();
let normalized = value.to_ascii_lowercase().replace('_', " ");
match normalized.as_str() {
"figure" => Self::Figure,
"media" => Self::Media,
"dataset" => Self::Dataset,
"poster" => Self::Poster,
"paper" | "journal contribution" => Self::JournalContribution,
"presentation" => Self::Presentation,
"thesis" => Self::Thesis,
"code" | "software" => Self::Software,
"metadata" | "online resource" => Self::OnlineResource,
"preprint" => Self::Preprint,
"book" => Self::Book,
"conference contribution" => Self::ConferenceContribution,
"chapter" => Self::Chapter,
"peer review" => Self::PeerReview,
"educational resource" => Self::EducationalResource,
"report" => Self::Report,
"standard" => Self::Standard,
"composition" => Self::Composition,
"funding" => Self::Funding,
"physical object" => Self::PhysicalObject,
"data management plan" => Self::DataManagementPlan,
"workflow" => Self::Workflow,
"monograph" => Self::Monograph,
"performance" => Self::Performance,
"event" => Self::Event,
"service" => Self::Service,
"model" => Self::Model,
_ => Self::Unknown(value),
}
}
}
impl Serialize for DefinedType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.api_name())
}
}
impl<'de> Deserialize<'de> for DefinedType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct DefinedTypeVisitor;
impl Visitor<'_> for DefinedTypeVisitor {
type Value = DefinedType;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a Figshare defined_type string or integer")
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
Ok(DefinedType::from_api_id(value))
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
let value = u64::try_from(value).map_err(E::custom)?;
Ok(DefinedType::from_api_id(value))
}
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: de::Error,
{
if !value.is_finite() || value.fract() != 0.0 || value < 0.0 {
return Err(E::custom("expected an integer-like defined_type value"));
}
let value = value.to_string().parse::<u64>().map_err(E::custom)?;
Ok(DefinedType::from_api_id(value))
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
Ok(DefinedType::from_api_name(value))
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
Ok(DefinedType::from_api_name(value))
}
}
deserializer.deserialize_any(DefinedTypeVisitor)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AuthorReference {
Id {
id: u64,
},
Name {
name: String,
},
}
impl AuthorReference {
#[must_use]
pub fn id(id: u64) -> Self {
Self::Id { id }
}
#[must_use]
pub fn name(name: impl Into<String>) -> Self {
Self::Name { name: name.into() }
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ArticleMetadataBuildError {
#[error("missing required field: title")]
MissingTitle,
#[error("missing required field: defined_type")]
MissingDefinedType,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ArticleMetadata {
pub title: String,
pub description: Option<String>,
pub defined_type: DefinedType,
pub tags: Vec<String>,
pub keywords: Vec<String>,
pub references: Vec<String>,
pub categories: Vec<CategoryId>,
pub authors: Vec<AuthorReference>,
pub custom_fields: BTreeMap<String, Value>,
pub funding: Option<String>,
pub license: Option<LicenseId>,
pub doi: Option<Doi>,
pub resource_doi: Option<String>,
pub resource_title: Option<String>,
}
impl ArticleMetadata {
#[must_use]
pub fn builder() -> ArticleMetadataBuilder {
ArticleMetadataBuilder::default()
}
pub(crate) fn to_payload(&self) -> Value {
let mut object = Map::new();
object.insert("title".into(), Value::String(self.title.clone()));
object.insert(
"defined_type".into(),
Value::String(self.defined_type.api_name().to_owned()),
);
if let Some(description) = &self.description {
object.insert("description".into(), Value::String(description.clone()));
}
if !self.tags.is_empty() {
object.insert(
"tags".into(),
Value::Array(self.tags.iter().cloned().map(Value::String).collect()),
);
}
if !self.keywords.is_empty() {
object.insert(
"keywords".into(),
Value::Array(self.keywords.iter().cloned().map(Value::String).collect()),
);
}
if !self.references.is_empty() {
object.insert(
"references".into(),
Value::Array(self.references.iter().cloned().map(Value::String).collect()),
);
}
if !self.categories.is_empty() {
object.insert(
"categories".into(),
Value::Array(
self.categories
.iter()
.map(|category| Value::from(category.0))
.collect(),
),
);
}
if !self.authors.is_empty() {
object.insert(
"authors".into(),
Value::Array(
self.authors
.iter()
.map(|author| match author {
AuthorReference::Id { id } => {
let mut author = Map::new();
author.insert("id".into(), Value::from(*id));
Value::Object(author)
}
AuthorReference::Name { name } => {
let mut author = Map::new();
author.insert("name".into(), Value::String(name.clone()));
Value::Object(author)
}
})
.collect(),
),
);
}
if !self.custom_fields.is_empty() {
object.insert(
"custom_fields".into(),
Value::Object(self.custom_fields.clone().into_iter().collect()),
);
}
if let Some(funding) = &self.funding {
object.insert("funding".into(), Value::String(funding.clone()));
}
if let Some(license) = self.license {
object.insert("license".into(), Value::from(license.0));
}
if let Some(doi) = &self.doi {
object.insert("doi".into(), Value::String(doi.to_string()));
}
if let Some(resource_doi) = &self.resource_doi {
object.insert("resource_doi".into(), Value::String(resource_doi.clone()));
}
if let Some(resource_title) = &self.resource_title {
object.insert(
"resource_title".into(),
Value::String(resource_title.clone()),
);
}
Value::Object(object)
}
}
#[derive(Clone, Debug, Default)]
pub struct ArticleMetadataBuilder {
title: Option<String>,
description: Option<String>,
defined_type: Option<DefinedType>,
tags: Vec<String>,
keywords: Vec<String>,
references: Vec<String>,
categories: Vec<CategoryId>,
authors: Vec<AuthorReference>,
custom_fields: BTreeMap<String, Value>,
funding: Option<String>,
license: Option<LicenseId>,
doi: Option<Doi>,
resource_doi: Option<String>,
resource_title: Option<String>,
}
impl ArticleMetadataBuilder {
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn defined_type(mut self, defined_type: DefinedType) -> Self {
self.defined_type = Some(defined_type);
self
}
#[must_use]
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn keyword(mut self, keyword: impl Into<String>) -> Self {
self.keywords.push(keyword.into());
self
}
#[must_use]
pub fn reference(mut self, reference: impl Into<String>) -> Self {
self.references.push(reference.into());
self
}
#[must_use]
pub fn category_id(mut self, category: impl Into<CategoryId>) -> Self {
self.categories.push(category.into());
self
}
#[must_use]
pub fn author(mut self, author: AuthorReference) -> Self {
self.authors.push(author);
self
}
#[must_use]
pub fn author_id(self, author_id: u64) -> Self {
self.author(AuthorReference::id(author_id))
}
#[must_use]
pub fn author_named(self, name: impl Into<String>) -> Self {
self.author(AuthorReference::name(name))
}
#[must_use]
pub fn custom_field_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.custom_fields
.insert(name.into(), Value::String(value.into()));
self
}
#[must_use]
pub fn custom_field_json(mut self, name: impl Into<String>, value: Value) -> Self {
self.custom_fields.insert(name.into(), value);
self
}
#[must_use]
pub fn funding(mut self, funding: impl Into<String>) -> Self {
self.funding = Some(funding.into());
self
}
#[must_use]
pub fn license_id(mut self, license: impl Into<LicenseId>) -> Self {
self.license = Some(license.into());
self
}
#[must_use]
pub fn doi(mut self, doi: Doi) -> Self {
self.doi = Some(doi);
self
}
#[must_use]
pub fn resource_doi(mut self, resource_doi: impl Into<String>) -> Self {
self.resource_doi = Some(resource_doi.into());
self
}
#[must_use]
pub fn resource_title(mut self, resource_title: impl Into<String>) -> Self {
self.resource_title = Some(resource_title.into());
self
}
pub fn build(self) -> Result<ArticleMetadata, ArticleMetadataBuildError> {
let title = self.title.ok_or(ArticleMetadataBuildError::MissingTitle)?;
let defined_type = self
.defined_type
.ok_or(ArticleMetadataBuildError::MissingDefinedType)?;
Ok(ArticleMetadata {
title,
description: self.description,
defined_type,
tags: self.tags,
keywords: self.keywords,
references: self.references,
categories: self.categories,
authors: self.authors,
custom_fields: self.custom_fields,
funding: self.funding,
license: self.license,
doi: self.doi,
resource_doi: self.resource_doi,
resource_title: self.resource_title,
})
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{ArticleMetadata, ArticleMetadataBuildError, AuthorReference, DefinedType};
use crate::ids::Doi;
#[test]
fn defined_type_accepts_strings_and_ids() {
let dataset_from_name: DefinedType = serde_json::from_str("\"dataset\"").unwrap();
let dataset_from_id: DefinedType = serde_json::from_str("3").unwrap();
assert_eq!(dataset_from_name, DefinedType::Dataset);
assert_eq!(dataset_from_id, DefinedType::Dataset);
assert_eq!(
serde_json::to_string(&DefinedType::Dataset).unwrap(),
"\"dataset\""
);
}
#[test]
fn defined_type_round_trips_all_current_api_variants() {
let cases = [
(DefinedType::Figure, "figure", 1),
(DefinedType::Media, "media", 2),
(DefinedType::Dataset, "dataset", 3),
(DefinedType::Poster, "poster", 5),
(DefinedType::JournalContribution, "journal contribution", 6),
(DefinedType::Presentation, "presentation", 7),
(DefinedType::Thesis, "thesis", 8),
(DefinedType::Software, "software", 9),
(DefinedType::OnlineResource, "online resource", 11),
(DefinedType::Preprint, "preprint", 12),
(DefinedType::Book, "book", 13),
(
DefinedType::ConferenceContribution,
"conference contribution",
14,
),
(DefinedType::Chapter, "chapter", 15),
(DefinedType::PeerReview, "peer review", 16),
(DefinedType::EducationalResource, "educational resource", 17),
(DefinedType::Report, "report", 18),
(DefinedType::Standard, "standard", 19),
(DefinedType::Composition, "composition", 20),
(DefinedType::Funding, "funding", 21),
(DefinedType::PhysicalObject, "physical object", 22),
(DefinedType::DataManagementPlan, "data management plan", 23),
(DefinedType::Workflow, "workflow", 24),
(DefinedType::Monograph, "monograph", 25),
(DefinedType::Performance, "performance", 26),
(DefinedType::Event, "event", 27),
(DefinedType::Service, "service", 28),
(DefinedType::Model, "model", 29),
];
for (defined_type, api_name, api_id) in cases {
assert_eq!(defined_type.api_name(), api_name);
assert_eq!(defined_type.api_id(), Some(api_id));
assert_eq!(DefinedType::from_api_name(api_name), defined_type);
assert_eq!(DefinedType::from_api_id(api_id), defined_type);
}
}
#[test]
fn defined_type_aliases_and_unknown_values_are_preserved() {
assert_eq!(
DefinedType::from_api_name("paper"),
DefinedType::JournalContribution
);
assert_eq!(DefinedType::from_api_name("code"), DefinedType::Software);
assert_eq!(
DefinedType::from_api_name("metadata"),
DefinedType::OnlineResource
);
assert_eq!(
DefinedType::from_api_name("custom widget"),
DefinedType::Unknown("custom widget".into())
);
assert_eq!(DefinedType::Unknown("31".into()).api_id(), Some(31));
assert_eq!(
DefinedType::from_api_id(31),
DefinedType::Unknown("31".into())
);
}
#[test]
fn defined_type_rejects_invalid_numeric_shapes() {
assert!(serde_json::from_str::<DefinedType>("-1").is_err());
assert!(serde_json::from_str::<DefinedType>("1.5").is_err());
}
#[test]
fn metadata_builder_requires_title_and_defined_type() {
assert_eq!(
ArticleMetadata::builder().build().unwrap_err(),
ArticleMetadataBuildError::MissingTitle
);
assert_eq!(
ArticleMetadata::builder().title("x").build().unwrap_err(),
ArticleMetadataBuildError::MissingDefinedType
);
}
#[test]
fn author_reference_helpers_cover_id_and_name_constructors() {
assert_eq!(AuthorReference::id(7), AuthorReference::Id { id: 7 });
assert_eq!(
AuthorReference::name("Doe, Jane"),
AuthorReference::Name {
name: "Doe, Jane".into()
}
);
}
#[test]
fn metadata_builder_serializes_expected_payload() {
let metadata = ArticleMetadata::builder()
.title("Example")
.defined_type(DefinedType::Dataset)
.description("Description")
.tag("data")
.keyword("science")
.reference("https://example.com")
.category_id(3)
.author(AuthorReference::id(7))
.author_named("Doe, Jane")
.custom_field_text("location", "Amsterdam")
.license_id(1)
.resource_doi("10.1234/example")
.build()
.unwrap();
let payload = metadata.to_payload();
assert_eq!(payload["title"], "Example");
assert_eq!(payload["defined_type"], "dataset");
assert_eq!(payload["categories"][0], 3);
assert_eq!(payload["authors"][0]["id"], 7);
assert_eq!(payload["authors"][1]["name"], "Doe, Jane");
assert_eq!(payload["custom_fields"]["location"], "Amsterdam");
}
#[test]
fn metadata_builder_populates_every_optional_field() {
let metadata = ArticleMetadata::builder()
.title("Example dataset")
.description("Long-form description")
.defined_type(DefinedType::Dataset)
.tag("alpha")
.tag("beta")
.keyword("science")
.keyword("open-data")
.reference("https://example.com/reference")
.category_id(11)
.category_id(12)
.author_id(3)
.author_named("Doe, Jane")
.custom_field_text("campus", "Pisa")
.custom_field_json("metrics", json!({ "downloads": 42 }))
.funding("Grant-42")
.license_id(1)
.doi(Doi::new("10.6084/m9.figshare.999").unwrap())
.resource_doi("10.1000/example")
.resource_title("Related resource")
.build()
.unwrap();
assert_eq!(metadata.title, "Example dataset");
assert_eq!(
metadata.description.as_deref(),
Some("Long-form description")
);
assert_eq!(metadata.defined_type, DefinedType::Dataset);
assert_eq!(metadata.tags, vec!["alpha", "beta"]);
assert_eq!(metadata.keywords, vec!["science", "open-data"]);
assert_eq!(metadata.references, vec!["https://example.com/reference"]);
assert_eq!(metadata.categories.len(), 2);
assert_eq!(metadata.authors.len(), 2);
assert_eq!(metadata.funding.as_deref(), Some("Grant-42"));
assert_eq!(metadata.license.unwrap().0, 1);
assert_eq!(
metadata.doi.as_ref().map(Doi::as_str),
Some("10.6084/m9.figshare.999")
);
assert_eq!(metadata.resource_doi.as_deref(), Some("10.1000/example"));
assert_eq!(metadata.resource_title.as_deref(), Some("Related resource"));
let payload = metadata.to_payload();
assert_eq!(payload["description"], "Long-form description");
assert_eq!(payload["tags"], json!(["alpha", "beta"]));
assert_eq!(payload["keywords"], json!(["science", "open-data"]));
assert_eq!(
payload["references"],
json!(["https://example.com/reference"])
);
assert_eq!(payload["categories"], json!([11, 12]));
assert_eq!(payload["authors"][0]["id"], 3);
assert_eq!(payload["authors"][1]["name"], "Doe, Jane");
assert_eq!(payload["custom_fields"]["campus"], "Pisa");
assert_eq!(payload["custom_fields"]["metrics"]["downloads"], 42);
assert_eq!(payload["funding"], "Grant-42");
assert_eq!(payload["license"], 1);
assert_eq!(payload["doi"], "10.6084/m9.figshare.999");
assert_eq!(payload["resource_doi"], "10.1000/example");
assert_eq!(payload["resource_title"], "Related resource");
}
}