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, Field, FieldSet, HttpClient, HttpRequest, HttpResponse, IdKind,
21 Lookup, 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.final_misses = vec![IdKind::Doi];
61 capabilities.fields = FieldSet::ALL.difference(FieldSet::of(&[Field::OpenAccess]));
63 DoiOrg {
64 config,
65 http,
66 capabilities,
67 }
68 }
69
70 pub fn default_policy() -> ResiliencePolicy {
72 let mut policy = ResiliencePolicy::default();
73 policy.min_interval = Duration::from_millis(200);
74 policy
75 }
76
77 async fn fetch(&self, doi: &Doi) -> Result<Option<SourceRecord>> {
78 let url = format!(
79 "{}/{}",
80 self.config.base_url.trim_end_matches('/'),
81 path_segment(doi.as_str())
82 );
83 let resp = self
84 .http
85 .send(HttpRequest::get(url).header("accept", CSL_JSON))
86 .await?;
87 match check(&resp) {
88 Ok(()) => {}
89 Err(Error::NotFound) => return Ok(None),
90 Err(e) => return Err(e),
91 }
92 let value: serde_json::Value = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
93 let item: CslItem = serde_json::from_value(value.clone()).map_err(Error::decode)?;
94 let raw = Raw {
95 media_type: CSL_JSON.into(),
96 body: resp.text(),
97 };
98 Ok(Some(SourceRecord::new(
99 ID,
100 ids(doi, &item),
101 item.to_work(),
102 FieldSet::ALL,
103 raw,
104 resp.received_at,
105 )))
106 }
107}
108
109fn ids(asked: &Doi, item: &CslItem) -> Vec<WorkId> {
112 let doi = item
113 .doi
114 .as_deref()
115 .and_then(|d| d.parse::<Doi>().ok())
116 .unwrap_or_else(|| asked.clone());
117 let arxiv = (doi.prefix() == ARXIV_DOI_PREFIX)
118 .then(|| doi.suffix().strip_prefix("arxiv.")?.parse().ok())
119 .flatten()
120 .map(WorkId::Arxiv);
121 std::iter::once(WorkId::Doi(doi)).chain(arxiv).collect()
122}
123
124fn check(resp: &HttpResponse) -> Result<()> {
125 if resp.is_success() {
126 return Ok(());
127 }
128 Err(match resp.status {
129 404 => Error::NotFound,
130 406 => Error::Unsupported("CSL-JSON from this DOI's registration agency"),
132 429 => Error::RateLimited {
133 retry_after: resp
134 .header("retry-after")
135 .and_then(|v| v.trim().parse().ok())
136 .map(Duration::from_secs),
137 },
138 status => Error::Server {
139 status,
140 message: None,
141 },
142 })
143}
144
145#[async_trait]
146impl aspero_core::Provider for DoiOrg {
147 fn id(&self) -> ProviderId {
148 ID
149 }
150
151 fn capabilities(&self) -> &Capabilities {
152 &self.capabilities
153 }
154
155 async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
156 let mut lookups = Vec::with_capacity(ids.len());
157 for id in ids {
158 let doi = match id {
159 WorkId::Doi(doi) => doi.clone(),
160 WorkId::Arxiv(arxiv) => {
161 format!("{ARXIV_DOI_PREFIX}/arxiv.{}", arxiv.id()).parse()?
162 }
163 _ => return Err(Error::Unsupported("doi.org lookup of non-DOI identifiers")),
164 };
165 lookups.push(Lookup {
166 id: id.clone(),
167 record: self.fetch(&doi).await?,
168 });
169 }
170 Ok(Response::new(lookups))
171 }
172}