Repository navigation
Literature Provider Adapters
Author: Ciprian Ștefan Pleșca
OpenLongevity ingests bibliographic and trial-registry data through a set of read-only adapters, each implementing the LiteratureProvider protocol defined in src/openlongevity/providers/base.py. This page documents the shared contract, the fully implemented PubMed adapter, and the design constraints that apply to every adapter in the set (Europe PMC, OpenAlex, Crossref, ClinicalTrials.gov).
classDiagram
class LiteratureProvider {
<<protocol>>
+str name
+search(query: SearchQuery) list~Publication~
+get_by_id(external_id: str) Publication~
}
class SearchQuery {
+str query
+int limit
+int page
+Mapping filters
+__post_init__() normalizes whitespace, enforces 1..200 chars, 1..100 limit, page >= 1
}
class Provenance {
+str source_provider
+str source_identifier
+str source_url
+str retrieved_at
+str~ checksum
+str normalization_version
+str parser_version
}
class Publication {
+str identifier
+str title
+str abstract
+tuple authors
+Provenance~ provenance
+str retraction_status
+tuple corrections
}
class ProviderError {
<<exception>>
}
LiteratureProvider ..> SearchQuery : accepts
LiteratureProvider ..> Publication : returns
Publication --> Provenance
LiteratureProvider ..> ProviderError : raises on recoverable failure
Every adapter is expected to:
- Accept a validated
SearchQuery— validation happens once, in the dataclass itself, so no adapter needs to re-check query length or limit bounds. - Return a
list[Publication], never a provider-specific shape. - Attach a
Provenanceto every returnedPublication, with asource_providername and a resolvablesource_url. - Raise
ProviderError— never let raw transport exceptions escape — for any recoverable upstream failure (timeout, malformed payload, rate limiting exhausted).
PubMedProvider is the only adapter implemented as a full, hardened I/O pipeline in this repository, and it establishes the pattern that other adapters are expected to follow.
flowchart TD
Start([search-SearchQuery]) --> Lock["acquire asyncio.Lock: serialize this instance's requests"]
Lock --> ESearch["esearch.fcgi\ndb=pubmed, term, retmax, retstart"]
ESearch --> Rate["fixed 0.35s pre-request delay\n(<3 req/s per instance)"]
Rate --> Stream[stream response, abort if > 5MB]
Stream --> StatusCheck{"status 429 or >= 500?"}
StatusCheck -- yes, attempts remain --> Backoff["respect Retry-After header,\nelse exponential backoff (0.5 x 2^n)"]
Backoff --> ESearch
StatusCheck -- no --> ParseIds{"idlist valid\n(list of numeric strings)?"}
ParseIds -- no --> Err1[raise ProviderError: malformed search data]
ParseIds -- empty --> Empty[return empty list]
ParseIds -- yes --> EFetch["efetch.fcgi\ndb=pubmed, id=joined ids, retmode=xml"]
EFetch --> Defused["defusedxml parse\n(blocks unsafe XML entities)"]
Defused --> ParseFail{"parse error?"}
ParseFail -- yes --> Err2[raise ProviderError: malformed or unsafe XML]
ParseFail -- no --> PerArticle[for each PubmedArticle node]
PerArticle --> Extract["extract pmid, title, abstract,\nauthors, journal, doi, types, corrections"]
Extract --> Skip{"pmid, title\nboth present?"}
Skip -- no --> Drop[skip this article]
Skip -- yes --> Canon["canonicalize per-article XML,\nSHA-256 checksum"]
Canon --> Build[build Publication + Provenance]
Build --> Collect[append to results]
Collect --> Done([return list of Publications])
-
Retry budget: up to 3 attempts per request stage, with exponential backoff (
0.5 × 2^attempt) on network/timeout errors, and honoring an upstreamRetry-Afterheader (capped at 10 seconds) on 429/5xx responses. -
Bounded memory: the response body is streamed and aborted with a
ProviderErrorthe moment it exceedsmax_bytes = 5_000_000, protecting the process from an unexpectedly large or malicious upstream payload. -
Safe XML parsing:
defusedxml.ElementTree.fromstringis used instead of the standard library parser, mitigating XML entity-expansion and external-entity attacks — a meaningful hardening choice for any adapter parsing third-party XML. -
Deterministic checksums: the checksum is computed over
ElementTree.canonicalize()output of the individual article node, not the whole batch response and not including the retrieval timestamp. This means the same article fetched on two different days produces the same checksum, which is exactly the propertyPublicationRepositorydepends on to distinguish "re-fetched, unchanged" from "genuinely revised." -
Malformed-identifier rejection:
esearchresults are validated as a list of purely numeric strings before use; anything else raisesProviderErrorrather than being passed downstream.
async def get_by_id(self, external_id: str) -> Publication | None:
pmid = external_id.removeprefix("PMID:")
if not pmid.isdigit():
raise ValueError("A numeric PMID is required")
records = await self.search(SearchQuery(f"{pmid}[PMID]", limit=1))
return next((record for record in records if record.identifier == f"PMID:{pmid}"), None)Note the distinction in error handling: an invalid PMID format raises ValueError (a caller programming error — the input was never a valid identifier), while any upstream failure during the underlying search() call raises ProviderError (a recoverable runtime condition). This distinction matters for api.py, which catches ValueError as a 422 client error and ProviderError as a 502 upstream error.
These adapters share the same LiteratureProvider contract and the Publication/Provenance output shape. Architecturally, they are expected to:
- Target their respective public APIs (Europe PMC REST API, OpenAlex works endpoint, Crossref works endpoint, and the ClinicalTrials.gov v2 API for
ClinicalTrialrecords). - Reuse
providers/http.pyfor shared transport concerns rather than each adapter reimplementing retry and timeout logic independently. - Populate
source_providerwith their own name (europe_pmc,openalex,crossref,clinicaltrials) so that downstream consumers — including theconcentrationgap heuristic described in Research-Gap-Detection — can distinguish where a record actually originated.
flowchart LR
subgraph Adapters["providers/ (all implement LiteratureProvider)"]
direction TB
PM[pubmed.py — fully hardened reference implementation]
EP[europe_pmc.py]
OA[openalex.py]
CR[crossref.py]
CT[clinicaltrials.py — returns ClinicalTrial records]
end
HTTP[http.py: shared transport helpers] --> PM
HTTP --> EP
HTTP --> OA
HTTP --> CR
HTTP --> CT
PM --> OUT[list of Publication]
EP --> OUT
OA --> OUT
CR --> OUT
CT --> OUT2[list of ClinicalTrial]
See Persistence-and-Data-Governance for what happens to a Publication once an adapter returns it, or API-Reference for the HTTP surface that triggers ingestion.
© 2026 Ciprian Ștefan Pleșca. All rights reserved.
Licensed under the Apache License 2.0 — see LICENSE in the repository root.
OpenLongevity is research software. It does not provide medical advice, diagnosis, or clinical treatment recommendations.