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