fastpaper/sources/
core.rs1use super::Paper;
2
3pub 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 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 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
60pub 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 server
284 .mock("GET", mockito::Matcher::Any)
285 .match_header("Authorization", "Bearer bad-key")
286 .with_status(403)
287 .create();
288 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}