Skip to main content

fastpaper/sources/
core.rs

1use super::Paper;
2
3/// Search CORE API.
4pub fn search(base_url: &str, query: &str, max_results: u32) -> Result<Vec<Paper>, String> {
5    let encoded = super::encode_query(query);
6    let url = format!(
7        "{}/v3/search/works?q={}&limit={}",
8        base_url, encoded, max_results
9    );
10    let api_key = std::env::var("CORE_API_KEY").ok();
11
12    // Try with key first, then without on 403
13    let result = http_get_core(&url, api_key.as_deref());
14    match result {
15        Err(ref e) if e.contains("403") && api_key.is_some() => {
16            // Retry without key
17            let body = http_get_core(&url, None)?;
18            parse_search_response(&body)
19        }
20        Err(e) => Err(e),
21        Ok(body) => parse_search_response(&body),
22    }
23}
24
25fn http_get_core(url: &str, api_key: Option<&str>) -> Result<String, String> {
26    let mut last_err = String::new();
27    for attempt in 0..3 {
28        if attempt > 0 {
29            std::thread::sleep(std::time::Duration::from_millis(100 * (1 << attempt)));
30        }
31        let mut req = ureq::get(url);
32        if let Some(key) = api_key {
33            req = req.header("Authorization", &format!("Bearer {}", key));
34        }
35        match req.call() {
36            Ok(resp) => {
37                return resp
38                    .into_body()
39                    .read_to_string()
40                    .map_err(|e| format!("Failed to read response: {}", e));
41            }
42            Err(ureq::Error::StatusCode(429)) => {
43                last_err = "rate limited (429)".to_string();
44                continue;
45            }
46            Err(ureq::Error::StatusCode(403)) => {
47                return Err("403 Forbidden".to_string());
48            }
49            Err(ureq::Error::StatusCode(code)) if code >= 500 => {
50                return Err(format!("Server error: {}", code));
51            }
52            Err(e) => {
53                return Err(format!("HTTP error: {}", e));
54            }
55        }
56    }
57    Err(last_err)
58}
59
60/// Parse CORE JSON search response into a list of Papers.
61pub fn parse_search_response(json: &str) -> Result<Vec<Paper>, String> {
62    let root: serde_json::Value =
63        serde_json::from_str(json).map_err(|e| format!("JSON parse error: {}", e))?;
64
65    let results = root["results"]
66        .as_array()
67        .ok_or("missing 'results' array")?;
68
69    let mut papers = Vec::new();
70    for item in results {
71        let title = item["title"].as_str().unwrap_or("").to_string();
72        if title.is_empty() {
73            continue;
74        }
75
76        let id = item["id"].as_u64().map(|n| n.to_string())
77            .or_else(|| item["id"].as_str().map(|s| s.to_string()))
78            .unwrap_or_default();
79
80        let authors: Vec<String> = item["authors"]
81            .as_array()
82            .map(|arr| {
83                arr.iter()
84                    .filter_map(|a| a["name"].as_str().map(|s| s.to_string()))
85                    .collect()
86            })
87            .unwrap_or_default();
88
89        let doi = item["doi"].as_str().map(|s| s.to_string());
90        let abstract_text = item["abstract"].as_str().map(|s| s.to_string());
91
92        let download_url = item["downloadUrl"]
93            .as_str()
94            .filter(|s| !s.is_empty())
95            .map(|s| s.to_string());
96
97        let citations = item["citationCount"].as_u64().map(|n| n as u32);
98
99        let year = item["publishedDate"]
100            .as_str()
101            .and_then(|s| s.get(..4))
102            .and_then(|y| y.parse::<u16>().ok())
103            .or_else(|| item["yearPublished"].as_u64().map(|y| y as u16));
104
105        papers.push(Paper {
106            id,
107            title,
108            authors,
109            abstract_text,
110            year,
111            doi,
112            url: item["links"]
113                .as_array()
114                .and_then(|arr| arr.first())
115                .and_then(|l| l["url"].as_str())
116                .map(|s| s.to_string()),
117            pdf_url: download_url,
118            venue: None,
119            citations,
120            fields: item["fieldOfStudy"]
121                .as_str()
122                .map(|s| vec![s.to_string()])
123                .unwrap_or_default(),
124            open_access: Some(true),
125            source: "core".to_string(),
126        });
127    }
128
129    Ok(papers)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use serial_test::serial;
136
137    const FIXTURE: &str = include_str!("../../tests/fixtures/core_search.json");
138
139    #[test]
140    fn parse_returns_ok() {
141        let result = parse_search_response(FIXTURE);
142        assert!(result.is_ok());
143    }
144
145    #[test]
146    fn parse_papers_not_empty() {
147        let papers = parse_search_response(FIXTURE).unwrap();
148        assert!(!papers.is_empty());
149    }
150
151    #[test]
152    fn parse_source_is_core() {
153        let papers = parse_search_response(FIXTURE).unwrap();
154        for p in &papers {
155            assert_eq!(p.source, "core");
156        }
157    }
158
159    #[test]
160    fn parse_title_not_empty() {
161        let papers = parse_search_response(FIXTURE).unwrap();
162        for p in &papers {
163            assert!(!p.title.is_empty(), "paper {} has empty title", p.id);
164        }
165    }
166
167    #[test]
168    fn parse_authors() {
169        let papers = parse_search_response(FIXTURE).unwrap();
170        let with_authors: Vec<_> = papers.iter().filter(|p| !p.authors.is_empty()).collect();
171        assert!(!with_authors.is_empty(), "no papers with authors");
172    }
173
174    #[test]
175    fn parse_doi() {
176        let papers = parse_search_response(FIXTURE).unwrap();
177        let with_doi: Vec<_> = papers.iter().filter(|p| p.doi.is_some()).collect();
178        assert!(!with_doi.is_empty(), "no papers with DOI");
179        for p in &with_doi {
180            assert!(p.doi.as_ref().unwrap().starts_with("10."));
181        }
182    }
183
184    #[test]
185    fn parse_abstract() {
186        let papers = parse_search_response(FIXTURE).unwrap();
187        let with_abstract: Vec<_> = papers.iter().filter(|p| p.abstract_text.is_some()).collect();
188        assert!(!with_abstract.is_empty(), "no papers with abstract");
189    }
190
191    #[test]
192    fn parse_pdf_url() {
193        let papers = parse_search_response(FIXTURE).unwrap();
194        let with_pdf: Vec<_> = papers.iter().filter(|p| p.pdf_url.is_some()).collect();
195        assert!(!with_pdf.is_empty(), "no papers with pdf_url");
196    }
197
198    #[test]
199    fn parse_citations() {
200        let papers = parse_search_response(FIXTURE).unwrap();
201        for p in &papers {
202            assert!(p.citations.is_some(), "paper {} missing citations", p.id);
203        }
204    }
205
206    #[test]
207    fn search_returns_papers() {
208        let mut server = mockito::Server::new();
209        let mock = server
210            .mock("GET", mockito::Matcher::Any)
211            .with_status(200)
212            .with_body(FIXTURE)
213            .create();
214        let papers = search(&server.url(), "test", 3).unwrap();
215        assert!(!papers.is_empty());
216        mock.assert();
217    }
218
219    #[test]
220    fn search_request_path() {
221        let mut server = mockito::Server::new();
222        let mock = server
223            .mock(
224                "GET",
225                mockito::Matcher::Regex("/v3/search/works".to_string()),
226            )
227            .with_status(200)
228            .with_body(FIXTURE)
229            .create();
230        let _ = search(&server.url(), "test", 3);
231        mock.assert();
232    }
233
234    #[test]
235    fn search_request_contains_limit() {
236        let mut server = mockito::Server::new();
237        let mock = server
238            .mock("GET", mockito::Matcher::Regex("limit=3".to_string()))
239            .with_status(200)
240            .with_body(FIXTURE)
241            .create();
242        let _ = search(&server.url(), "test", 3);
243        mock.assert();
244    }
245
246    #[test]
247    #[serial]
248    fn search_with_api_key_sends_bearer() {
249        unsafe { std::env::set_var("CORE_API_KEY", "core-test-key") };
250        let mut server = mockito::Server::new();
251        let mock = server
252            .mock("GET", mockito::Matcher::Any)
253            .match_header("Authorization", "Bearer core-test-key")
254            .with_status(200)
255            .with_body(FIXTURE)
256            .create();
257        let result = search(&server.url(), "test", 3);
258        unsafe { std::env::remove_var("CORE_API_KEY") };
259        assert!(result.is_ok());
260        mock.assert();
261    }
262
263    #[test]
264    #[serial]
265    fn search_works_without_api_key() {
266        unsafe { std::env::remove_var("CORE_API_KEY") };
267        let mut server = mockito::Server::new();
268        server
269            .mock("GET", mockito::Matcher::Any)
270            .with_status(200)
271            .with_body(FIXTURE)
272            .create();
273        let result = search(&server.url(), "test", 3);
274        assert!(result.is_ok());
275    }
276
277    #[test]
278    #[serial]
279    fn search_403_with_key_retries_without() {
280        unsafe { std::env::set_var("CORE_API_KEY", "bad-key") };
281        let mut server = mockito::Server::new();
282        // First request with key → 403
283        server
284            .mock("GET", mockito::Matcher::Any)
285            .match_header("Authorization", "Bearer bad-key")
286            .with_status(403)
287            .create();
288        // Second request without key → 200
289        server
290            .mock("GET", mockito::Matcher::Any)
291            .match_header("Authorization", mockito::Matcher::Missing)
292            .with_status(200)
293            .with_body(FIXTURE)
294            .create();
295        let result = search(&server.url(), "test", 3);
296        unsafe { std::env::remove_var("CORE_API_KEY") };
297        assert!(result.is_ok());
298    }
299}