use super::Paper;
pub fn search(base_url: &str, query: &str, max_results: u32) -> Result<Vec<Paper>, String> {
let encoded = super::encode_query(query);
let url = format!(
"{}/api/search/articles/{}?pageSize={}",
base_url, encoded, max_results
);
let mut last_err = String::new();
for attempt in 0..3 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(100 * (1 << attempt)));
}
match ureq::get(&url).call() {
Ok(resp) => {
let body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {}", e))?;
return parse_search_response(&body);
}
Err(ureq::Error::StatusCode(429)) => {
last_err = "rate limited (429)".to_string();
continue;
}
Err(ureq::Error::StatusCode(code)) if code >= 500 => {
return Err(format!("Server error: {}", code));
}
Err(e) => {
return Err(format!("HTTP error: {}", e));
}
}
}
Err(last_err)
}
pub fn parse_search_response(json: &str) -> Result<Vec<Paper>, String> {
let root: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {}", e))?;
let results = root["results"]
.as_array()
.ok_or("missing 'results' array")?;
let mut papers = Vec::new();
for item in results {
let bib = &item["bibjson"];
let title = bib["title"].as_str().unwrap_or("").to_string();
if title.is_empty() {
continue;
}
let authors: Vec<String> = bib["author"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|a| a["name"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let doi = bib["identifier"]
.as_array()
.and_then(|arr| {
arr.iter()
.find(|id| id["type"].as_str() == Some("doi"))
.and_then(|id| id["id"].as_str().map(|s| s.to_string()))
});
let abstract_text = bib["abstract"].as_str().map(|s| s.to_string());
let year = bib["year"]
.as_str()
.and_then(|s| s.parse::<u16>().ok());
let pdf_url = bib["link"]
.as_array()
.and_then(|arr| {
arr.iter()
.find(|l| {
l["type"].as_str().map(|t| t.contains("fulltext")).unwrap_or(false)
})
.and_then(|l| l["url"].as_str().map(|s| s.to_string()))
});
let id = item["id"].as_str().unwrap_or("").to_string();
papers.push(Paper {
id,
title,
authors,
abstract_text,
year,
doi,
url: pdf_url.clone(),
pdf_url,
venue: bib["journal"]["title"].as_str().map(|s| s.to_string()),
citations: None,
fields: vec![],
open_access: Some(true),
source: "doaj".to_string(),
});
}
Ok(papers)
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = include_str!("../../tests/fixtures/doaj_search.json");
#[test]
fn parse_returns_ok() {
let result = parse_search_response(FIXTURE);
assert!(result.is_ok());
}
#[test]
fn parse_papers_not_empty() {
let papers = parse_search_response(FIXTURE).unwrap();
assert!(!papers.is_empty());
}
#[test]
fn parse_source_is_doaj() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert_eq!(p.source, "doaj");
}
}
#[test]
fn parse_title() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert!(!p.title.is_empty(), "paper {} has empty title", p.id);
}
}
#[test]
fn parse_authors() {
let papers = parse_search_response(FIXTURE).unwrap();
let with_authors: Vec<_> = papers.iter().filter(|p| !p.authors.is_empty()).collect();
assert!(!with_authors.is_empty(), "no papers with authors");
}
#[test]
fn parse_doi() {
let papers = parse_search_response(FIXTURE).unwrap();
let with_doi: Vec<_> = papers.iter().filter(|p| p.doi.is_some()).collect();
assert!(!with_doi.is_empty(), "no papers with DOI");
for p in &with_doi {
assert!(p.doi.as_ref().unwrap().starts_with("10."));
}
}
#[test]
fn parse_abstract() {
let papers = parse_search_response(FIXTURE).unwrap();
let with_abstract: Vec<_> = papers.iter().filter(|p| p.abstract_text.is_some()).collect();
assert!(!with_abstract.is_empty(), "no papers with abstract");
}
#[test]
fn parse_year() {
let papers = parse_search_response(FIXTURE).unwrap();
let with_year: Vec<_> = papers.iter().filter(|p| p.year.is_some()).collect();
assert!(!with_year.is_empty(), "no papers with year");
for p in &with_year {
assert!(p.year.unwrap() >= 2000);
}
}
#[test]
fn parse_open_access_always_true() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert_eq!(p.open_access, Some(true), "DOAJ papers are always OA");
}
}
#[test]
fn parse_pdf_url_from_fulltext_link() {
let papers = parse_search_response(FIXTURE).unwrap();
let with_pdf: Vec<_> = papers.iter().filter(|p| p.pdf_url.is_some()).collect();
assert!(!with_pdf.is_empty(), "no papers with pdf_url");
for p in &with_pdf {
assert!(p.pdf_url.as_ref().unwrap().starts_with("http"));
}
}
#[test]
fn search_returns_papers() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body(FIXTURE)
.create();
let papers = search(&server.url(), "test", 3).unwrap();
assert!(!papers.is_empty());
mock.assert();
}
#[test]
fn search_request_path_contains_search_articles() {
let mut server = mockito::Server::new();
let mock = server
.mock(
"GET",
mockito::Matcher::Regex("/api/search/articles/".to_string()),
)
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(&server.url(), "test", 3);
mock.assert();
}
#[test]
fn search_request_contains_page_size() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Regex("pageSize=3".to_string()))
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(&server.url(), "test", 3);
mock.assert();
}
}