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)> {
100 let resp = self.http.send(self.request(url)).await?;
101 http::check(&resp)?;
102 let usage = http::usage(&resp);
103 Ok((resp, usage))
104 }
105
106 async fn lookup_one(&self, key: &Key) -> Result<(Option<SourceRecord>, Option<Usage>)> {
107 let segment = path_segment(&key.path_segment());
108 let (resp, usage) = match self.send(self.works_url(&format!("/{segment}"))).await {
109 Ok(ok) => ok,
110 Err(Error::NotFound) => return Ok((None, None)),
111 Err(e) => return Err(e),
112 };
113 let value: serde_json::Value = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
114 let raw = Raw {
115 media_type: "application/json".into(),
116 body: resp.text(),
117 };
118 let record = wire::to_record(&value, raw, resp.received_at).map_err(Error::decode)?;
119 Ok((Some(record), Some(usage)))
120 }
121
122 async fn list(&self, params: &[(&str, String)]) -> Result<(Vec<Hit>, WireList, Usage)> {
124 let url = url::Url::parse_with_params(&self.works_url(""), params)
125 .map_err(|e| Error::BadRequest(format!("invalid OpenAlex base URL: {e}")))?;
126 let (resp, usage) = self.send(url.into()).await?;
127 let list: WireList = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
128 let hits = list
129 .results
130 .iter()
131 .filter_map(|v| {
132 let score = v.get("relevance_score").and_then(serde_json::Value::as_f64);
133 let record = wire::to_record(v, Raw::json(v), resp.received_at);
134 or_skip(record).map(|r| Hit::new(r, score))
135 })
136 .collect();
137 Ok((hits, list, usage))
138 }
139}
140
141fn or_skip(record: serde_json::Result<SourceRecord>) -> Option<SourceRecord> {
144 record
145 .inspect_err(|e| {
146 tracing::warn!(
147 target: "aspero::decode",
148 provider = %ID, error = %e, "skipped a record that doesn't decode"
149 );
150 })
151 .ok()
152}
153
154struct Key {
156 filter: &'static str,
158 value: String,
159 matches: WorkId,
161}
162
163impl Key {
164 fn new(id: &WorkId) -> Result<Key> {
165 Ok(match id {
166 WorkId::Doi(doi) => Key::doi(doi.clone()),
167 WorkId::Arxiv(arxiv) => {
169 let doi = format!("{ARXIV_DOI_PREFIX}/arxiv.{}", arxiv.id());
170 Key::doi(doi.parse::<Doi>()?)
171 }
172 WorkId::Pmid(pmid) => Key {
173 filter: "pmid",
174 value: pmid.to_string(),
175 matches: id.clone(),
176 },
177 WorkId::OpenAlex(w) => Key {
178 filter: "openalex",
179 value: w.to_string(),
180 matches: id.clone(),
181 },
182 _ => {
183 return Err(Error::Unsupported(
184 "OpenAlex lookup of this identifier kind",
185 ));
186 }
187 })
188 }
189
190 fn doi(doi: Doi) -> Key {
191 Key {
192 filter: "doi",
193 value: doi.as_str().to_owned(),
194 matches: WorkId::Doi(doi),
195 }
196 }
197
198 fn path_segment(&self) -> String {
199 match self.filter {
200 "openalex" => self.value.clone(),
201 filter => format!("{filter}:{}", self.value),
202 }
203 }
204
205 fn batchable(&self) -> bool {
207 !self.value.contains([',', '|'])
208 }
209}
210
211fn type_filter(t: &WorkType) -> Result<String> {
212 let openalex = match t {
213 WorkType::JournalArticle => "article",
214 WorkType::ConferencePaper => "conference-paper",
215 WorkType::Preprint => "preprint",
216 WorkType::Book => "book",
217 WorkType::Chapter => "book-chapter",
218 WorkType::Thesis => "dissertation",
219 WorkType::Report => "report",
220 WorkType::Dataset => "dataset",
221 WorkType::Other(other) => other.as_str(),
222 _ => {
223 return Err(Error::BadRequest(format!(
224 "OpenAlex has no work type matching {:?}",
225 t.as_csl()
226 )));
227 }
228 };
229 Ok(format!("type:{openalex}"))
230}
231
232fn respond<T>(value: T, usage: Option<Usage>) -> Response<T> {
233 match usage {
234 Some(u) => Response::with_usage(value, u),
235 None => Response::new(value),
236 }
237}
238
239#[async_trait]
240impl Provider for OpenAlex {
241 fn id(&self) -> ProviderId {
242 ID
243 }
244
245 fn capabilities(&self) -> &Capabilities {
246 &self.capabilities
247 }
248
249 async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
250 let keys = ids.iter().map(Key::new).collect::<Result<Vec<_>>>()?;
251 let mut usage = None;
252
253 if let [key] = keys.as_slice() {
254 let (record, u) = self.lookup_one(key).await?;
255 let lookup = Lookup {
256 id: ids[0].clone(),
257 record,
258 };
259 return Ok(respond(vec![lookup], u));
260 }
261
262 let mut groups: Vec<(&str, Vec<&Key>)> = Vec::new();
264 let mut singles = Vec::new();
265 for key in &keys {
266 if !key.batchable() {
267 singles.push(key);
268 } else if let Some((_, group)) = groups.iter_mut().find(|(f, _)| *f == key.filter) {
269 group.push(key);
270 } else {
271 groups.push((key.filter, vec![key]));
272 }
273 }
274
275 let mut found = Vec::new();
276 for (filter, group) in groups {
277 for chunk in group.chunks(MAX_PAGE as usize) {
278 let values: Vec<&str> = chunk.iter().map(|k| k.value.as_str()).collect();
279 let params = [
280 ("filter", format!("{filter}:{}", values.join("|"))),
281 ("per-page", MAX_PAGE.to_string()),
282 ];
283 let (hits, _, u) = self.list(¶ms).await?;
284 found.extend(hits.into_iter().map(|h| h.record));
285 usage = Some(usage.map_or(u, |acc: Usage| acc.then(u)));
286 }
287 }
288 for key in singles {
289 let (record, u) = self.lookup_one(key).await?;
290 found.extend(record);
291 if let Some(u) = u {
292 usage = Some(usage.map_or(u, |acc: Usage| acc.then(u)));
293 }
294 }
295
296 let lookups = ids
297 .iter()
298 .zip(&keys)
299 .map(|(id, key)| Lookup {
300 id: id.clone(),
301 record: found.iter().find(|r| r.ids.contains(&key.matches)).cloned(),
302 })
303 .collect();
304 Ok(respond(lookups, usage))
305 }
306
307 async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
308 if query.mode != SearchMode::Keywords {
309 return Err(Error::Unsupported("OpenAlex has no citation matcher"));
310 }
311 let mut filters = Vec::new();
312 if let Some(years) = query.years {
313 if let Some(from) = years.from {
314 filters.push(format!("from_publication_date:{from:04}-01-01"));
315 }
316 if let Some(to) = years.to {
317 filters.push(format!("to_publication_date:{to:04}-12-31"));
318 }
319 }
320 if let Some(t) = &query.work_type {
321 filters.push(type_filter(t)?);
322 }
323 if let Some(oa) = query.open_access {
324 filters.push(format!("is_oa:{oa}"));
325 }
326 if let Some(place) = &query.place {
327 let clean = |s: &str| s.replace([',', '|'], "");
329 filters.push(format!("biblio.volume:{}", clean(&place.volume)));
330 filters.push(format!("biblio.first_page:{}", clean(&place.first_page)));
331 }
332
333 let mut params: Vec<(&str, String)> = Vec::new();
334 for (key, value) in &query.extra {
335 match key.as_str() {
336 "filter" => filters.push(value.clone()),
337 "sort" => params.push(("sort", value.clone())),
338 other => {
339 return Err(Error::BadRequest(format!(
340 "unknown OpenAlex parameter {other:?} (supported: filter, sort)"
341 )));
342 }
343 }
344 }
345 if !query.text.trim().is_empty() {
346 params.push(("search", query.text.trim().to_owned()));
347 }
348 if !filters.is_empty() {
349 params.push(("filter", filters.join(",")));
350 }
351 params.extend(page_params(&query.page));
352
353 let (hits, list, usage) = self.list(¶ms).await?;
354 let page = Page::new(hits, list.meta.count, list.meta.next_cursor.map(Cursor));
355 Ok(Response::with_usage(page, usage))
356 }
357}
358
359fn page_params(page: &PageReq) -> [(&'static str, String); 2] {
361 let cursor = page.cursor.as_ref().map_or("*", |c| c.0.as_str());
362 [
363 ("per-page", page.limit.clamp(1, MAX_PAGE).to_string()),
364 ("cursor", cursor.to_owned()),
365 ]
366}