Skip to main content

aspero_openalex/
lib.rs

1//! OpenAlex provider.
2//!
3//! Costs (help.openalex.org, 2026-11): single-work lookups are free, list and
4//! filter calls $0.0001, searches $0.001, from a free $1/day with an API key.
5//! So lookups of one ID use the free singleton endpoint, batches use one
6//! `filter` call per identifier kind, and search is the only real expense.
7//!
8//! Every call reports its cost and remaining budget in [`Usage`] (USD).
9
10mod http;
11mod wire;
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use aspero_core::http::path_segment;
17use aspero_core::{
18    Capabilities, Cursor, Doi, Error, FieldSet, Filter, Hit, HttpClient, HttpRequest, HttpResponse,
19    IdKind, Lookup, Page, PageReq, Provider, ProviderId, Raw, ResiliencePolicy, Response, Result,
20    SearchMode, SearchQuery, SourceRecord, Usage, WorkId, WorkType,
21};
22use async_trait::async_trait;
23use secrecy::{ExposeSecret, SecretString};
24
25use crate::wire::{ARXIV_DOI_PREFIX, WireList};
26
27pub const ID: ProviderId = ProviderId::from_static("openalex");
28
29pub const DEFAULT_BASE_URL: &str = "https://api.openalex.org";
30
31/// OpenAlex's cap on `per-page` and on values OR-ed in one filter.
32const MAX_PAGE: u32 = 100;
33
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub struct OpenAlexConfig {
37    /// Without a key OpenAlex allows $0.10/day; with a free key, $1/day.
38    pub api_key: Option<SecretString>,
39    pub base_url: String,
40}
41
42impl Default for OpenAlexConfig {
43    fn default() -> Self {
44        OpenAlexConfig {
45            api_key: None,
46            base_url: DEFAULT_BASE_URL.to_owned(),
47        }
48    }
49}
50
51pub struct OpenAlex {
52    config: OpenAlexConfig,
53    http: Arc<dyn HttpClient>,
54    capabilities: Capabilities,
55}
56
57impl OpenAlex {
58    pub fn new(config: OpenAlexConfig, http: Arc<dyn HttpClient>) -> Self {
59        let mut capabilities = Capabilities::default();
60        capabilities.lookup_kinds =
61            vec![IdKind::Doi, IdKind::Arxiv, IdKind::Pmid, IdKind::OpenAlex];
62        capabilities.max_batch = MAX_PAGE as usize;
63        capabilities.fields = FieldSet::ALL;
64        capabilities.search = true;
65        capabilities.search_filters = vec![
66            Filter::Years,
67            Filter::WorkType,
68            Filter::OpenAccess,
69            Filter::Place,
70        ];
71        OpenAlex {
72            config,
73            http,
74            capabilities,
75        }
76    }
77
78    /// OpenAlex allows 30 req/s with a key; aspero stays well under.
79    pub fn default_policy() -> ResiliencePolicy {
80        let mut policy = ResiliencePolicy::default();
81        policy.min_interval = Duration::from_millis(100);
82        policy
83    }
84
85    fn request(&self, url: String) -> HttpRequest {
86        let req = HttpRequest::get(url).header("accept", "application/json");
87        match &self.config.api_key {
88            Some(key) => req.header("authorization", format!("Bearer {}", key.expose_secret())),
89            None => req,
90        }
91    }
92
93    fn works_url(&self, rest: &str) -> String {
94        format!("{}/works{rest}", self.config.base_url.trim_end_matches('/'))
95    }
96
97    /// Sends and maps non-2xx statuses to errors, keeping usage either way.
98    async fn send(&self, url: String) -> Result<(HttpResponse, Usage)> {
99        let resp = self.http.send(self.request(url)).await?;
100        http::check(&resp)?;
101        let usage = http::usage(&resp);
102        Ok((resp, usage))
103    }
104
105    async fn lookup_one(&self, key: &Key) -> Result<(Option<SourceRecord>, Option<Usage>)> {
106        let segment = path_segment(&key.path_segment());
107        let (resp, usage) = match self.send(self.works_url(&format!("/{segment}"))).await {
108            Ok(ok) => ok,
109            Err(Error::NotFound) => return Ok((None, None)),
110            Err(e) => return Err(e),
111        };
112        let value: serde_json::Value = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
113        let raw = Raw {
114            media_type: "application/json".into(),
115            body: resp.text(),
116        };
117        let record = wire::to_record(&value, raw, resp.received_at).map_err(Error::decode)?;
118        Ok((Some(record), Some(usage)))
119    }
120
121    /// One list call; `params` are already-decoded query pairs.
122    async fn list(&self, params: &[(&str, String)]) -> Result<(Vec<Hit>, WireList, Usage)> {
123        let url = url::Url::parse_with_params(&self.works_url(""), params)
124            .map_err(|e| Error::BadRequest(format!("invalid OpenAlex base URL: {e}")))?;
125        let (resp, usage) = self.send(url.into()).await?;
126        let list: WireList = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
127        let hits = list
128            .results
129            .iter()
130            .map(|v| {
131                let score = v.get("relevance_score").and_then(serde_json::Value::as_f64);
132                wire::to_record(v, Raw::json(v), resp.received_at).map(|r| Hit::new(r, score))
133            })
134            .collect::<serde_json::Result<_>>()
135            .map_err(Error::decode)?;
136        Ok((hits, list, usage))
137    }
138}
139
140/// How a requested ID is addressed at OpenAlex.
141struct Key {
142    /// Filter name, also the singleton path prefix.
143    filter: &'static str,
144    value: String,
145    /// The ID a returned record must carry to answer this request.
146    matches: WorkId,
147}
148
149impl Key {
150    fn new(id: &WorkId) -> Result<Key> {
151        Ok(match id {
152            WorkId::Doi(doi) => Key::doi(doi.clone()),
153            // arXiv works are indexed under their DataCite DOI.
154            WorkId::Arxiv(arxiv) => {
155                let doi = format!("{ARXIV_DOI_PREFIX}/arxiv.{}", arxiv.id());
156                Key::doi(doi.parse::<Doi>()?)
157            }
158            WorkId::Pmid(pmid) => Key {
159                filter: "pmid",
160                value: pmid.to_string(),
161                matches: id.clone(),
162            },
163            WorkId::OpenAlex(w) => Key {
164                filter: "openalex",
165                value: w.to_string(),
166                matches: id.clone(),
167            },
168            _ => {
169                return Err(Error::Unsupported(
170                    "OpenAlex lookup of this identifier kind",
171                ));
172            }
173        })
174    }
175
176    fn doi(doi: Doi) -> Key {
177        Key {
178            filter: "doi",
179            value: doi.as_str().to_owned(),
180            matches: WorkId::Doi(doi),
181        }
182    }
183
184    fn path_segment(&self) -> String {
185        match self.filter {
186            "openalex" => self.value.clone(),
187            filter => format!("{filter}:{}", self.value),
188        }
189    }
190
191    /// `,` and `|` are filter syntax, so such values can't be OR-ed.
192    fn batchable(&self) -> bool {
193        !self.value.contains([',', '|'])
194    }
195}
196
197fn type_filter(t: &WorkType) -> Result<String> {
198    let openalex = match t {
199        WorkType::JournalArticle => "article",
200        WorkType::ConferencePaper => "conference-paper",
201        WorkType::Preprint => "preprint",
202        WorkType::Book => "book",
203        WorkType::Chapter => "book-chapter",
204        WorkType::Thesis => "dissertation",
205        WorkType::Report => "report",
206        WorkType::Dataset => "dataset",
207        WorkType::Other(other) => other.as_str(),
208        _ => {
209            return Err(Error::BadRequest(format!(
210                "OpenAlex has no work type matching {:?}",
211                t.as_csl()
212            )));
213        }
214    };
215    Ok(format!("type:{openalex}"))
216}
217
218fn respond<T>(value: T, usage: Option<Usage>) -> Response<T> {
219    match usage {
220        Some(u) => Response::with_usage(value, u),
221        None => Response::new(value),
222    }
223}
224
225#[async_trait]
226impl Provider for OpenAlex {
227    fn id(&self) -> ProviderId {
228        ID
229    }
230
231    fn capabilities(&self) -> &Capabilities {
232        &self.capabilities
233    }
234
235    async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
236        let keys = ids.iter().map(Key::new).collect::<Result<Vec<_>>>()?;
237        let mut usage = None;
238
239        if let [key] = keys.as_slice() {
240            let (record, u) = self.lookup_one(key).await?;
241            let lookup = Lookup {
242                id: ids[0].clone(),
243                record,
244            };
245            return Ok(respond(vec![lookup], u));
246        }
247
248        // One filter call per kind, in order of first appearance.
249        let mut groups: Vec<(&str, Vec<&Key>)> = Vec::new();
250        let mut singles = Vec::new();
251        for key in &keys {
252            if !key.batchable() {
253                singles.push(key);
254            } else if let Some((_, group)) = groups.iter_mut().find(|(f, _)| *f == key.filter) {
255                group.push(key);
256            } else {
257                groups.push((key.filter, vec![key]));
258            }
259        }
260
261        let mut found = Vec::new();
262        for (filter, group) in groups {
263            for chunk in group.chunks(MAX_PAGE as usize) {
264                let values: Vec<&str> = chunk.iter().map(|k| k.value.as_str()).collect();
265                let params = [
266                    ("filter", format!("{filter}:{}", values.join("|"))),
267                    ("per-page", MAX_PAGE.to_string()),
268                ];
269                let (hits, _, u) = self.list(&params).await?;
270                found.extend(hits.into_iter().map(|h| h.record));
271                usage = Some(usage.map_or(u, |acc: Usage| acc.then(u)));
272            }
273        }
274        for key in singles {
275            let (record, u) = self.lookup_one(key).await?;
276            found.extend(record);
277            if let Some(u) = u {
278                usage = Some(usage.map_or(u, |acc: Usage| acc.then(u)));
279            }
280        }
281
282        let lookups = ids
283            .iter()
284            .zip(&keys)
285            .map(|(id, key)| Lookup {
286                id: id.clone(),
287                record: found.iter().find(|r| r.ids.contains(&key.matches)).cloned(),
288            })
289            .collect();
290        Ok(respond(lookups, usage))
291    }
292
293    async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
294        if query.mode != SearchMode::Keywords {
295            return Err(Error::Unsupported("OpenAlex has no citation matcher"));
296        }
297        let mut filters = Vec::new();
298        if let Some(years) = query.years {
299            if let Some(from) = years.from {
300                filters.push(format!("from_publication_date:{from:04}-01-01"));
301            }
302            if let Some(to) = years.to {
303                filters.push(format!("to_publication_date:{to:04}-12-31"));
304            }
305        }
306        if let Some(t) = &query.work_type {
307            filters.push(type_filter(t)?);
308        }
309        if let Some(oa) = query.open_access {
310            filters.push(format!("is_oa:{oa}"));
311        }
312        if let Some(place) = &query.place {
313            // Filter values can't hold commas or pipes, which separate them.
314            let clean = |s: &str| s.replace([',', '|'], "");
315            filters.push(format!("biblio.volume:{}", clean(&place.volume)));
316            filters.push(format!("biblio.first_page:{}", clean(&place.first_page)));
317        }
318
319        let mut params: Vec<(&str, String)> = Vec::new();
320        for (key, value) in &query.extra {
321            match key.as_str() {
322                "filter" => filters.push(value.clone()),
323                "sort" => params.push(("sort", value.clone())),
324                other => {
325                    return Err(Error::BadRequest(format!(
326                        "unknown OpenAlex parameter {other:?} (supported: filter, sort)"
327                    )));
328                }
329            }
330        }
331        if !query.text.trim().is_empty() {
332            params.push(("search", query.text.trim().to_owned()));
333        }
334        if !filters.is_empty() {
335            params.push(("filter", filters.join(",")));
336        }
337        params.extend(page_params(&query.page));
338
339        let (hits, list, usage) = self.list(&params).await?;
340        let page = Page::new(hits, list.meta.count, list.meta.next_cursor.map(Cursor));
341        Ok(Response::with_usage(page, usage))
342    }
343}
344
345/// Cursor paging (needs a key; unlimited depth, unlike `page=`).
346fn page_params(page: &PageReq) -> [(&'static str, String); 2] {
347    let cursor = page.cursor.as_ref().map_or("*", |c| c.0.as_str());
348    [
349        ("per-page", page.limit.clamp(1, MAX_PAGE).to_string()),
350        ("cursor", cursor.to_owned()),
351    ]
352}