1use std::sync::Arc;
15use std::time::Duration;
16
17use aspero_core::csl::CslItem;
18use aspero_core::http::path_segment;
19use aspero_core::{
20 Capabilities, Doi, Error, FieldSet, HttpClient, HttpRequest, HttpResponse, IdKind, Lookup,
21 ProviderId, Raw, ResiliencePolicy, Response, Result, SourceRecord, WorkId,
22};
23use async_trait::async_trait;
24
25pub const ID: ProviderId = ProviderId::from_static("doi");
26
27pub const DEFAULT_BASE_URL: &str = "https://doi.org";
28
29const CSL_JSON: &str = "application/vnd.citationstyles.csl+json";
30
31const ARXIV_DOI_PREFIX: &str = "10.48550";
33
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub struct DoiConfig {
37 pub base_url: String,
38}
39
40impl Default for DoiConfig {
41 fn default() -> Self {
42 DoiConfig {
43 base_url: DEFAULT_BASE_URL.to_owned(),
44 }
45 }
46}
47
48pub struct DoiOrg {
49 config: DoiConfig,
50 http: Arc<dyn HttpClient>,
51 capabilities: Capabilities,
52}
53
54impl DoiOrg {
55 pub fn new(config: DoiConfig, http: Arc<dyn HttpClient>) -> Self {
56 let mut capabilities = Capabilities::default();
57 capabilities.lookup_kinds = vec![IdKind::Doi, IdKind::Arxiv];
58 capabilities.max_batch = 1;
59 capabilities.fields = FieldSet::ALL;
60 DoiOrg {
61 config,
62 http,
63 capabilities,
64 }
65 }
66
67 pub fn default_policy() -> ResiliencePolicy {
69 let mut policy = ResiliencePolicy::default();
70 policy.min_interval = Duration::from_millis(200);
71 policy
72 }
73
74 async fn fetch(&self, doi: &Doi) -> Result<Option<SourceRecord>> {
75 let url = format!(
76 "{}/{}",
77 self.config.base_url.trim_end_matches('/'),
78 path_segment(doi.as_str())
79 );
80 let resp = self
81 .http
82 .send(HttpRequest::get(url).header("accept", CSL_JSON))
83 .await?;
84 match check(&resp) {
85 Ok(()) => {}
86 Err(Error::NotFound) => return Ok(None),
87 Err(e) => return Err(e),
88 }
89 let value: serde_json::Value = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
90 let item: CslItem = serde_json::from_value(value.clone()).map_err(Error::decode)?;
91 let raw = Raw {
92 media_type: CSL_JSON.into(),
93 body: resp.text(),
94 };
95 Ok(Some(SourceRecord::new(
96 ID,
97 ids(doi, &item),
98 item.to_work(),
99 FieldSet::ALL,
100 raw,
101 resp.received_at,
102 )))
103 }
104}
105
106fn ids(asked: &Doi, item: &CslItem) -> Vec<WorkId> {
109 let doi = item
110 .doi
111 .as_deref()
112 .and_then(|d| d.parse::<Doi>().ok())
113 .unwrap_or_else(|| asked.clone());
114 let arxiv = (doi.prefix() == ARXIV_DOI_PREFIX)
115 .then(|| doi.suffix().strip_prefix("arxiv.")?.parse().ok())
116 .flatten()
117 .map(WorkId::Arxiv);
118 std::iter::once(WorkId::Doi(doi)).chain(arxiv).collect()
119}
120
121fn check(resp: &HttpResponse) -> Result<()> {
122 if resp.is_success() {
123 return Ok(());
124 }
125 Err(match resp.status {
126 404 => Error::NotFound,
127 406 => Error::Unsupported("CSL-JSON from this DOI's registration agency"),
129 429 => Error::RateLimited {
130 retry_after: resp
131 .header("retry-after")
132 .and_then(|v| v.trim().parse().ok())
133 .map(Duration::from_secs),
134 },
135 status => Error::Server {
136 status,
137 message: None,
138 },
139 })
140}
141
142#[async_trait]
143impl aspero_core::Provider for DoiOrg {
144 fn id(&self) -> ProviderId {
145 ID
146 }
147
148 fn capabilities(&self) -> &Capabilities {
149 &self.capabilities
150 }
151
152 async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
153 let mut lookups = Vec::with_capacity(ids.len());
154 for id in ids {
155 let doi = match id {
156 WorkId::Doi(doi) => doi.clone(),
157 WorkId::Arxiv(arxiv) => {
158 format!("{ARXIV_DOI_PREFIX}/arxiv.{}", arxiv.id()).parse()?
159 }
160 _ => return Err(Error::Unsupported("doi.org lookup of non-DOI identifiers")),
161 };
162 lookups.push(Lookup {
163 id: id.clone(),
164 record: self.fetch(&doi).await?,
165 });
166 }
167 Ok(Response::new(lookups))
168 }
169}