1use 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 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 DepositionId
56);
57id_newtype!(
58 RecordId
60);
61id_newtype!(
62 ConceptRecId
64);
65
66#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
68#[serde(transparent)]
69pub struct DepositionFileId(
70 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
94#[serde(transparent)]
95pub struct Doi(
96 pub String,
98);
99
100#[derive(Clone, Debug, PartialEq, Eq, Error)]
102pub enum DoiError {
103 #[error("DOI cannot be empty")]
105 Empty,
106 #[error("invalid DOI: {0}")]
108 Invalid(String),
109}
110
111impl Doi {
112 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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(transparent)]
193pub struct BucketUrl(
194 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}