Skip to main content

research_agent/adapters/
openalex_source.rs

1use async_trait::async_trait;
2use serde::Deserialize;
3
4use crate::adapters::semantic_scholar_source::percent_encode;
5use crate::domain::paper::Paper;
6use crate::error::{ResearchError, Result};
7use crate::ports::paper_source::PaperSource;
8
9/// OpenAlex (api.openalex.org) — 250M+ works with free, keyless metadata.
10/// Modeled on `SemanticScholarSource`: reqwest + serde DTOs, explicit non-2xx
11/// checks, and offline tests over a canned JSON fixture.
12pub struct OpenAlexSource {
13    client: reqwest::Client,
14}
15
16impl OpenAlexSource {
17    pub fn new() -> Self {
18        Self {
19            client: reqwest::Client::new(),
20        }
21    }
22}
23
24impl Default for OpenAlexSource {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30/// Rebuild the abstract from OpenAlex's inverted index: word → list of
31/// positions in the abstract. Positions may arrive unordered.
32fn reconstruct_abstract(inverted: &std::collections::HashMap<String, Vec<usize>>) -> String {
33    let mut words: Vec<(usize, &str)> = inverted
34        .iter()
35        .flat_map(|(word, positions)| positions.iter().map(move |&pos| (pos, word.as_str())))
36        .collect();
37    words.sort_unstable_by_key(|(pos, _)| *pos);
38    words
39        .into_iter()
40        .map(|(_, word)| word)
41        .collect::<Vec<_>>()
42        .join(" ")
43}
44
45/// `https://doi.org/10.1234/x` → `10.1234/x`; pass through anything else.
46fn normalize_doi(doi: &str) -> String {
47    doi.strip_prefix("https://doi.org/")
48        .unwrap_or(doi)
49        .to_string()
50}
51
52/// `https://openalex.org/W2741809807` → `W2741809807`.
53fn openalex_work_id(id: &str) -> Option<String> {
54    id.rsplit('/')
55        .next()
56        .filter(|s| !s.is_empty())
57        .map(String::from)
58}
59
60#[derive(Deserialize)]
61struct OaResponse {
62    results: Option<Vec<OaWork>>,
63}
64
65#[derive(Deserialize)]
66struct OaWork {
67    id: Option<String>,
68    #[serde(default)]
69    display_name: Option<String>,
70    #[serde(default)]
71    publication_year: Option<u32>,
72    #[serde(default)]
73    doi: Option<String>,
74    #[serde(default)]
75    primary_location: Option<OaLocation>,
76    #[serde(default)]
77    authorships: Vec<OaAuthorship>,
78    #[serde(default)]
79    abstract_inverted_index: Option<std::collections::HashMap<String, Vec<usize>>>,
80    #[serde(default)]
81    referenced_works: Vec<String>,
82}
83
84/// Shared mapping for both the search and reference endpoints.
85fn work_to_paper(work: OaWork) -> Option<Paper> {
86    let title = work.display_name.filter(|t| !t.is_empty())?;
87    let mut paper = Paper::new(title);
88    paper.year = work.publication_year;
89    paper.doi = work.doi.as_deref().map(normalize_doi);
90    paper.venue = work
91        .primary_location
92        .as_ref()
93        .and_then(|loc| loc.source.as_ref())
94        .and_then(|src| src.display_name.clone());
95    paper.openalex_id = work.id.as_deref().and_then(openalex_work_id);
96    paper.abstract_text = work
97        .abstract_inverted_index
98        .as_ref()
99        .map(reconstruct_abstract)
100        .unwrap_or_default();
101    paper.authors = work
102        .authorships
103        .iter()
104        .filter_map(|a| a.author.as_ref().and_then(|au| au.display_name.clone()))
105        .collect();
106    paper.url = work.id.clone();
107    Some(paper)
108}
109
110#[derive(Deserialize)]
111struct OaLocation {
112    #[serde(default)]
113    source: Option<OaSource>,
114}
115
116#[derive(Deserialize)]
117struct OaSource {
118    #[serde(default)]
119    display_name: Option<String>,
120}
121
122#[derive(Deserialize)]
123struct OaAuthorship {
124    #[serde(default)]
125    author: Option<OaAuthor>,
126}
127
128#[derive(Deserialize)]
129struct OaAuthor {
130    #[serde(default)]
131    display_name: Option<String>,
132}
133
134#[async_trait]
135impl PaperSource for OpenAlexSource {
136    async fn fetch_papers(&self, query: &str, limit: usize) -> Result<Vec<Paper>> {
137        let url = format!(
138            "https://api.openalex.org/works?search={}&per-page={limit}",
139            percent_encode(query),
140        );
141        let resp = self
142            .client
143            .get(&url)
144            .header("User-Agent", "research-agent/0.1")
145            .send()
146            .await
147            .map_err(|e| ResearchError::Source(format!("OpenAlex request failed: {e}")))?;
148
149        let status = resp.status();
150        if !status.is_success() {
151            return Err(ResearchError::Source(format!(
152                "OpenAlex API returned HTTP {status}"
153            )));
154        }
155
156        let parsed: OaResponse = resp
157            .json()
158            .await
159            .map_err(|e| ResearchError::Source(format!("OpenAlex parse failed: {e}")))?;
160
161        Ok(parsed
162            .results
163            .unwrap_or_default()
164            .into_iter()
165            .filter_map(work_to_paper)
166            .collect())
167    }
168
169    fn name(&self) -> &str {
170        "openalex"
171    }
172}
173
174/// Citation-graph half of the OpenAlex adapter: resolve the `W…` ids a paper
175/// references, then hydrate those ids into `Paper` records; and the reverse,
176/// list the works citing a paper via the `cites:` filter.
177pub struct ReferencesSource {
178    client: reqwest::Client,
179}
180
181impl ReferencesSource {
182    pub fn new() -> Self {
183        Self {
184            client: reqwest::Client::new(),
185        }
186    }
187
188    /// OpenAlex work ids (`W…`) referenced by `paper`. The paper must carry an
189    /// `openalex_id` or a DOI; anything else cannot be resolved.
190    pub async fn reference_ids(&self, paper: &Paper) -> Result<Vec<String>> {
191        let key = match (&paper.openalex_id, &paper.doi) {
192            (Some(id), _) => id.clone(),
193            (None, Some(doi)) => format!("doi:{doi}"),
194            (None, None) => {
195                return Err(ResearchError::Source(
196                    "paper has no openalex_id or DOI; cannot resolve references".into(),
197                ));
198            }
199        };
200        let work = self.fetch_work(&key).await?;
201        Ok(work
202            .referenced_works
203            .iter()
204            .filter_map(|u| openalex_work_id(u))
205            .collect())
206    }
207
208    /// Works citing `paper`, hydrated. The `cites:` filter needs a `W…` id, so
209    /// a DOI-only paper is resolved through one extra lookup.
210    // ponytail: first page only (200 works, the OpenAlex per-page max);
211    // cursor-pagination the `meta.next_cursor` loop when full citing sets matter.
212    pub async fn citing_papers(&self, paper: &Paper) -> Result<Vec<Paper>> {
213        let work_id = match &paper.openalex_id {
214            Some(id) => id.clone(),
215            None => {
216                let doi = paper.doi.as_ref().ok_or_else(|| {
217                    ResearchError::Source(
218                        "paper has no openalex_id or DOI; cannot resolve citers".into(),
219                    )
220                })?;
221                let work = self.fetch_work(&format!("doi:{doi}")).await?;
222                openalex_work_id(work.id.as_deref().unwrap_or_default()).ok_or_else(|| {
223                    ResearchError::Source("OpenAlex returned a work without an id".into())
224                })?
225            }
226        };
227        let works = self
228            .fetch_results(&format!(
229                "https://api.openalex.org/works?filter=cites:{work_id}&per-page=200"
230            ))
231            .await?;
232        Ok(works.into_iter().filter_map(work_to_paper).collect())
233    }
234
235    /// Hydrate work ids into papers. One batched request per chunk (the
236    /// `openalex_id:` filter accepts `|`-joined ids; per-page caps at 200).
237    pub async fn hydrate(&self, work_ids: &[String]) -> Result<Vec<Paper>> {
238        let mut papers = Vec::new();
239        for chunk in work_ids.chunks(50) {
240            let url = format!(
241                "https://api.openalex.org/works?filter=openalex_id:{}&per-page={}",
242                chunk.join("|"),
243                chunk.len(),
244            );
245            let works = self.fetch_results(&url).await?;
246            papers.extend(works.into_iter().filter_map(work_to_paper));
247        }
248        Ok(papers)
249    }
250
251    /// GET a works-list URL and unwrap its `results`.
252    async fn fetch_results(&self, url: &str) -> Result<Vec<OaWork>> {
253        let resp = self
254            .client
255            .get(url)
256            .header("User-Agent", "research-agent/0.1")
257            .send()
258            .await
259            .map_err(|e| ResearchError::Source(format!("OpenAlex request failed: {e}")))?;
260        let status = resp.status();
261        if !status.is_success() {
262            return Err(ResearchError::Source(format!(
263                "OpenAlex API returned HTTP {status}"
264            )));
265        }
266        let parsed: OaResponse = resp
267            .json()
268            .await
269            .map_err(|e| ResearchError::Source(format!("OpenAlex parse failed: {e}")))?;
270        Ok(parsed.results.unwrap_or_default())
271    }
272
273    async fn fetch_work(&self, key: &str) -> Result<OaWork> {
274        let url = format!("https://api.openalex.org/works/{}", percent_encode(key));
275        let resp = self
276            .client
277            .get(&url)
278            .header("User-Agent", "research-agent/0.1")
279            .send()
280            .await
281            .map_err(|e| ResearchError::Source(format!("OpenAlex request failed: {e}")))?;
282        let status = resp.status();
283        if !status.is_success() {
284            return Err(ResearchError::Source(format!(
285                "OpenAlex API returned HTTP {status}"
286            )));
287        }
288        resp.json()
289            .await
290            .map_err(|e| ResearchError::Source(format!("OpenAlex parse failed: {e}")))
291    }
292}
293
294impl Default for ReferencesSource {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    const FIXTURE: &str = r#"{
305        "meta": {"count": 1},
306        "results": [{
307            "id": "https://openalex.org/W2741809807",
308            "doi": "https://doi.org/10.1038/nature12373",
309            "display_name": "Nanometre-scale thermometry in a living cell",
310            "publication_year": 2013,
311            "primary_location": {
312                "source": {"display_name": "Nature"}
313            },
314            "authorships": [
315                {"author": {"display_name": "G. Kucsko"}},
316                {"author": {"display_name": "P. C. Maurer"}}
317            ],
318            "abstract_inverted_index": {
319                "Heat": [0], "is": [1], "generated": [2], "by": [3], "the": [4, 9],
320                "cell": [5, 10]
321            }
322        }]
323    }"#;
324
325    #[test]
326    fn parses_openalex_work() {
327        let parsed: OaResponse = serde_json::from_str(FIXTURE).unwrap();
328        let works = parsed.results.unwrap();
329        assert_eq!(works.len(), 1);
330        let work = works.into_iter().next().unwrap();
331        assert_eq!(
332            openalex_work_id(work.id.as_deref().unwrap()).unwrap(),
333            "W2741809807"
334        );
335        assert_eq!(
336            work.doi.as_deref().map(normalize_doi).unwrap(),
337            "10.1038/nature12373"
338        );
339        assert_eq!(work.publication_year, Some(2013));
340    }
341
342    #[test]
343    fn reconstructs_abstract_from_inverted_index() {
344        let parsed: OaResponse = serde_json::from_str(FIXTURE).unwrap();
345        let work = &parsed.results.unwrap()[0];
346        let inverted = work.abstract_inverted_index.as_ref().unwrap();
347        let abstract_text = reconstruct_abstract(inverted);
348        assert_eq!(abstract_text, "Heat is generated by the cell the cell");
349    }
350
351    #[test]
352    fn missing_fields_tolerated() {
353        let parsed: OaResponse =
354            serde_json::from_str(r#"{"results": [{"id": "https://openalex.org/W1"}]}"#).unwrap();
355        let work = &parsed.results.unwrap()[0];
356        assert!(work.display_name.is_none());
357        assert!(work.abstract_inverted_index.is_none());
358        assert!(work.authorships.is_empty());
359    }
360
361    #[test]
362    fn source_name() {
363        assert_eq!(OpenAlexSource::new().name(), "openalex");
364    }
365
366    const REFS_FIXTURE: &str = r#"{
367        "id": "https://openalex.org/W2741809807",
368        "display_name": "Nanometre-scale thermometry in a living cell",
369        "referenced_works": [
370            "https://openalex.org/W1560783210",
371            "https://openalex.org/W1724212071"
372        ]
373    }"#;
374
375    #[test]
376    fn extracts_reference_ids_from_urls() {
377        let work: OaWork = serde_json::from_str(REFS_FIXTURE).unwrap();
378        let ids: Vec<String> = work
379            .referenced_works
380            .iter()
381            .filter_map(|u| openalex_work_id(u))
382            .collect();
383        assert_eq!(ids, vec!["W1560783210", "W1724212071"]);
384    }
385
386    #[test]
387    fn hydrates_referenced_works() {
388        let parsed: OaResponse = serde_json::from_str(
389            r#"{"results": [{"id": "https://openalex.org/W1560783210", "display_name": "Anatomy of green open access"}]}"#,
390        )
391        .unwrap();
392        let papers: Vec<Paper> = parsed
393            .results
394            .unwrap()
395            .into_iter()
396            .filter_map(work_to_paper)
397            .collect();
398        assert_eq!(papers.len(), 1);
399        assert_eq!(papers[0].openalex_id.as_deref(), Some("W1560783210"));
400        assert_eq!(papers[0].title, "Anatomy of green open access");
401    }
402}