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, 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
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        // Records say nothing about free copies.
60        capabilities.fields = FieldSet::ALL.difference(FieldSet::of(&[Field::OpenAccess]));
61        DoiOrg {
62            config,
63            http,
64            capabilities,
65        }
66    }
67
68    /// Agencies advertise about 10 req/s; stay well under.
69    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
107/// The record's identifiers: its DOI as served (else as asked), plus the
108/// arXiv ID behind an arXiv DataCite DOI.
109fn 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        // The DOI exists, but its agency offers no CSL-JSON.
129        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}