1use std::sync::Arc;
14use std::time::Duration;
15
16use aspero_core::csl::{plain_text, strip_markup};
17use aspero_core::http::escape_lucene;
18use aspero_core::{
19 Capabilities, Cursor, Doi, Error, FieldSet, Filter, Hit, HttpClient, HttpRequest, HttpResponse,
20 IdKind, Lookup, Nbk, OpenAccess, Page, PartialDate, Person, Pmcid, Pmid, Provider, ProviderId,
21 Raw, ResiliencePolicy, Response, Result, SearchMode, SearchQuery, SourceRecord, Update,
22 UpdateKind, Work, WorkId, WorkType, tidy_abstract,
23};
24use async_trait::async_trait;
25use jiff::Timestamp;
26use serde::Deserialize;
27
28pub const ID: ProviderId = ProviderId::from_static("europepmc");
29
30pub const DEFAULT_BASE_URL: &str = "https://www.ebi.ac.uk/europepmc/webservices/rest";
31
32const MAX_BATCH: usize = 50;
34
35const MAX_PAGE: u32 = 1000;
37
38#[derive(Debug, Clone)]
39#[non_exhaustive]
40pub struct EuropePmcConfig {
41 pub base_url: String,
42}
43
44impl Default for EuropePmcConfig {
45 fn default() -> Self {
46 EuropePmcConfig {
47 base_url: DEFAULT_BASE_URL.to_owned(),
48 }
49 }
50}
51
52pub struct EuropePmc {
53 config: EuropePmcConfig,
54 http: Arc<dyn HttpClient>,
55 capabilities: Capabilities,
56}
57
58impl EuropePmc {
59 pub fn new(config: EuropePmcConfig, http: Arc<dyn HttpClient>) -> Self {
60 let mut capabilities = Capabilities::default();
61 capabilities.lookup_kinds = vec![IdKind::Pmid, IdKind::Pmcid, IdKind::Doi, IdKind::Nbk];
62 capabilities.max_batch = MAX_BATCH;
63 capabilities.fields = FieldSet::ALL;
64 capabilities.search = true;
65 capabilities.search_filters = vec![Filter::Years, Filter::OpenAccess];
66 EuropePmc {
67 config,
68 http,
69 capabilities,
70 }
71 }
72
73 pub fn default_policy() -> ResiliencePolicy {
74 let mut policy = ResiliencePolicy::default();
75 policy.min_interval = Duration::from_millis(200);
76 policy
77 }
78
79 async fn search_raw(&self, params: &[(&str, String)]) -> Result<(WireResults, Timestamp)> {
80 let base = format!("{}/search", self.config.base_url.trim_end_matches('/'));
81 let url = url::Url::parse_with_params(&base, params)
82 .map_err(|e| Error::BadRequest(format!("invalid Europe PMC base URL: {e}")))?;
83 let resp = self
84 .http
85 .send(HttpRequest::get(String::from(url)).header("accept", "application/json"))
86 .await?;
87 check(&resp)?;
88 let results = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
89 Ok((results, resp.received_at))
90 }
91}
92
93fn check(resp: &HttpResponse) -> Result<()> {
94 if resp.is_success() {
95 return Ok(());
96 }
97 Err(match resp.status {
98 400 => Error::BadRequest("rejected by Europe PMC".into()),
99 404 => Error::NotFound,
100 429 => Error::RateLimited {
101 retry_after: resp
102 .header("retry-after")
103 .and_then(|v| v.trim().parse().ok())
104 .map(Duration::from_secs),
105 },
106 status => Error::Server {
107 status,
108 message: None,
109 },
110 })
111}
112
113#[derive(Debug, Default, Deserialize)]
114#[serde(default, rename_all = "camelCase")]
115struct WireResults {
116 hit_count: Option<u64>,
117 next_cursor_mark: Option<String>,
118 result_list: ResultList,
119}
120
121#[derive(Debug, Default, Deserialize)]
122#[serde(default)]
123struct ResultList {
124 result: Vec<serde_json::Value>,
125}
126
127#[derive(Debug, Default, Deserialize)]
128#[serde(default, rename_all = "camelCase")]
129struct WireRecord {
130 id: Option<String>,
131 source: Option<String>,
132 pmid: Option<String>,
133 pmcid: Option<String>,
134 doi: Option<String>,
135 title: Option<String>,
136 author_list: AuthorList,
137 pub_year: Option<String>,
138 first_publication_date: Option<String>,
139 journal_info: JournalInfo,
140 page_info: Option<String>,
141 abstract_text: Option<String>,
142 pub_type_list: PubTypes,
143 cited_by_count: Option<u64>,
144 is_open_access: Option<String>,
145 license: Option<String>,
146 full_text_url_list: FullTextUrls,
147 bookid: Option<String>,
149 book_or_report_details: BookDetails,
150}
151
152#[derive(Debug, Default, Deserialize)]
153#[serde(default, rename_all = "camelCase")]
154struct BookDetails {
155 publisher: Option<String>,
156 comprising_title: Option<String>,
158}
159
160#[derive(Debug, Default, Deserialize)]
161#[serde(default)]
162struct FullTextUrls {
163 #[serde(rename = "fullTextUrl")]
164 full_text_url: Vec<FullTextUrl>,
165}
166
167#[derive(Debug, Default, Deserialize)]
171#[serde(default, rename_all = "camelCase")]
172struct FullTextUrl {
173 availability_code: Option<String>,
174 document_style: Option<String>,
175 site: Option<String>,
176 url: Option<String>,
177}
178
179impl WireRecord {
180 fn open_access(&self) -> Option<OpenAccess> {
181 let links = &self.full_text_url_list.full_text_url;
182 if self.is_open_access.is_none() && links.is_empty() {
183 return None;
184 }
185 let free = |style: &str| {
186 let mut free: Vec<&FullTextUrl> = links
187 .iter()
188 .filter(|l| matches!(l.availability_code.as_deref(), Some("OA" | "F")))
189 .filter(|l| l.document_style.as_deref() == Some(style))
190 .collect();
191 free.sort_by_key(|l| l.site.as_deref() != Some("Europe_PMC"));
193 free.first().and_then(|l| l.url.clone())
194 };
195 let mut oa = OpenAccess::default();
196 oa.pdf_url = free("pdf");
197 oa.url = free("html");
198 oa.is_oa =
199 self.is_open_access.as_deref() == Some("Y") || oa.pdf_url.is_some() || oa.url.is_some();
200 oa.license = self
201 .license
202 .as_deref()
203 .map(|l| l.trim().to_lowercase().replace(' ', "-"));
204 oa.source = Some("europepmc".into());
205 Some(oa)
206 }
207}
208
209#[derive(Debug, Default, Deserialize)]
210#[serde(default)]
211struct AuthorList {
212 author: Vec<Author>,
213}
214
215#[derive(Debug, Default, Deserialize)]
216#[serde(default, rename_all = "camelCase")]
217struct Author {
218 full_name: Option<String>,
219 first_name: Option<String>,
220 last_name: Option<String>,
221 collective_name: Option<String>,
222 author_id: Option<AuthorId>,
223}
224
225#[derive(Debug, Default, Deserialize)]
226#[serde(default, rename_all = "camelCase")]
227struct AuthorId {
228 #[serde(rename = "type")]
229 id_type: Option<String>,
230 value: Option<String>,
231}
232
233#[derive(Debug, Default, Deserialize)]
234#[serde(default)]
235#[serde(rename_all = "camelCase")]
236struct JournalInfo {
237 year_of_publication: Option<i16>,
238 volume: Option<String>,
239 issue: Option<String>,
240 journal: Journal,
241}
242
243#[derive(Debug, Default, Deserialize)]
244#[serde(default)]
245struct Journal {
246 title: Option<String>,
247}
248
249#[derive(Debug, Default, Deserialize)]
250#[serde(default, rename_all = "camelCase")]
251struct PubTypes {
252 pub_type: Vec<String>,
253}
254
255impl Author {
256 fn person(self) -> Option<Person> {
257 let mut person = Person::default();
258 match (self.last_name, self.collective_name, self.full_name) {
259 (Some(last), _, _) => {
260 person.family = Some(last);
261 person.given = self.first_name;
262 }
263 (None, Some(name), _) | (None, None, Some(name)) => person.literal = Some(name),
264 (None, None, None) => return None,
265 }
266 person.orcid = self
267 .author_id
268 .filter(|id| id.id_type.as_deref() == Some("ORCID"))
269 .and_then(|id| id.value);
270 Some(person)
271 }
272}
273
274fn to_record(value: &serde_json::Value, at: Timestamp) -> serde_json::Result<SourceRecord> {
275 let r = WireRecord::deserialize(value)?;
276 let open_access = r.open_access();
277 let mut ids: Vec<WorkId> = Vec::new();
278 ids.extend(
279 r.pmid
280 .as_deref()
281 .and_then(|p| p.parse::<Pmid>().ok())
282 .map(WorkId::Pmid),
283 );
284 ids.extend(
285 r.pmcid
286 .as_deref()
287 .and_then(|p| p.parse::<Pmcid>().ok())
288 .map(WorkId::Pmcid),
289 );
290 ids.extend(
291 r.doi
292 .as_deref()
293 .and_then(|d| d.parse::<Doi>().ok())
294 .map(WorkId::Doi),
295 );
296 let nbk = r.bookid.as_deref().and_then(|b| b.parse::<Nbk>().ok());
297 ids.extend(nbk.map(WorkId::Nbk));
298 let book = &r.book_or_report_details;
299
300 let types = &r.pub_type_list.pub_type;
301 let has = |t: &str| types.iter().any(|p| p.eq_ignore_ascii_case(t));
302 let mut work = Work::default();
303 work.title = r
304 .title
305 .map(|t| plain_text(t).trim_end_matches('.').to_owned());
306 work.authors = r
307 .author_list
308 .author
309 .into_iter()
310 .filter_map(Author::person)
311 .collect();
312 work.issued = r
313 .first_publication_date
314 .as_deref()
315 .and_then(|d| d.parse::<PartialDate>().ok())
316 .or_else(|| {
317 r.pub_year
318 .as_deref()?
319 .parse()
320 .ok()
321 .map(PartialDate::from_year)
322 });
323 work.work_type = if nbk.is_some() {
324 Some(match book.comprising_title {
326 Some(_) => WorkType::Chapter,
327 None => WorkType::Book,
328 })
329 } else if r.source.as_deref() == Some("PPR") || has("Preprint") {
330 Some(WorkType::Preprint)
331 } else if has("Journal Article") || has("research-article") || has("Review") {
332 Some(WorkType::JournalArticle)
333 } else {
334 None
335 };
336 let issued_year = work.issued.map(|d| d.year());
338 let mut other: Vec<i16> = r
339 .journal_info
340 .year_of_publication
341 .into_iter()
342 .chain(r.pub_year.as_deref().and_then(|y| y.parse().ok()))
343 .filter(|y| Some(*y) != issued_year)
344 .collect();
345 other.sort_unstable();
346 other.dedup();
347 work.other_years = other;
348 work.container_title = r
349 .journal_info
350 .journal
351 .title
352 .or_else(|| book.comprising_title.clone())
353 .map(plain_text);
354 work.publisher = book.publisher.clone();
355 work.volume = r.journal_info.volume;
356 work.issue = r.journal_info.issue;
357 work.page = r.page_info;
358 work.abstract_text = r
359 .abstract_text
360 .as_deref()
361 .map(|a| tidy_abstract(&strip_markup(a)))
362 .filter(|a| !a.is_empty());
363 work.url = match (r.source.as_deref(), r.id.as_deref()) {
364 _ if nbk.is_some() => nbk.map(|n| format!("https://www.ncbi.nlm.nih.gov/books/{n}/")),
365 (Some(source), Some(id)) => Some(format!("https://europepmc.org/article/{source}/{id}")),
366 _ => None,
367 };
368 work.cited_by_count = r.cited_by_count;
369 work.open_access = open_access;
370 if has("Retracted Publication") {
371 let mut retraction = Update::new(UpdateKind::Retraction);
372 retraction.source = Some("europepmc".into());
373 work.updates.push(retraction);
374 }
375 Ok(SourceRecord::new(
376 ID,
377 ids,
378 work,
379 FieldSet::ALL,
380 Raw::json(value),
381 at,
382 ))
383}
384
385fn clause(id: &WorkId) -> Result<String> {
387 Ok(match id {
388 WorkId::Pmid(p) => format!("(EXT_ID:{p} AND SRC:MED)"),
389 WorkId::Pmcid(p) => format!("PMCID:{p}"),
390 WorkId::Nbk(n) => format!("BOOK_ID:{n}"),
391 WorkId::Doi(d) => format!("DOI:\"{}\"", d.as_str().replace('"', "\\\"")),
392 _ => {
393 return Err(Error::Unsupported(
394 "Europe PMC lookup of this identifier kind",
395 ));
396 }
397 })
398}
399
400#[async_trait]
401impl Provider for EuropePmc {
402 fn id(&self) -> ProviderId {
403 ID
404 }
405
406 fn capabilities(&self) -> &Capabilities {
407 &self.capabilities
408 }
409
410 async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
411 let clauses = ids.iter().map(clause).collect::<Result<Vec<_>>>()?;
412 let mut found = Vec::new();
413 for chunk in clauses.chunks(MAX_BATCH) {
414 let params = [
415 ("query", chunk.join(" OR ")),
416 ("resultType", "core".to_owned()),
417 ("format", "json".to_owned()),
418 ("pageSize", chunk.len().to_string()),
419 ];
420 let (results, at) = self.search_raw(¶ms).await?;
421 for v in &results.result_list.result {
422 found.push(to_record(v, at).map_err(Error::decode)?);
423 }
424 }
425 let lookups = ids
426 .iter()
427 .map(|id| Lookup {
428 id: id.clone(),
429 record: found.iter().find(|r| r.ids.contains(id)).cloned(),
430 })
431 .collect();
432 Ok(Response::new(lookups))
433 }
434
435 async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
436 if query.mode != SearchMode::Keywords {
437 return Err(Error::Unsupported("Europe PMC has no citation matcher"));
438 }
439 let mut clauses = Vec::new();
440 if !query.text.trim().is_empty() {
441 clauses.push(format!("({})", escape_lucene(query.text.trim())));
442 }
443 for (key, value) in &query.extra {
444 match key.as_str() {
445 "query" => clauses.push(format!("({value})")),
447 other => {
448 return Err(Error::BadRequest(format!(
449 "unknown Europe PMC parameter {other:?} (supported: query)"
450 )));
451 }
452 }
453 }
454 if let Some(years) = query.years {
455 let end = |y: Option<i16>| y.map_or("*".to_owned(), |y| y.to_string());
456 clauses.push(format!(
457 "PUB_YEAR:[{} TO {}]",
458 end(years.from),
459 end(years.to)
460 ));
461 }
462 if let Some(oa) = query.open_access {
463 clauses.push(format!("OPEN_ACCESS:{}", if oa { "y" } else { "n" }));
464 }
465 let cursor = query.page.cursor.as_ref().map_or("*", |c| c.0.as_str());
466 let params = [
467 ("query", clauses.join(" AND ")),
468 ("resultType", "core".to_owned()),
469 ("format", "json".to_owned()),
470 ("pageSize", query.page.limit.clamp(1, MAX_PAGE).to_string()),
471 ("cursorMark", cursor.to_owned()),
472 ];
473 let (results, at) = self.search_raw(¶ms).await?;
474 let hits: Vec<Hit> = results
475 .result_list
476 .result
477 .iter()
478 .map(|v| to_record(v, at).map(|r| Hit::new(r, None)))
479 .collect::<serde_json::Result<_>>()
480 .map_err(Error::decode)?;
481 let next = results
483 .next_cursor_mark
484 .filter(|next| !hits.is_empty() && next != cursor)
485 .map(Cursor);
486 Ok(Response::new(Page::new(hits, results.hit_count, next)))
487 }
488}