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