1mod 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
31const MAX_PAGE: u32 = 100;
33
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub struct OpenAlexConfig {
37 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 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 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 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
140struct Key {
142 filter: &'static str,
144 value: String,
145 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 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 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 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(¶ms).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 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(¶ms).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
345fn 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}