Skip to main content

zenodo_rs/
ids.rs

1//! Small identifier newtypes used throughout the public API.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use thiserror::Error;
8use url::Url;
9
10use crate::serde_util::deserialize_u64ish;
11
12macro_rules! id_newtype {
13    ($(#[$meta:meta])* $name:ident) => {
14        $(#[$meta])*
15        #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
16        pub struct $name(
17            /// Raw numeric identifier returned by Zenodo.
18            pub u64
19        );
20
21        impl fmt::Display for $name {
22            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23                write!(f, "{}", self.0)
24            }
25        }
26
27        impl From<u64> for $name {
28            fn from(value: u64) -> Self {
29                Self(value)
30            }
31        }
32
33        impl Serialize for $name {
34            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
35            where
36                S: Serializer,
37            {
38                serializer.serialize_u64(self.0)
39            }
40        }
41
42        impl<'de> Deserialize<'de> for $name {
43            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
44            where
45                D: Deserializer<'de>,
46            {
47                deserialize_u64ish(deserializer).map(Self)
48            }
49        }
50    };
51}
52
53id_newtype!(
54    /// Identifier for a deposition draft or published deposition.
55    DepositionId
56);
57id_newtype!(
58    /// Identifier for a public record version.
59    RecordId
60);
61id_newtype!(
62    /// Identifier shared across all versions in a record family.
63    ConceptRecId
64);
65
66/// Identifier for a file attached to a deposition draft.
67#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
68#[serde(transparent)]
69pub struct DepositionFileId(
70    /// Raw file identifier returned by Zenodo.
71    pub String,
72);
73
74impl fmt::Display for DepositionFileId {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        self.0.fmt(f)
77    }
78}
79
80impl From<String> for DepositionFileId {
81    fn from(value: String) -> Self {
82        Self(value)
83    }
84}
85
86impl From<&str> for DepositionFileId {
87    fn from(value: &str) -> Self {
88        Self(value.to_owned())
89    }
90}
91
92/// DOI string wrapper used by record and deposition types.
93#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
94#[serde(transparent)]
95pub struct Doi(
96    /// Raw DOI value.
97    pub String,
98);
99
100/// Errors raised while parsing or validating DOI selectors.
101#[derive(Clone, Debug, PartialEq, Eq, Error)]
102pub enum DoiError {
103    /// The normalized DOI string was empty.
104    #[error("DOI cannot be empty")]
105    Empty,
106    /// The DOI did not match the expected `10.<registrant>/<suffix>` shape.
107    #[error("invalid DOI: {0}")]
108    Invalid(String),
109}
110
111impl Doi {
112    /// Creates a normalized DOI wrapper from a raw DOI-like input.
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// use zenodo_rs::Doi;
118    ///
119    /// let doi = Doi::new(" https://doi.org/10.5281/ZENODO.123 ")?;
120    /// assert_eq!(doi.as_str(), "10.5281/zenodo.123");
121    /// # Ok::<(), zenodo_rs::DoiError>(())
122    /// ```
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the normalized value does not resemble a DOI.
127    pub fn new(value: impl AsRef<str>) -> Result<Self, DoiError> {
128        let normalized = normalize_doi(value.as_ref());
129        validate_doi(&normalized)?;
130        Ok(Self(normalized))
131    }
132
133    /// Returns the raw DOI string.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use zenodo_rs::Doi;
139    ///
140    /// let doi = Doi::new("doi:10.5281/ZENODO.456")?;
141    /// assert_eq!(doi.as_str(), "10.5281/zenodo.456");
142    /// # Ok::<(), zenodo_rs::DoiError>(())
143    /// ```
144    #[must_use]
145    pub fn as_str(&self) -> &str {
146        &self.0
147    }
148}
149
150impl fmt::Display for Doi {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        self.0.fmt(f)
153    }
154}
155
156impl TryFrom<String> for Doi {
157    type Error = DoiError;
158
159    fn try_from(value: String) -> Result<Self, Self::Error> {
160        Self::new(value)
161    }
162}
163
164impl TryFrom<&str> for Doi {
165    type Error = DoiError;
166
167    fn try_from(value: &str) -> Result<Self, Self::Error> {
168        Self::new(value)
169    }
170}
171
172impl FromStr for Doi {
173    type Err = DoiError;
174
175    fn from_str(s: &str) -> Result<Self, Self::Err> {
176        Self::new(s)
177    }
178}
179
180impl<'de> Deserialize<'de> for Doi {
181    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182    where
183        D: Deserializer<'de>,
184    {
185        let value = String::deserialize(deserializer)?;
186        Self::new(value).map_err(serde::de::Error::custom)
187    }
188}
189
190/// Bucket upload URL returned by Zenodo for draft file uploads.
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(transparent)]
193pub struct BucketUrl(
194    /// Raw bucket URL.
195    pub Url,
196);
197
198impl From<Url> for BucketUrl {
199    fn from(value: Url) -> Self {
200        Self(value)
201    }
202}
203
204impl AsRef<Url> for BucketUrl {
205    fn as_ref(&self) -> &Url {
206        &self.0
207    }
208}
209
210fn normalize_doi(value: &str) -> String {
211    let trimmed = value.trim();
212    let without_prefix = trim_doi_prefix(trimmed);
213    without_prefix.trim().to_ascii_lowercase()
214}
215
216fn trim_doi_prefix(value: &str) -> &str {
217    const PREFIXES: [&str; 4] = [
218        "doi:",
219        "https://doi.org/",
220        "http://doi.org/",
221        "https://dx.doi.org/",
222    ];
223
224    for prefix in PREFIXES {
225        if value.len() >= prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) {
226            return &value[prefix.len()..];
227        }
228    }
229
230    value
231}
232
233fn validate_doi(value: &str) -> Result<(), DoiError> {
234    if value.is_empty() {
235        return Err(DoiError::Empty);
236    }
237
238    let Some((registrant, suffix)) = value.split_once('/') else {
239        return Err(DoiError::Invalid(value.to_owned()));
240    };
241
242    if registrant.len() <= 3 || !registrant.starts_with("10.") || suffix.is_empty() {
243        return Err(DoiError::Invalid(value.to_owned()));
244    }
245
246    Ok(())
247}
248
249#[cfg(test)]
250mod tests {
251    use super::{BucketUrl, ConceptRecId, DepositionFileId, DepositionId, Doi, DoiError, RecordId};
252
253    #[test]
254    fn numeric_ids_deserialize_from_strings_and_numbers() {
255        let deposition: DepositionId = serde_json::from_str("\"12\"").unwrap();
256        let record: RecordId = serde_json::from_str("13").unwrap();
257        let concept: ConceptRecId = serde_json::from_str("\"14\"").unwrap();
258        let float_record: RecordId = serde_json::from_str("15.0").unwrap();
259
260        assert_eq!(deposition.0, 12);
261        assert_eq!(record.0, 13);
262        assert_eq!(concept.0, 14);
263        assert_eq!(float_record.0, 15);
264    }
265
266    #[test]
267    fn doi_round_trips_through_display_and_parse() {
268        let doi: Doi = "10.5281/zenodo.123".parse().unwrap();
269        assert_eq!(doi.as_str(), "10.5281/zenodo.123");
270        assert_eq!(doi.to_string(), "10.5281/zenodo.123");
271    }
272
273    #[test]
274    fn doi_normalization_trims_prefixes_and_case() {
275        assert_eq!(
276            Doi::new("  HTTPS://DOI.ORG/10.5281/ZENODO.123  ")
277                .unwrap()
278                .as_str(),
279            "10.5281/zenodo.123"
280        );
281        assert_eq!(
282            Doi::new("doi:10.5281/ZENODO.456").unwrap().as_str(),
283            "10.5281/zenodo.456"
284        );
285        assert_eq!(
286            Doi::new("https://dx.doi.org/10.5281/ZENODO.789")
287                .unwrap()
288                .as_str(),
289            "10.5281/zenodo.789"
290        );
291    }
292
293    #[test]
294    fn doi_deserialization_normalizes_values() {
295        let doi: Doi = serde_json::from_str("\"HTTPS://DOI.ORG/10.5281/ZENODO.999\"").unwrap();
296        assert_eq!(doi.as_str(), "10.5281/zenodo.999");
297    }
298
299    #[test]
300    fn bucket_url_wraps_url() {
301        let url = url::Url::parse("https://zenodo.org/api/files/abc").unwrap();
302        let bucket = BucketUrl::from(url.clone());
303        assert_eq!(bucket.as_ref(), &url);
304    }
305
306    #[test]
307    fn string_wrappers_support_common_conversions() {
308        let file_id = DepositionFileId::from("abc");
309        let doi = Doi::try_from(String::from("10.5281/zenodo.456")).unwrap();
310        let borrowed_doi = Doi::try_from("10.5281/zenodo.789").unwrap();
311        let deposition = DepositionId::from(5_u64);
312        let serialized = serde_json::to_string(&deposition).unwrap();
313        let owned_file_id = DepositionFileId::from(String::from("xyz"));
314
315        assert_eq!(file_id.to_string(), "abc");
316        assert_eq!(doi.to_string(), "10.5281/zenodo.456");
317        assert_eq!(borrowed_doi.to_string(), "10.5281/zenodo.789");
318        assert_eq!(owned_file_id.to_string(), "xyz");
319        assert_eq!(serialized, "5");
320    }
321
322    #[test]
323    fn doi_validation_rejects_empty_or_invalid_values() {
324        assert_eq!(Doi::new("  ").unwrap_err(), DoiError::Empty);
325        assert!(matches!(
326            Doi::new("zenodo.123").unwrap_err(),
327            DoiError::Invalid(value) if value == "zenodo.123"
328        ));
329        assert!(matches!(
330            Doi::new("10.5281/").unwrap_err(),
331            DoiError::Invalid(value) if value == "10.5281/"
332        ));
333    }
334}