Skip to main content

aspero_doi/
lib.rs

1//! doi.org content-negotiation provider.
2//!
3//! Asks the DOI system itself for CSL-JSON (`Accept:
4//! application/vnd.citationstyles.csl+json`); doi.org redirects to the DOI's
5//! registration agency — Crossref, DataCite, mEDRA, JaLC, … — which answers.
6//! So it covers every registered DOI, not just one agency's, and its 404 is
7//! the authoritative "this DOI does not exist".
8//!
9//! One DOI per request (no batching), so it sits after the batch-capable
10//! providers as a fallback. Observed 2026-11-10: Crossref-registered DOIs
11//! answer with Crossref's own type names and a 10 req/s limit header;
12//! DataCite's answers (e.g. arXiv) carry abstracts.
13
14use 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
31/// arXiv's DataCite prefix: `10.48550/arXiv.<id>`.
32const 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    /// Agencies advertise about 10 req/s; stay well under.
68    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
106/// The record's identifiers: its DOI as served (else as asked), plus the
107/// arXiv ID behind an arXiv DataCite DOI.
108fn 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        // The DOI exists, but its agency offers no CSL-JSON.
128        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}