@@ -0,0 +1,6 @@
|
||||
# FIX CRLF always when developer has not set
|
||||
# Linux/Mac: git config --global core.autocrlf input
|
||||
# Windows: git config --global core.autocrlf true
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
* eol=lf
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Publish image
|
||||
|
||||
# Build and push the feeder image to the GitHub Container Registry (ghcr.io):
|
||||
# * every push to main -> the moving `main` tag
|
||||
# * every `v*` tag -> semver tags (e.g. v1.0.0 -> 1.0.0, 1.0, 1)
|
||||
# The build context is the repo root (the workspace) so the vendored
|
||||
# `meta-feeder-sdk` path dependency resolves; the Dockerfile lives under
|
||||
# feeder-plugin/paper-feeder/.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./feeder-plugin/europepmc-feeder/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
Generated
+1947
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["feeder-plugin/*"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/worph/meta-feeder-europepmc"
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "europepmc-feeder"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
# Europe PMC open-access life-science literature. Open, keyless.
|
||||
# Upstream: Europe PMC (www.ebi.ac.uk/europepmc/webservices/rest)
|
||||
|
||||
[lib]
|
||||
name = "europepmc_feeder"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "europepmc-feeder"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# The SDK is a dependency, not a vendored copy. Pinned by rev until the first
|
||||
# tag; see docs/project-architecture/feeder-architecture.md §8.
|
||||
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", rev = "d2c6ef4c1887be9ae9f92ebedc39a810aa57c7e4" }
|
||||
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["json"] }
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
|
||||
futures = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
bytes = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.40", features = ["full", "test-util"] }
|
||||
@@ -0,0 +1,44 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# europepmc-feeder sidecar. Multi-stage build; build context is this repo's root.
|
||||
# meta-feeder-sdk arrives as a git dependency, so no vendored crate is copied
|
||||
# in — `git` must be present in the builder for cargo to fetch it.
|
||||
|
||||
FROM rust:1.89-slim-bookworm AS builder
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY Cargo.toml rust-toolchain.toml ./
|
||||
COPY feeder-plugin/ feeder-plugin/
|
||||
|
||||
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,id=europepmc-feeder-target,target=/build/target \
|
||||
cargo build --release --bin europepmc-feeder \
|
||||
&& cp target/release/europepmc-feeder /europepmc-feeder
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /europepmc-feeder /usr/local/bin/europepmc-feeder
|
||||
|
||||
RUN useradd --system --uid 10001 --no-create-home metafeeder \
|
||||
&& mkdir -p /data/meta-feeder \
|
||||
&& chown metafeeder:metafeeder /data/meta-feeder
|
||||
|
||||
USER metafeeder
|
||||
WORKDIR /data/meta-feeder
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV META_FEEDER_HTTP_LISTEN=0.0.0.0:8080 \
|
||||
META_FEEDER_STATE_DIR=/data/meta-feeder \
|
||||
RUST_LOG=info
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/europepmc-feeder"]
|
||||
@@ -0,0 +1,617 @@
|
||||
//! PubMed Central bridge via the [Europe PMC REST API](https://europepmc.org/RestfulWebService).
|
||||
//!
|
||||
//! - Search: `GET <search-base>/search?query=<q>&format=json&pageSize=N&resultType=lite`
|
||||
//! → JSON `resultList.result[]`; filter to entries with a `pmcid`
|
||||
//! (full-text open-access subset).
|
||||
//! - PDF fetch: `GET <pdf-base>/articles/<pmcid>?pdf=render` → binary PDF.
|
||||
//!
|
||||
//! No auth, no API key. Europe PMC is a public mirror of NIH's PubMed
|
||||
//! Central with developer-friendly REST endpoints. NIH's own `efetch.fcgi`
|
||||
//! returns JATS XML by default and would need a separate URL-discovery
|
||||
//! step to land on the PDF; Europe PMC's `?pdf=render` is the single
|
||||
//! direct fetch this plugin is built around.
|
||||
//!
|
||||
//! **Open-access only.** Records without a `pmcid` field have no full
|
||||
//! text in Europe PMC and are filtered out at search time — there's
|
||||
//! nothing for `compute_outcomes` to hash. The PMC OA subset is large
|
||||
//! (~5 M articles) so the filter still surfaces plenty.
|
||||
//!
|
||||
//! See `docs/gateway-feature.md` §4 for the v0 plugin role.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
|
||||
use meta_feeder_sdk::common;
|
||||
use meta_feeder_sdk::cache::MidhashCache;
|
||||
#[cfg(test)]
|
||||
use meta_feeder_sdk::plugin::HashKind;
|
||||
use meta_feeder_sdk::plugin::{upstream_id_field, ConfigError, FeederPlugin, GatewayQuery, HashOutcome};
|
||||
use meta_feeder_sdk::types::{DiscoveryRecord, GatewayError, PluginHealth};
|
||||
|
||||
/// Default Europe PMC REST API root. Used for `/search` and the
|
||||
/// `?query=PMCID:<id>` single-record lookup that powers `compute_outcomes`.
|
||||
const DEFAULT_SEARCH_BASE: &str = "https://www.ebi.ac.uk/europepmc/webservices/rest";
|
||||
|
||||
/// Default Europe PMC web host. Hosts the `/articles/<pmcid>?pdf=render`
|
||||
/// endpoint that streams the rendered PDF directly. Different host from
|
||||
/// the REST API root (Europe PMC splits API and content hosting).
|
||||
const DEFAULT_PDF_BASE: &str = "https://europepmc.org";
|
||||
|
||||
/// HTTP timeout per upstream call. Generous: Europe PMC's PDF render can
|
||||
/// take 5–10 s on cold articles (it composes the PDF on demand from
|
||||
/// JATS XML for some entries).
|
||||
const HTTP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Polite identification per the Europe PMC API guidelines.
|
||||
const USER_AGENT: &str = concat!(
|
||||
"meta-share/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (gateway:pubmed)"
|
||||
);
|
||||
|
||||
/// PubMed Central gateway plugin. Same structural shape as the other
|
||||
/// fetch-capable v0 plugins: cheap to construct; `configure()` opens the
|
||||
/// per-plugin redb cache; per-record fetches are memoized so repeated
|
||||
/// "add" clicks for the same article don't re-download the PDF.
|
||||
pub struct PubmedPlugin {
|
||||
http: reqwest::Client,
|
||||
search_base: String,
|
||||
pdf_base: String,
|
||||
cache: Option<MidhashCache>,
|
||||
}
|
||||
|
||||
impl Default for PubmedPlugin {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PubmedPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self::with_bases(
|
||||
DEFAULT_SEARCH_BASE.to_string(),
|
||||
DEFAULT_PDF_BASE.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Single-base constructor used by tests against a `wiremock::MockServer`:
|
||||
/// the mock serves both `/search` (JSON) and `/articles/<id>` (PDF) under
|
||||
/// one host. Production uses [`with_bases`] / [`new`] with two distinct
|
||||
/// hosts.
|
||||
pub fn with_base_url(base: String) -> Self {
|
||||
Self::with_bases(base.clone(), base)
|
||||
}
|
||||
|
||||
pub fn with_bases(search_base: String, pdf_base: String) -> Self {
|
||||
let http = common::build_http_client(HTTP_TIMEOUT_SECS, USER_AGENT, None);
|
||||
Self {
|
||||
http,
|
||||
search_base,
|
||||
pdf_base,
|
||||
cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(&self) -> Result<&MidhashCache, GatewayError> {
|
||||
common::require_cache(self.cache.as_ref(), "pubmed")
|
||||
}
|
||||
|
||||
/// Run a free-text query against Europe PMC's `/search`. Returns the
|
||||
/// raw JSON parsed into our internal shape; downstream filters drop
|
||||
/// any record without a `pmcid` (the OA filter).
|
||||
async fn search_json(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
) -> Result<EuropePmcSearchResponse, GatewayError> {
|
||||
let url = format!(
|
||||
"{}/search?query={}&format=json&pageSize={}&resultType=lite",
|
||||
self.search_base.trim_end_matches('/'),
|
||||
common::urlencode(query),
|
||||
max_results
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GatewayError::Transient(format!("GET {url}: {e}")))?;
|
||||
common::map_status(&resp)?;
|
||||
resp.json::<EuropePmcSearchResponse>()
|
||||
.await
|
||||
.map_err(|e| GatewayError::Permanent(format!("decode europe-pmc search response: {e}")))
|
||||
}
|
||||
|
||||
/// Single-record lookup by PMC id. Used by `compute_outcomes` to
|
||||
/// refresh the record metadata before the PDF pull. Europe PMC's
|
||||
/// search supports `query=PMCID:<id>` to find exactly one article
|
||||
/// (the indexed pmcid → pmid mapping is unambiguous).
|
||||
async fn fetch_entry(&self, pmcid: &str) -> Result<EuropePmcResult, GatewayError> {
|
||||
let body = self.search_json(&format!("PMCID:{pmcid}"), 1).await?;
|
||||
body.result_list
|
||||
.result
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(GatewayError::NotFound)
|
||||
}
|
||||
|
||||
/// GET the rendered PDF from Europe PMC. The `?pdf=render` query
|
||||
/// triggers an on-demand render for articles that aren't pre-baked
|
||||
/// (most modern OA papers ARE pre-baked; render is sub-second).
|
||||
async fn fetch_pdf(&self, pmcid: &str) -> Result<bytes::Bytes, GatewayError> {
|
||||
let url = format!(
|
||||
"{}/articles/{}?pdf=render",
|
||||
self.pdf_base.trim_end_matches('/'),
|
||||
pmcid
|
||||
);
|
||||
let bytes = common::fetch_bytes(&self.http, &url).await?;
|
||||
// Europe PMC occasionally returns a 200 HTML page ("PDF not
|
||||
// available") for articles in the OA index whose render failed.
|
||||
// Cheapest guard: insist on the PDF magic bytes; reject anything
|
||||
// else as a permanent failure so the cache doesn't store junk
|
||||
// and the caller gets a clear error.
|
||||
if !bytes.starts_with(b"%PDF") {
|
||||
return Err(GatewayError::Permanent(format!(
|
||||
"europe-pmc returned non-PDF body for {pmcid} (article may have no rendered PDF)"
|
||||
)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeederPlugin for PubmedPlugin {
|
||||
fn upstream_id(&self) -> &'static str {
|
||||
"pubmed"
|
||||
}
|
||||
|
||||
fn configure(&mut self, cache_dir: &Path) -> Result<(), ConfigError> {
|
||||
self.cache = Some(common::open_midhash_cache(cache_dir, "pubmed")?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_query(
|
||||
&self,
|
||||
query: &GatewayQuery,
|
||||
max_results: usize,
|
||||
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
|
||||
// Layer A early-return: PubMed/Europe PMC only serves
|
||||
// `document` / `paper`. A non-paper filter can never match —
|
||||
// skip the upstream call entirely.
|
||||
if !meta_feeder_sdk::query_eval::query_accepts_plugin(
|
||||
query,
|
||||
self.served_file_types(),
|
||||
self.served_content_kinds(),
|
||||
) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let q = query.free_text_or_star();
|
||||
// Europe PMC returns mixed PubMed + PMC + PPR records; only those
|
||||
// with a `pmcid` field have a fetchable full text. Filter out
|
||||
// metadata-only entries early so a later `compute_outcomes` for
|
||||
// a returned record_id always has a chance of succeeding.
|
||||
let body = self.search_json(q, max_results).await?;
|
||||
let records: Vec<DiscoveryRecord> = body
|
||||
.result_list
|
||||
.result
|
||||
.into_iter()
|
||||
.filter_map(|r| r.pmcid.clone().map(|pmcid| into_discovery_record(r, pmcid)))
|
||||
.take(max_results)
|
||||
.collect();
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn compute_outcomes(&self, record_id: &str) -> Result<Vec<HashOutcome>, GatewayError> {
|
||||
let cache = self.cache()?;
|
||||
if let Some(hit) = common::cached_outcome(cache, record_id, "pubmed")? {
|
||||
return Ok(hit);
|
||||
}
|
||||
|
||||
let entry = self.fetch_entry(record_id).await?;
|
||||
let pmcid = entry.pmcid.clone().ok_or_else(|| {
|
||||
// Defensive: `fetch_entry` queried by PMCID, so a missing
|
||||
// pmcid in the response means the upstream gave us back the
|
||||
// wrong record. Treat as NotFound so the caller can move on.
|
||||
GatewayError::NotFound
|
||||
})?;
|
||||
let bytes = self.fetch_pdf(&pmcid).await?;
|
||||
let cid = meta_feeder_sdk::hash::compute_ipfs_cid(&bytes);
|
||||
|
||||
common::store_midhash(cache, record_id, "pubmed", &cid);
|
||||
|
||||
let record = into_discovery_record(entry, pmcid);
|
||||
Ok(common::single_outcome(
|
||||
cid,
|
||||
bytes,
|
||||
record,
|
||||
Some("pdf".to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
fn health(&self) -> PluginHealth {
|
||||
if self.cache.is_some() {
|
||||
PluginHealth::Ok
|
||||
} else {
|
||||
PluginHealth::Degraded {
|
||||
reason: "configure() not yet called".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn served_file_types(&self) -> &'static [&'static str] {
|
||||
&["document"]
|
||||
}
|
||||
|
||||
fn served_content_kinds(&self) -> &'static [&'static str] {
|
||||
&["paper"]
|
||||
}
|
||||
}
|
||||
|
||||
// -- Europe PMC JSON shapes (subset we use) ----------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EuropePmcSearchResponse {
|
||||
#[serde(rename = "resultList", default)]
|
||||
result_list: EuropePmcResultList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct EuropePmcResultList {
|
||||
#[serde(default)]
|
||||
result: Vec<EuropePmcResult>,
|
||||
}
|
||||
|
||||
/// One record from Europe PMC search. Field names match the API's
|
||||
/// camelCase JSON keys via `rename_all`; missing fields default to
|
||||
/// `None`. Everything past `pmcid` is surfaced into the DiscoveryRecord
|
||||
/// when present.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct EuropePmcResult {
|
||||
/// PubMed numeric id (e.g. "12345678"). Always present for MED-source
|
||||
/// records; absent for preprints (`PPR…`).
|
||||
pmid: Option<String>,
|
||||
/// PMC id (e.g. "PMC2868305"). **Present only when the article has
|
||||
/// full text in PMC's OA subset** — this is the discriminator we
|
||||
/// filter on at search time.
|
||||
pmcid: Option<String>,
|
||||
/// Article DOI when registered with CrossRef.
|
||||
doi: Option<String>,
|
||||
title: Option<String>,
|
||||
/// Comma-separated author list, e.g. "Smith J, Doe J".
|
||||
author_string: Option<String>,
|
||||
journal_title: Option<String>,
|
||||
pub_year: Option<String>,
|
||||
/// Full abstract text — multi-paragraph. We pass through verbatim;
|
||||
/// the UI trims for display.
|
||||
abstract_text: Option<String>,
|
||||
is_open_access: Option<String>,
|
||||
}
|
||||
|
||||
/// Convert a Europe PMC search result into a `DiscoveryRecord`. The
|
||||
/// caller is responsible for confirming `pmcid` is `Some` (handle_query
|
||||
/// filters first) — we accept it as a separate arg so the conversion
|
||||
/// can't accidentally emit a recordless of full-text availability.
|
||||
fn into_discovery_record(r: EuropePmcResult, pmcid: String) -> DiscoveryRecord {
|
||||
let mut fields: BTreeMap<String, String> = BTreeMap::new();
|
||||
if let Some(t) = r.title {
|
||||
fields.insert("title".to_string(), t);
|
||||
}
|
||||
fields.insert("fileType".to_string(), "document".to_string());
|
||||
fields.insert("contentKind".to_string(), "paper".to_string());
|
||||
fields.insert(
|
||||
"sourceUrl".to_string(),
|
||||
format!("https://europepmc.org/article/PMC/{pmcid}"),
|
||||
);
|
||||
fields.insert("fileName".to_string(), format!("pubmed-{pmcid}.pdf"));
|
||||
fields.insert(upstream_id_field("pubmed"), pmcid.clone());
|
||||
fields.insert("format".to_string(), "pdf".to_string());
|
||||
if let Some(p) = r.pmid {
|
||||
fields.insert("pmid".to_string(), p);
|
||||
}
|
||||
if let Some(d) = r.doi {
|
||||
fields.insert("doi".to_string(), d);
|
||||
}
|
||||
if let Some(a) = r.author_string {
|
||||
fields.insert("author".to_string(), a);
|
||||
}
|
||||
if let Some(j) = r.journal_title {
|
||||
fields.insert("journal".to_string(), j);
|
||||
}
|
||||
if let Some(y) = r.pub_year {
|
||||
fields.insert("year".to_string(), y);
|
||||
}
|
||||
if let Some(s) = r.abstract_text {
|
||||
fields.insert("summary".to_string(), s);
|
||||
}
|
||||
if let Some(o) = r.is_open_access {
|
||||
fields.insert("openAccess".to_string(), o);
|
||||
}
|
||||
|
||||
DiscoveryRecord {
|
||||
upstream_id: "pubmed".to_string(),
|
||||
record_id: pmcid,
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal search response with one OA record + one non-OA record
|
||||
/// (no pmcid). `handle_query` should keep only the OA one.
|
||||
const SAMPLE_SEARCH: &str = r#"{
|
||||
"hitCount": 2,
|
||||
"request": {},
|
||||
"resultList": {
|
||||
"result": [
|
||||
{
|
||||
"id": "12345678",
|
||||
"source": "MED",
|
||||
"pmid": "12345678",
|
||||
"pmcid": "PMC2868305",
|
||||
"doi": "10.1371/journal.pone.0010068",
|
||||
"title": "Discovery of a new species",
|
||||
"authorString": "Smith J, Doe J",
|
||||
"journalTitle": "PLoS One",
|
||||
"pubYear": "2010",
|
||||
"abstractText": "We describe a new species discovered in...",
|
||||
"isOpenAccess": "Y"
|
||||
},
|
||||
{
|
||||
"id": "98765432",
|
||||
"source": "MED",
|
||||
"pmid": "98765432",
|
||||
"title": "Closed-access paper",
|
||||
"authorString": "Other A",
|
||||
"journalTitle": "Some Journal",
|
||||
"pubYear": "2018",
|
||||
"isOpenAccess": "N"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_europe_pmc_search_response() {
|
||||
let body: EuropePmcSearchResponse = serde_json::from_str(SAMPLE_SEARCH).expect("parse");
|
||||
assert_eq!(body.result_list.result.len(), 2);
|
||||
assert_eq!(
|
||||
body.result_list.result[0].pmcid.as_deref(),
|
||||
Some("PMC2868305")
|
||||
);
|
||||
assert!(body.result_list.result[1].pmcid.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_discovery_record_emits_required_fields() {
|
||||
let body: EuropePmcSearchResponse = serde_json::from_str(SAMPLE_SEARCH).unwrap();
|
||||
let r0 = body.result_list.result.into_iter().next().unwrap();
|
||||
let pmcid = r0.pmcid.clone().unwrap();
|
||||
let rec = into_discovery_record(r0, pmcid);
|
||||
assert_eq!(rec.upstream_id, "pubmed");
|
||||
assert_eq!(rec.record_id, "PMC2868305");
|
||||
// §6.4 conventions.
|
||||
assert_eq!(
|
||||
rec.fields.get("fileType").map(String::as_str),
|
||||
Some("document")
|
||||
);
|
||||
assert_eq!(
|
||||
rec.fields.get("contentKind").map(String::as_str),
|
||||
Some("paper")
|
||||
);
|
||||
assert_eq!(rec.fields.get("format").map(String::as_str), Some("pdf"));
|
||||
assert_eq!(
|
||||
rec.fields.get("sourceUrl").map(String::as_str),
|
||||
Some("https://europepmc.org/article/PMC/PMC2868305")
|
||||
);
|
||||
assert_eq!(
|
||||
rec.fields.get("fileName").map(String::as_str),
|
||||
Some("pubmed-PMC2868305.pdf")
|
||||
);
|
||||
// Canonical `<upstream_id>id` field — `format!("{upstream_id}id")`
|
||||
// gives "pubmedid"; value is the PMC id.
|
||||
assert_eq!(
|
||||
rec.fields.get("pubmedid").map(String::as_str),
|
||||
Some("PMC2868305")
|
||||
);
|
||||
// Bibliographic surface.
|
||||
assert_eq!(rec.fields.get("year").map(String::as_str), Some("2010"));
|
||||
assert_eq!(
|
||||
rec.fields.get("author").map(String::as_str),
|
||||
Some("Smith J, Doe J")
|
||||
);
|
||||
assert_eq!(
|
||||
rec.fields.get("journal").map(String::as_str),
|
||||
Some("PLoS One")
|
||||
);
|
||||
assert_eq!(
|
||||
rec.fields.get("doi").map(String::as_str),
|
||||
Some("10.1371/journal.pone.0010068")
|
||||
);
|
||||
}
|
||||
|
||||
// -- HTTP integration tests against a wiremock server ----------------
|
||||
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn configured_plugin_against(server: &MockServer) -> (PubmedPlugin, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut plugin = PubmedPlugin::with_base_url(server.uri());
|
||||
plugin.configure(dir.path()).expect("configure");
|
||||
(plugin, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_query_drops_non_oa_records() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search"))
|
||||
.and(query_param("query", "species"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(SAMPLE_SEARCH)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
let records = plugin
|
||||
.handle_query(&GatewayQuery::from_free_text("species"), 10)
|
||||
.await
|
||||
.expect("handle_query");
|
||||
// Two records in the mock; only the one with `pmcid` survives.
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].record_id, "PMC2868305");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_query_truncates_to_max_results() {
|
||||
let server = MockServer::start().await;
|
||||
// Build a synthetic response with several OA records.
|
||||
let mut results = String::new();
|
||||
for i in 0..5 {
|
||||
if i > 0 {
|
||||
results.push(',');
|
||||
}
|
||||
results.push_str(&format!(
|
||||
r#"{{ "pmcid": "PMC{i}", "title": "Paper {i}", "pubYear": "2020" }}"#,
|
||||
i = i + 100
|
||||
));
|
||||
}
|
||||
let body = format!(r#"{{ "hitCount": 5, "resultList": {{ "result": [{results}] }} }}"#);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
let records = plugin
|
||||
.handle_query(&GatewayQuery::from_free_text("anything"), 2)
|
||||
.await
|
||||
.expect("handle_query");
|
||||
assert_eq!(records.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compute_outcomes_fetches_pdf_and_caches() {
|
||||
let server = MockServer::start().await;
|
||||
// PMCID lookup: search?query=PMCID:PMC2868305
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search"))
|
||||
.and(query_param("query", "PMCID:PMC2868305"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(SAMPLE_SEARCH)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
// PDF response — bytes get hashed.
|
||||
let pdf_bytes = b"%PDF-1.4 mock pubmed pdf\n".to_vec();
|
||||
let expected_cid = meta_feeder_sdk::hash::compute_ipfs_cid(&pdf_bytes);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/articles/PMC2868305"))
|
||||
.and(query_param("pdf", "render"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(pdf_bytes.clone())
|
||||
.insert_header("content-type", "application/pdf"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
let mut outcomes = plugin
|
||||
.compute_outcomes("PMC2868305")
|
||||
.await
|
||||
.expect("compute");
|
||||
assert_eq!(outcomes.len(), 1, "pubmed is single-outcome");
|
||||
let outcome = outcomes.remove(0);
|
||||
assert_eq!(outcome.hash.as_str(), expected_cid);
|
||||
assert_eq!(outcome.hash_kind, HashKind::Sha2_256);
|
||||
// Auto-store inputs.
|
||||
assert_eq!(outcome.bytes.as_deref(), Some(pdf_bytes.as_slice()));
|
||||
assert_eq!(outcome.file_extension.as_deref(), Some("pdf"));
|
||||
let rec = outcome.record.expect("record present on fresh compute");
|
||||
assert_eq!(rec.record_id, "PMC2868305");
|
||||
assert_eq!(
|
||||
rec.fields.get("doi").map(String::as_str),
|
||||
Some("10.1371/journal.pone.0010068")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compute_outcomes_cache_hit_skips_http() {
|
||||
let server = MockServer::start().await;
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
plugin
|
||||
.cache
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.put_midhash("PMC2868305", "bafyCACHED")
|
||||
.unwrap();
|
||||
let mut outcomes = plugin
|
||||
.compute_outcomes("PMC2868305")
|
||||
.await
|
||||
.expect("cache hit");
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
let outcome = outcomes.remove(0);
|
||||
assert_eq!(outcome.hash.as_str(), "bafyCACHED");
|
||||
assert!(outcome.bytes.is_none());
|
||||
assert!(outcome.record.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compute_outcomes_rejects_non_pdf_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(SAMPLE_SEARCH)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
// Upstream returns an HTML "PDF not available" page with 200 OK
|
||||
// — Europe PMC actually does this for articles in the OA index
|
||||
// whose render failed. The plugin must reject rather than store
|
||||
// junk bytes.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/articles/PMC2868305"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string("<html>PDF not available</html>")
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
let err = plugin
|
||||
.compute_outcomes("PMC2868305")
|
||||
.await
|
||||
.expect_err("non-PDF");
|
||||
assert!(matches!(err, GatewayError::Permanent(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upstream_404_maps_to_not_found() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
let err = plugin
|
||||
.compute_outcomes("PMC-does-not-exist")
|
||||
.await
|
||||
.expect_err("expected NotFound");
|
||||
assert!(matches!(err, GatewayError::NotFound), "got {err:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! `europepmc-feeder` library surface. The binary (`main.rs`) wraps the plugin in
|
||||
//! [`meta_feeder_sdk::serve_feeders`].
|
||||
|
||||
pub mod europepmc;
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `europepmc-feeder` — a single-upstream feeder sidecar.
|
||||
//!
|
||||
//! Upstream: Europe PMC (www.ebi.ac.uk/europepmc/webservices/rest)
|
||||
//!
|
||||
//! Env:
|
||||
//! - `META_FEEDER_HTTP_LISTEN` — listen addr (default `0.0.0.0:8080`)
|
||||
//! - `META_FEEDER_STATE_DIR` — per-plugin cache root (default `/data/meta-feeder`)
|
||||
//! - `RUST_LOG` — tracing filter (default `info`)
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use meta_feeder_sdk::{serve_feeders, FeederPlugin};
|
||||
use europepmc_feeder::europepmc::PubmedPlugin;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.init();
|
||||
|
||||
let listen: SocketAddr = std::env::var("META_FEEDER_HTTP_LISTEN")
|
||||
.unwrap_or_else(|_| "0.0.0.0:8080".to_string())
|
||||
.parse()?;
|
||||
let state_dir =
|
||||
std::env::var("META_FEEDER_STATE_DIR").unwrap_or_else(|_| "/data/meta-feeder".to_string());
|
||||
|
||||
let plugins: Vec<Box<dyn FeederPlugin>> = vec![Box::new(PubmedPlugin::new())];
|
||||
serve_feeders(plugins, state_dir, listen).await
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.89"
|
||||
components = ["rustfmt", "clippy"]
|
||||
Reference in New Issue
Block a user