use async_trait::async_trait;
use url::Url;
use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
use crate::source::{FetchContext, FetchError, FetchResult, Source};
use crate::{CapabilityProfile, Ref};
const DEFAULT_BASE: &str = "https://www.ebi.ac.uk";
#[derive(Clone, Debug)]
pub struct EuropePmcSource {
base: Url,
}
impl EuropePmcSource {
#[must_use]
pub fn new() -> Self {
Self {
#[allow(clippy::expect_used)]
base: Url::parse(DEFAULT_BASE).expect("hard-coded base URL is valid"),
}
}
pub fn with_base(base: Url) -> Self {
Self { base }
}
fn request_url(&self, doi: &crate::Doi) -> Result<Url, FetchError> {
let mut url = self
.base
.join("/europepmc/webservices/rest/search")
.map_err(|e| FetchError::SourceSchema {
hint: format!("europepmc URL construction failed: {e}"),
})?;
url.query_pairs_mut()
.append_pair("query", &format!("DOI:\"{}\"", doi.as_str()))
.append_pair("format", "json")
.append_pair("resultType", "core")
.append_pair("pageSize", "1");
Ok(url)
}
}
impl Default for EuropePmcSource {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Source for EuropePmcSource {
fn name(&self) -> &str {
"europe-pmc"
}
fn can_serve(&self, profile: &CapabilityProfile, ref_: &Ref) -> bool {
profile.metadata.europe_pmc && matches!(ref_, Ref::Doi(_))
}
async fn fetch(
&self,
ref_: &Ref,
profile: &CapabilityProfile,
ctx: &FetchContext,
) -> Result<FetchResult, FetchError> {
let doi = match ref_ {
Ref::Doi(d) => d,
Ref::Arxiv(_) => {
return Err(FetchError::NotEligible {
source_key: "europe-pmc".into(),
});
}
};
if !profile.metadata.europe_pmc {
return Err(FetchError::NotEligible {
source_key: "europe-pmc".into(),
});
}
let _permit = ctx.rate_limiter.acquire(self.name()).await;
let url = self.request_url(doi)?;
let (body, final_url) = ctx.http.fetch_bytes(self.name(), url).await?;
let envelope: serde_json::Value =
serde_json::from_slice(&body).map_err(|e| FetchError::SourceSchema {
hint: format!("europepmc returned non-JSON: {e}"),
})?;
let results = envelope
.get("resultList")
.and_then(|r| r.get("result"))
.and_then(|r| r.as_array())
.ok_or_else(|| FetchError::SourceSchema {
hint: format!(
"europepmc response missing `resultList.result` (got: {})",
truncate_for_hint(&body)
),
})?;
let record = results.first().ok_or_else(|| FetchError::NotFound {
hint: format!("europepmc has no record for {}", doi.as_str()),
})?;
if !is_open_access(record) {
return Err(FetchError::SourceSchema {
hint: format!(
"europepmc record is indexed but not open access \
(isOpenAccess = {}, inEPMC = {})",
flag(record, "isOpenAccess").unwrap_or("absent"),
flag(record, "inEPMC").unwrap_or("absent"),
),
});
}
let license = record
.get("license")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("unknown")
.to_string();
let canonical = ref_.promote(self.name(), None).digest_hex();
ctx.log.append(RowInput {
event: LogEvent::Fetch,
result: LogResult::Ok,
capability: Capability::Metadata,
ref_: Some(doi.as_str()),
source: Some(self.name()),
error_code: None,
size_bytes: Some(body.len() as u64),
license: Some(license.as_str()),
store_path: None,
canonical_digest: Some(&canonical),
})?;
Ok(FetchResult {
source: self.name().to_string(),
license,
pdf_bytes: None,
final_url: Some(final_url),
metadata_json: Some(record.clone()),
})
}
}
fn flag<'a>(record: &'a serde_json::Value, name: &str) -> Option<&'a str> {
record.get(name).and_then(|v| v.as_str())
}
#[must_use]
pub fn is_open_access(record: &serde_json::Value) -> bool {
flag(record, "isOpenAccess") == Some("Y")
}
#[must_use]
pub fn open_access_pdf_url(record: &serde_json::Value) -> Option<&str> {
record
.get("fullTextUrlList")
.and_then(|l| l.get("fullTextUrl"))
.and_then(|l| l.as_array())
.and_then(|entries| {
entries.iter().find_map(|e| {
let style = e.get("documentStyle").and_then(|v| v.as_str())?;
let code = e.get("availabilityCode").and_then(|v| v.as_str())?;
let url = e.get("url").and_then(|v| v.as_str())?;
(style.eq_ignore_ascii_case("pdf") && matches!(code, "F" | "OA")).then_some(url)
})
})
}
fn truncate_for_hint(body: &[u8]) -> String {
const MAX: usize = 200;
let s = String::from_utf8_lossy(body);
if s.len() <= MAX {
s.into_owned()
} else {
format!("{}…", &s[..MAX])
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use std::sync::Arc;
use camino::Utf8PathBuf;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::http::HttpClient;
use crate::provenance::ProvenanceLog;
use crate::rate_limiter::RateLimiter;
use crate::{CapabilityProfile, Doi, MetadataAccess, RateLimits, Ref};
const SAMPLE_OA: &str = r#"{
"hitCount": 1,
"resultList": {"result": [
{
"id": "PMC1234567",
"source": "PMC",
"doi": "10.1234/open",
"title": "An Open Access Article",
"pubYear": "2024",
"isOpenAccess": "Y",
"inEPMC": "Y",
"license": "cc by",
"fullTextUrlList": {"fullTextUrl": [
{"availability": "Subscription required", "availabilityCode": "S",
"documentStyle": "doi", "site": "DOI",
"url": "https://doi.org/10.1234/open"},
{"availability": "Free", "availabilityCode": "F",
"documentStyle": "pdf", "site": "Europe_PMC",
"url": "https://europepmc.org/articles/PMC1234567?pdf=render"}
]}
}
]}
}"#;
const SAMPLE_IN_EPMC_BUT_CLOSED: &str = r#"{
"hitCount": 1,
"resultList": {"result": [
{
"id": "23903748",
"source": "MED",
"doi": "10.1038/nature12373",
"title": "Nanometre-scale thermometry in a living cell.",
"pubYear": "2013",
"isOpenAccess": "N",
"inEPMC": "Y",
"inPMC": "Y"
}
]}
}"#;
const SAMPLE_EMPTY: &str = r#"{"hitCount": 0, "resultList": {"result": []}}"#;
fn build_test_context(wiremock_host: &str) -> (TempDir, FetchContext) {
let td = TempDir::new().expect("tempdir");
let log_dir =
Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
let http = Arc::new(HttpClient::new_for_tests_allow_http(
"europe-pmc",
wiremock_host,
));
let session_id = "01J0000000000000000000TEST".to_string();
let log = Arc::new(
ProvenanceLog::open(log_dir.join("test.jsonl"), session_id.clone())
.expect("provenance log opens"),
);
let ctx = FetchContext {
http,
rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
log,
session_id,
cache_root: None,
};
(td, ctx)
}
fn profile(europe_pmc: bool) -> CapabilityProfile {
let mut p = CapabilityProfile::from_env().expect("clean env never errors");
p.metadata = MetadataAccess {
openalex: false,
semantic_scholar: false,
doaj: false,
datacite: false,
hal: false,
openaire: false,
core: false,
europe_pmc,
};
p
}
#[test]
fn request_url_asks_for_the_core_result_type() {
let src = EuropePmcSource::new();
let doi = Doi::parse("10.1234/open").expect("valid doi");
let url = src.request_url(&doi).expect("url builds");
assert_eq!(url.path(), "/europepmc/webservices/rest/search");
let pairs: Vec<(String, String)> = url
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
assert!(pairs.contains(&("resultType".into(), "core".into())));
assert!(pairs.contains(&("format".into(), "json".into())));
assert!(pairs.contains(&("query".into(), "DOI:\"10.1234/open\"".into())));
}
#[tokio::test]
async fn fetch_returns_an_open_access_record() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/europepmc/webservices/rest/search"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_OA))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = EuropePmcSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1234/open").expect("doi"));
let got = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect("fetch succeeds");
assert_eq!(got.source, "europe-pmc");
assert_eq!(got.license, "cc by");
assert!(got.pdf_bytes.is_none(), "metadata-only contract");
let rec = got.metadata_json.expect("record");
assert_eq!(
open_access_pdf_url(&rec),
Some("https://europepmc.org/articles/PMC1234567?pdf=render"),
"must pick the free PDF entry, not the first URL in the list"
);
}
#[tokio::test]
async fn in_epmc_but_not_open_access_is_refused() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/europepmc/webservices/rest/search"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_IN_EPMC_BUT_CLOSED))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = EuropePmcSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1038/nature12373").expect("doi"));
let err = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect_err("a non-OA record must be refused, not returned");
let msg = err.to_string();
assert!(
matches!(err, FetchError::SourceSchema { .. }),
"got {err:?}"
);
assert!(
msg.contains("isOpenAccess = N") && msg.contains("inEPMC = Y"),
"the refusal must say WHY, naming both flags; got: {msg}"
);
}
#[test]
fn open_access_is_judged_on_isopenaccess_not_inepmc() {
assert!(is_open_access(&serde_json::json!({"isOpenAccess": "Y"})));
assert!(!is_open_access(
&serde_json::json!({"isOpenAccess": "N", "inEPMC": "Y"})
));
assert!(
!is_open_access(&serde_json::json!({"inEPMC": "Y"})),
"presence in the archive is not openness"
);
assert!(
!is_open_access(&serde_json::json!({})),
"absent is not open"
);
}
#[test]
fn pdf_url_extraction_skips_landing_pages_and_subscription_entries() {
let only_landing = serde_json::json!({"fullTextUrlList": {"fullTextUrl": [
{"documentStyle": "html", "availabilityCode": "F", "url": "https://x/landing"}
]}});
assert_eq!(open_access_pdf_url(&only_landing), None);
let paywalled_pdf = serde_json::json!({"fullTextUrlList": {"fullTextUrl": [
{"documentStyle": "pdf", "availabilityCode": "S", "url": "https://x/paid.pdf"}
]}});
assert_eq!(
open_access_pdf_url(&paywalled_pdf),
None,
"a subscription PDF is not an OA PDF"
);
assert_eq!(open_access_pdf_url(&serde_json::json!({})), None);
}
#[tokio::test]
async fn no_record_surfaces_as_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/europepmc/webservices/rest/search"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_EMPTY))
.mount(&server)
.await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = EuropePmcSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1234/absent").expect("doi"));
let err = src
.fetch(&ref_, &profile(true), &ctx)
.await
.expect_err("must not claim success");
assert!(matches!(err, FetchError::NotFound { .. }), "got {err:?}");
}
#[tokio::test]
async fn is_inert_when_the_runtime_flag_is_unset() {
let server = MockServer::start().await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = EuropePmcSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Doi(Doi::parse("10.1234/open").expect("doi"));
assert!(!src.can_serve(&profile(false), &ref_));
let err = src
.fetch(&ref_, &profile(false), &ctx)
.await
.expect_err("must refuse");
assert!(matches!(err, FetchError::NotEligible { .. }), "got {err:?}");
assert!(
server
.received_requests()
.await
.expect("recorded")
.is_empty(),
"an inert source must make NO request"
);
}
#[tokio::test]
async fn arxiv_refs_are_not_eligible() {
let server = MockServer::start().await;
let (_td, ctx) = build_test_context(&server.address().to_string());
let src = EuropePmcSource::with_base(Url::parse(&server.uri()).expect("base"));
let ref_ = Ref::Arxiv(crate::ArxivId::parse("2401.12345").expect("arxiv id"));
assert!(!src.can_serve(&profile(true), &ref_));
assert!(matches!(
src.fetch(&ref_, &profile(true), &ctx).await,
Err(FetchError::NotEligible { .. })
));
}
}