pubmed_client/europe_pmc/fulltext.rs
1//! Europe PMC full-text (JATS XML) retrieval.
2
3use tracing::{info, instrument};
4
5use pubmed_parser::ParseError;
6use pubmed_parser::pmc::PmcArticle;
7use pubmed_parser::pmc::parser::parse_pmc_xml;
8
9use crate::error::Result;
10
11use super::client::EuropePmcClient;
12use super::id::EuropePmcId;
13
14impl EuropePmcClient {
15 /// Fetch and parse the full text of a Europe PMC record into a [`PmcArticle`].
16 ///
17 /// Europe PMC serves full text as JATS XML, which is parsed by the same
18 /// parser used for NCBI PMC. Parsing into a [`PmcArticle`] requires a PMC id,
19 /// so this method only supports `PMC`-sourced records; for other sources use
20 /// [`EuropePmcClient::fetch_full_text_xml`] to get the raw JATS instead.
21 ///
22 /// Results are cached when a cache is configured (key `epmc-ft:<source>:<id>`).
23 ///
24 /// # Errors
25 ///
26 /// * [`ParseError::PmcNotAvailable`] — the record is not PMC-sourced.
27 /// * [`crate::PubMedError::ApiError`] — the HTTP request failed.
28 #[instrument(skip(self), fields(id = %id))]
29 pub async fn fetch_full_text(&self, id: &EuropePmcId) -> Result<PmcArticle> {
30 let Some(pmcid) = id.pmcid() else {
31 return Err(ParseError::PmcNotAvailable { id: id.to_string() }.into());
32 };
33
34 let cache_key = format!("epmc-ft:{}:{}", id.source, id.id);
35 if let Some(cache) = &self.cache
36 && let Some(cached) = cache.get(&cache_key).await
37 {
38 info!(id = %id, "Cache hit for Europe PMC full text");
39 return Ok(cached);
40 }
41
42 let xml = self.fetch_full_text_xml(id).await?;
43 let article = parse_pmc_xml(&xml, &pmcid)?;
44
45 if let Some(cache) = &self.cache {
46 cache.insert(cache_key, article.clone()).await;
47 }
48
49 Ok(article)
50 }
51
52 /// Fetch the raw JATS XML full text for a Europe PMC record.
53 ///
54 /// Works for any source that has full text available. Returns the response
55 /// body verbatim.
56 ///
57 /// Unlike the list endpoints, Europe PMC addresses full text by the record
58 /// id alone (`/{id}/fullTextXML`) rather than by `(source, id)`; a
59 /// source-qualified path answers 404.
60 #[instrument(skip(self), fields(id = %id))]
61 pub async fn fetch_full_text_xml(&self, id: &EuropePmcId) -> Result<String> {
62 let endpoint = format!("{}/fullTextXML", id.id);
63 let response = self
64 .executor()
65 .get_endpoint(&self.base_url, &endpoint, &[])
66 .await?;
67 Ok(response.text().await?)
68 }
69}