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![Filter::Years, Filter::WorkType, Filter::OpenAccess];
66 OpenAlex {
67 config,
68 http,
69 capabilities,
70 }
71 }
72
73 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 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 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
135struct Key {
137 filter: &'static str,
139 value: String,
140 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 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 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 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(¶ms).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(¶ms).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
334fn 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}