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}