1use std::collections::BTreeMap;
4
5use serde::de::{self, Visitor};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::{Map, Value};
8use thiserror::Error;
9
10use crate::ids::{CategoryId, Doi, LicenseId};
11
12#[derive(Clone, Debug, PartialEq, Eq, Hash)]
14#[non_exhaustive]
15pub enum DefinedType {
16 Figure,
18 Dataset,
20 Media,
22 Poster,
24 JournalContribution,
26 Presentation,
28 Thesis,
30 Software,
32 OnlineResource,
34 Preprint,
36 Book,
38 ConferenceContribution,
40 Chapter,
42 PeerReview,
44 EducationalResource,
46 Report,
48 Standard,
50 Composition,
52 Funding,
54 PhysicalObject,
56 DataManagementPlan,
58 Workflow,
60 Monograph,
62 Performance,
64 Event,
66 Service,
68 Model,
70 Unknown(String),
72}
73
74impl DefinedType {
75 #[must_use]
89 pub fn api_name(&self) -> &str {
90 match self {
91 Self::Figure => "figure",
92 Self::Dataset => "dataset",
93 Self::Media => "media",
94 Self::Poster => "poster",
95 Self::JournalContribution => "journal contribution",
96 Self::Presentation => "presentation",
97 Self::Thesis => "thesis",
98 Self::Software => "software",
99 Self::OnlineResource => "online resource",
100 Self::Preprint => "preprint",
101 Self::Book => "book",
102 Self::ConferenceContribution => "conference contribution",
103 Self::Chapter => "chapter",
104 Self::PeerReview => "peer review",
105 Self::EducationalResource => "educational resource",
106 Self::Report => "report",
107 Self::Standard => "standard",
108 Self::Composition => "composition",
109 Self::Funding => "funding",
110 Self::PhysicalObject => "physical object",
111 Self::DataManagementPlan => "data management plan",
112 Self::Workflow => "workflow",
113 Self::Monograph => "monograph",
114 Self::Performance => "performance",
115 Self::Event => "event",
116 Self::Service => "service",
117 Self::Model => "model",
118 Self::Unknown(value) => value.as_str(),
119 }
120 }
121
122 #[must_use]
124 pub fn api_id(&self) -> Option<u64> {
125 match self {
126 Self::Figure => Some(1),
127 Self::Dataset => Some(3),
128 Self::Media => Some(2),
129 Self::Poster => Some(5),
130 Self::JournalContribution => Some(6),
131 Self::Presentation => Some(7),
132 Self::Thesis => Some(8),
133 Self::Software => Some(9),
134 Self::OnlineResource => Some(11),
135 Self::Preprint => Some(12),
136 Self::Book => Some(13),
137 Self::ConferenceContribution => Some(14),
138 Self::Chapter => Some(15),
139 Self::PeerReview => Some(16),
140 Self::EducationalResource => Some(17),
141 Self::Report => Some(18),
142 Self::Standard => Some(19),
143 Self::Composition => Some(20),
144 Self::Funding => Some(21),
145 Self::PhysicalObject => Some(22),
146 Self::DataManagementPlan => Some(23),
147 Self::Workflow => Some(24),
148 Self::Monograph => Some(25),
149 Self::Performance => Some(26),
150 Self::Event => Some(27),
151 Self::Service => Some(28),
152 Self::Model => Some(29),
153 Self::Unknown(value) => value.parse().ok(),
154 }
155 }
156
157 #[must_use]
160 pub fn from_api_id(id: u64) -> Self {
161 match id {
162 1 => Self::Figure,
163 2 => Self::Media,
164 3 => Self::Dataset,
165 5 => Self::Poster,
166 6 => Self::JournalContribution,
167 7 => Self::Presentation,
168 8 => Self::Thesis,
169 9 => Self::Software,
170 11 => Self::OnlineResource,
171 12 => Self::Preprint,
172 13 => Self::Book,
173 14 => Self::ConferenceContribution,
174 15 => Self::Chapter,
175 16 => Self::PeerReview,
176 17 => Self::EducationalResource,
177 18 => Self::Report,
178 19 => Self::Standard,
179 20 => Self::Composition,
180 21 => Self::Funding,
181 22 => Self::PhysicalObject,
182 23 => Self::DataManagementPlan,
183 24 => Self::Workflow,
184 25 => Self::Monograph,
185 26 => Self::Performance,
186 27 => Self::Event,
187 28 => Self::Service,
188 29 => Self::Model,
189 other => Self::Unknown(other.to_string()),
190 }
191 }
192
193 #[must_use]
196 pub fn from_api_name(value: impl Into<String>) -> Self {
197 let value = value.into();
198 let normalized = value.to_ascii_lowercase().replace('_', " ");
199 match normalized.as_str() {
200 "figure" => Self::Figure,
201 "media" => Self::Media,
202 "dataset" => Self::Dataset,
203 "poster" => Self::Poster,
204 "paper" | "journal contribution" => Self::JournalContribution,
205 "presentation" => Self::Presentation,
206 "thesis" => Self::Thesis,
207 "code" | "software" => Self::Software,
208 "metadata" | "online resource" => Self::OnlineResource,
209 "preprint" => Self::Preprint,
210 "book" => Self::Book,
211 "conference contribution" => Self::ConferenceContribution,
212 "chapter" => Self::Chapter,
213 "peer review" => Self::PeerReview,
214 "educational resource" => Self::EducationalResource,
215 "report" => Self::Report,
216 "standard" => Self::Standard,
217 "composition" => Self::Composition,
218 "funding" => Self::Funding,
219 "physical object" => Self::PhysicalObject,
220 "data management plan" => Self::DataManagementPlan,
221 "workflow" => Self::Workflow,
222 "monograph" => Self::Monograph,
223 "performance" => Self::Performance,
224 "event" => Self::Event,
225 "service" => Self::Service,
226 "model" => Self::Model,
227 _ => Self::Unknown(value),
228 }
229 }
230}
231
232impl Serialize for DefinedType {
233 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
234 where
235 S: Serializer,
236 {
237 serializer.serialize_str(self.api_name())
238 }
239}
240
241impl<'de> Deserialize<'de> for DefinedType {
242 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
243 where
244 D: Deserializer<'de>,
245 {
246 struct DefinedTypeVisitor;
247
248 impl Visitor<'_> for DefinedTypeVisitor {
249 type Value = DefinedType;
250
251 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 formatter.write_str("a Figshare defined_type string or integer")
253 }
254
255 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
256 Ok(DefinedType::from_api_id(value))
257 }
258
259 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
260 where
261 E: de::Error,
262 {
263 let value = u64::try_from(value).map_err(E::custom)?;
264 Ok(DefinedType::from_api_id(value))
265 }
266
267 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
268 where
269 E: de::Error,
270 {
271 if !value.is_finite() || value.fract() != 0.0 || value < 0.0 {
272 return Err(E::custom("expected an integer-like defined_type value"));
273 }
274
275 let value = value.to_string().parse::<u64>().map_err(E::custom)?;
276 Ok(DefinedType::from_api_id(value))
277 }
278
279 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
280 Ok(DefinedType::from_api_name(value))
281 }
282
283 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
284 Ok(DefinedType::from_api_name(value))
285 }
286 }
287
288 deserializer.deserialize_any(DefinedTypeVisitor)
289 }
290}
291
292#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(untagged)]
295pub enum AuthorReference {
296 Id {
298 id: u64,
300 },
301 Name {
303 name: String,
305 },
306}
307
308impl AuthorReference {
309 #[must_use]
311 pub fn id(id: u64) -> Self {
312 Self::Id { id }
313 }
314
315 #[must_use]
317 pub fn name(name: impl Into<String>) -> Self {
318 Self::Name { name: name.into() }
319 }
320}
321
322#[derive(Debug, Error, PartialEq, Eq)]
324pub enum ArticleMetadataBuildError {
325 #[error("missing required field: title")]
327 MissingTitle,
328 #[error("missing required field: defined_type")]
330 MissingDefinedType,
331}
332
333#[derive(Clone, Debug, PartialEq)]
335pub struct ArticleMetadata {
336 pub title: String,
338 pub description: Option<String>,
340 pub defined_type: DefinedType,
342 pub tags: Vec<String>,
344 pub keywords: Vec<String>,
346 pub references: Vec<String>,
348 pub categories: Vec<CategoryId>,
350 pub authors: Vec<AuthorReference>,
352 pub custom_fields: BTreeMap<String, Value>,
354 pub funding: Option<String>,
356 pub license: Option<LicenseId>,
358 pub doi: Option<Doi>,
360 pub resource_doi: Option<String>,
362 pub resource_title: Option<String>,
364}
365
366impl ArticleMetadata {
367 #[must_use]
388 pub fn builder() -> ArticleMetadataBuilder {
389 ArticleMetadataBuilder::default()
390 }
391
392 pub(crate) fn to_payload(&self) -> Value {
393 let mut object = Map::new();
394 object.insert("title".into(), Value::String(self.title.clone()));
395 object.insert(
396 "defined_type".into(),
397 Value::String(self.defined_type.api_name().to_owned()),
398 );
399
400 if let Some(description) = &self.description {
401 object.insert("description".into(), Value::String(description.clone()));
402 }
403 if !self.tags.is_empty() {
404 object.insert(
405 "tags".into(),
406 Value::Array(self.tags.iter().cloned().map(Value::String).collect()),
407 );
408 }
409 if !self.keywords.is_empty() {
410 object.insert(
411 "keywords".into(),
412 Value::Array(self.keywords.iter().cloned().map(Value::String).collect()),
413 );
414 }
415 if !self.references.is_empty() {
416 object.insert(
417 "references".into(),
418 Value::Array(self.references.iter().cloned().map(Value::String).collect()),
419 );
420 }
421 if !self.categories.is_empty() {
422 object.insert(
423 "categories".into(),
424 Value::Array(
425 self.categories
426 .iter()
427 .map(|category| Value::from(category.0))
428 .collect(),
429 ),
430 );
431 }
432 if !self.authors.is_empty() {
433 object.insert(
434 "authors".into(),
435 Value::Array(
436 self.authors
437 .iter()
438 .map(|author| match author {
439 AuthorReference::Id { id } => {
440 let mut author = Map::new();
441 author.insert("id".into(), Value::from(*id));
442 Value::Object(author)
443 }
444 AuthorReference::Name { name } => {
445 let mut author = Map::new();
446 author.insert("name".into(), Value::String(name.clone()));
447 Value::Object(author)
448 }
449 })
450 .collect(),
451 ),
452 );
453 }
454 if !self.custom_fields.is_empty() {
455 object.insert(
456 "custom_fields".into(),
457 Value::Object(self.custom_fields.clone().into_iter().collect()),
458 );
459 }
460 if let Some(funding) = &self.funding {
461 object.insert("funding".into(), Value::String(funding.clone()));
462 }
463 if let Some(license) = self.license {
464 object.insert("license".into(), Value::from(license.0));
465 }
466 if let Some(doi) = &self.doi {
467 object.insert("doi".into(), Value::String(doi.to_string()));
468 }
469 if let Some(resource_doi) = &self.resource_doi {
470 object.insert("resource_doi".into(), Value::String(resource_doi.clone()));
471 }
472 if let Some(resource_title) = &self.resource_title {
473 object.insert(
474 "resource_title".into(),
475 Value::String(resource_title.clone()),
476 );
477 }
478
479 Value::Object(object)
480 }
481}
482
483#[derive(Clone, Debug, Default)]
485pub struct ArticleMetadataBuilder {
486 title: Option<String>,
487 description: Option<String>,
488 defined_type: Option<DefinedType>,
489 tags: Vec<String>,
490 keywords: Vec<String>,
491 references: Vec<String>,
492 categories: Vec<CategoryId>,
493 authors: Vec<AuthorReference>,
494 custom_fields: BTreeMap<String, Value>,
495 funding: Option<String>,
496 license: Option<LicenseId>,
497 doi: Option<Doi>,
498 resource_doi: Option<String>,
499 resource_title: Option<String>,
500}
501
502impl ArticleMetadataBuilder {
503 #[must_use]
505 pub fn title(mut self, title: impl Into<String>) -> Self {
506 self.title = Some(title.into());
507 self
508 }
509
510 #[must_use]
512 pub fn description(mut self, description: impl Into<String>) -> Self {
513 self.description = Some(description.into());
514 self
515 }
516
517 #[must_use]
519 pub fn defined_type(mut self, defined_type: DefinedType) -> Self {
520 self.defined_type = Some(defined_type);
521 self
522 }
523
524 #[must_use]
526 pub fn tag(mut self, tag: impl Into<String>) -> Self {
527 self.tags.push(tag.into());
528 self
529 }
530
531 #[must_use]
533 pub fn keyword(mut self, keyword: impl Into<String>) -> Self {
534 self.keywords.push(keyword.into());
535 self
536 }
537
538 #[must_use]
540 pub fn reference(mut self, reference: impl Into<String>) -> Self {
541 self.references.push(reference.into());
542 self
543 }
544
545 #[must_use]
547 pub fn category_id(mut self, category: impl Into<CategoryId>) -> Self {
548 self.categories.push(category.into());
549 self
550 }
551
552 #[must_use]
554 pub fn author(mut self, author: AuthorReference) -> Self {
555 self.authors.push(author);
556 self
557 }
558
559 #[must_use]
561 pub fn author_id(self, author_id: u64) -> Self {
562 self.author(AuthorReference::id(author_id))
563 }
564
565 #[must_use]
567 pub fn author_named(self, name: impl Into<String>) -> Self {
568 self.author(AuthorReference::name(name))
569 }
570
571 #[must_use]
573 pub fn custom_field_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
574 self.custom_fields
575 .insert(name.into(), Value::String(value.into()));
576 self
577 }
578
579 #[must_use]
581 pub fn custom_field_json(mut self, name: impl Into<String>, value: Value) -> Self {
582 self.custom_fields.insert(name.into(), value);
583 self
584 }
585
586 #[must_use]
588 pub fn funding(mut self, funding: impl Into<String>) -> Self {
589 self.funding = Some(funding.into());
590 self
591 }
592
593 #[must_use]
595 pub fn license_id(mut self, license: impl Into<LicenseId>) -> Self {
596 self.license = Some(license.into());
597 self
598 }
599
600 #[must_use]
602 pub fn doi(mut self, doi: Doi) -> Self {
603 self.doi = Some(doi);
604 self
605 }
606
607 #[must_use]
609 pub fn resource_doi(mut self, resource_doi: impl Into<String>) -> Self {
610 self.resource_doi = Some(resource_doi.into());
611 self
612 }
613
614 #[must_use]
616 pub fn resource_title(mut self, resource_title: impl Into<String>) -> Self {
617 self.resource_title = Some(resource_title.into());
618 self
619 }
620
621 pub fn build(self) -> Result<ArticleMetadata, ArticleMetadataBuildError> {
627 let title = self.title.ok_or(ArticleMetadataBuildError::MissingTitle)?;
628 let defined_type = self
629 .defined_type
630 .ok_or(ArticleMetadataBuildError::MissingDefinedType)?;
631
632 Ok(ArticleMetadata {
633 title,
634 description: self.description,
635 defined_type,
636 tags: self.tags,
637 keywords: self.keywords,
638 references: self.references,
639 categories: self.categories,
640 authors: self.authors,
641 custom_fields: self.custom_fields,
642 funding: self.funding,
643 license: self.license,
644 doi: self.doi,
645 resource_doi: self.resource_doi,
646 resource_title: self.resource_title,
647 })
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use serde_json::json;
654
655 use super::{ArticleMetadata, ArticleMetadataBuildError, AuthorReference, DefinedType};
656 use crate::ids::Doi;
657
658 #[test]
659 fn defined_type_accepts_strings_and_ids() {
660 let dataset_from_name: DefinedType = serde_json::from_str("\"dataset\"").unwrap();
661 let dataset_from_id: DefinedType = serde_json::from_str("3").unwrap();
662
663 assert_eq!(dataset_from_name, DefinedType::Dataset);
664 assert_eq!(dataset_from_id, DefinedType::Dataset);
665 assert_eq!(
666 serde_json::to_string(&DefinedType::Dataset).unwrap(),
667 "\"dataset\""
668 );
669 }
670
671 #[test]
672 fn defined_type_round_trips_all_current_api_variants() {
673 let cases = [
674 (DefinedType::Figure, "figure", 1),
675 (DefinedType::Media, "media", 2),
676 (DefinedType::Dataset, "dataset", 3),
677 (DefinedType::Poster, "poster", 5),
678 (DefinedType::JournalContribution, "journal contribution", 6),
679 (DefinedType::Presentation, "presentation", 7),
680 (DefinedType::Thesis, "thesis", 8),
681 (DefinedType::Software, "software", 9),
682 (DefinedType::OnlineResource, "online resource", 11),
683 (DefinedType::Preprint, "preprint", 12),
684 (DefinedType::Book, "book", 13),
685 (
686 DefinedType::ConferenceContribution,
687 "conference contribution",
688 14,
689 ),
690 (DefinedType::Chapter, "chapter", 15),
691 (DefinedType::PeerReview, "peer review", 16),
692 (DefinedType::EducationalResource, "educational resource", 17),
693 (DefinedType::Report, "report", 18),
694 (DefinedType::Standard, "standard", 19),
695 (DefinedType::Composition, "composition", 20),
696 (DefinedType::Funding, "funding", 21),
697 (DefinedType::PhysicalObject, "physical object", 22),
698 (DefinedType::DataManagementPlan, "data management plan", 23),
699 (DefinedType::Workflow, "workflow", 24),
700 (DefinedType::Monograph, "monograph", 25),
701 (DefinedType::Performance, "performance", 26),
702 (DefinedType::Event, "event", 27),
703 (DefinedType::Service, "service", 28),
704 (DefinedType::Model, "model", 29),
705 ];
706
707 for (defined_type, api_name, api_id) in cases {
708 assert_eq!(defined_type.api_name(), api_name);
709 assert_eq!(defined_type.api_id(), Some(api_id));
710 assert_eq!(DefinedType::from_api_name(api_name), defined_type);
711 assert_eq!(DefinedType::from_api_id(api_id), defined_type);
712 }
713 }
714
715 #[test]
716 fn defined_type_aliases_and_unknown_values_are_preserved() {
717 assert_eq!(
718 DefinedType::from_api_name("paper"),
719 DefinedType::JournalContribution
720 );
721 assert_eq!(DefinedType::from_api_name("code"), DefinedType::Software);
722 assert_eq!(
723 DefinedType::from_api_name("metadata"),
724 DefinedType::OnlineResource
725 );
726 assert_eq!(
727 DefinedType::from_api_name("custom widget"),
728 DefinedType::Unknown("custom widget".into())
729 );
730 assert_eq!(DefinedType::Unknown("31".into()).api_id(), Some(31));
731 assert_eq!(
732 DefinedType::from_api_id(31),
733 DefinedType::Unknown("31".into())
734 );
735 }
736
737 #[test]
738 fn defined_type_rejects_invalid_numeric_shapes() {
739 assert!(serde_json::from_str::<DefinedType>("-1").is_err());
740 assert!(serde_json::from_str::<DefinedType>("1.5").is_err());
741 }
742
743 #[test]
744 fn metadata_builder_requires_title_and_defined_type() {
745 assert_eq!(
746 ArticleMetadata::builder().build().unwrap_err(),
747 ArticleMetadataBuildError::MissingTitle
748 );
749 assert_eq!(
750 ArticleMetadata::builder().title("x").build().unwrap_err(),
751 ArticleMetadataBuildError::MissingDefinedType
752 );
753 }
754
755 #[test]
756 fn author_reference_helpers_cover_id_and_name_constructors() {
757 assert_eq!(AuthorReference::id(7), AuthorReference::Id { id: 7 });
758 assert_eq!(
759 AuthorReference::name("Doe, Jane"),
760 AuthorReference::Name {
761 name: "Doe, Jane".into()
762 }
763 );
764 }
765
766 #[test]
767 fn metadata_builder_serializes_expected_payload() {
768 let metadata = ArticleMetadata::builder()
769 .title("Example")
770 .defined_type(DefinedType::Dataset)
771 .description("Description")
772 .tag("data")
773 .keyword("science")
774 .reference("https://example.com")
775 .category_id(3)
776 .author(AuthorReference::id(7))
777 .author_named("Doe, Jane")
778 .custom_field_text("location", "Amsterdam")
779 .license_id(1)
780 .resource_doi("10.1234/example")
781 .build()
782 .unwrap();
783
784 let payload = metadata.to_payload();
785 assert_eq!(payload["title"], "Example");
786 assert_eq!(payload["defined_type"], "dataset");
787 assert_eq!(payload["categories"][0], 3);
788 assert_eq!(payload["authors"][0]["id"], 7);
789 assert_eq!(payload["authors"][1]["name"], "Doe, Jane");
790 assert_eq!(payload["custom_fields"]["location"], "Amsterdam");
791 }
792
793 #[test]
794 fn metadata_builder_populates_every_optional_field() {
795 let metadata = ArticleMetadata::builder()
796 .title("Example dataset")
797 .description("Long-form description")
798 .defined_type(DefinedType::Dataset)
799 .tag("alpha")
800 .tag("beta")
801 .keyword("science")
802 .keyword("open-data")
803 .reference("https://example.com/reference")
804 .category_id(11)
805 .category_id(12)
806 .author_id(3)
807 .author_named("Doe, Jane")
808 .custom_field_text("campus", "Pisa")
809 .custom_field_json("metrics", json!({ "downloads": 42 }))
810 .funding("Grant-42")
811 .license_id(1)
812 .doi(Doi::new("10.6084/m9.figshare.999").unwrap())
813 .resource_doi("10.1000/example")
814 .resource_title("Related resource")
815 .build()
816 .unwrap();
817
818 assert_eq!(metadata.title, "Example dataset");
819 assert_eq!(
820 metadata.description.as_deref(),
821 Some("Long-form description")
822 );
823 assert_eq!(metadata.defined_type, DefinedType::Dataset);
824 assert_eq!(metadata.tags, vec!["alpha", "beta"]);
825 assert_eq!(metadata.keywords, vec!["science", "open-data"]);
826 assert_eq!(metadata.references, vec!["https://example.com/reference"]);
827 assert_eq!(metadata.categories.len(), 2);
828 assert_eq!(metadata.authors.len(), 2);
829 assert_eq!(metadata.funding.as_deref(), Some("Grant-42"));
830 assert_eq!(metadata.license.unwrap().0, 1);
831 assert_eq!(
832 metadata.doi.as_ref().map(Doi::as_str),
833 Some("10.6084/m9.figshare.999")
834 );
835 assert_eq!(metadata.resource_doi.as_deref(), Some("10.1000/example"));
836 assert_eq!(metadata.resource_title.as_deref(), Some("Related resource"));
837
838 let payload = metadata.to_payload();
839 assert_eq!(payload["description"], "Long-form description");
840 assert_eq!(payload["tags"], json!(["alpha", "beta"]));
841 assert_eq!(payload["keywords"], json!(["science", "open-data"]));
842 assert_eq!(
843 payload["references"],
844 json!(["https://example.com/reference"])
845 );
846 assert_eq!(payload["categories"], json!([11, 12]));
847 assert_eq!(payload["authors"][0]["id"], 3);
848 assert_eq!(payload["authors"][1]["name"], "Doe, Jane");
849 assert_eq!(payload["custom_fields"]["campus"], "Pisa");
850 assert_eq!(payload["custom_fields"]["metrics"]["downloads"], 42);
851 assert_eq!(payload["funding"], "Grant-42");
852 assert_eq!(payload["license"], 1);
853 assert_eq!(payload["doi"], "10.6084/m9.figshare.999");
854 assert_eq!(payload["resource_doi"], "10.1000/example");
855 assert_eq!(payload["resource_title"], "Related resource");
856 }
857}