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