pubmed_client/europe_pmc/
client.rs

1//! Core [`EuropePmcClient`] definition and shared plumbing.
2
3use std::time::Duration;
4
5use reqwest::Client;
6use tracing::info;
7
8use crate::cache::{PmcCache, create_cache};
9use crate::config::ClientConfig;
10use crate::rate_limit::RateLimiter;
11use crate::request::RequestExecutor;
12use crate::tls::install_default_crypto_provider;
13
14/// Base URL for the Europe PMC RESTful Web Service.
15///
16/// Europe PMC is hosted by EBI on a different host and path scheme from the
17/// NCBI E-utilities, so it deliberately does **not** reuse
18/// [`ClientConfig::base_url`] (which is the NCBI eutils override). Use
19/// [`EuropePmcClient::with_base_url`] to point at a proxy or mock server.
20pub(crate) const EUROPE_PMC_BASE_URL: &str = "https://www.ebi.ac.uk/europepmc/webservices/rest";
21
22/// Client for the Europe PMC REST API.
23///
24/// Provides cross-source search, JATS full-text retrieval, reference and
25/// citation graphs, external database links, and supplementary file downloads.
26/// No API key is required; transport-level configuration (timeout, user agent,
27/// retry, rate limiting, caching) is shared with the rest of the workspace via
28/// [`ClientConfig`].
29///
30/// # Example
31///
32/// ```no_run
33/// use pubmed_client::europe_pmc::{EuropePmcClient, EuropePmcId};
34///
35/// #[tokio::main]
36/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
37///     let client = EuropePmcClient::new();
38///     let results = client.search("malaria vaccine", 10).await?;
39///     for r in &results {
40///         println!("{}/{}: {}", r.source, r.id, r.title.as_deref().unwrap_or(""));
41///     }
42///
43///     let article = client.fetch_full_text(&EuropePmcId::pmc("PMC3258128")?).await?;
44///     println!("Title: {}", article.title().unwrap_or("Untitled"));
45///     Ok(())
46/// }
47/// ```
48#[derive(Clone)]
49pub struct EuropePmcClient {
50    pub(crate) client: Client,
51    pub(crate) base_url: String,
52    pub(crate) rate_limiter: RateLimiter,
53    pub(crate) config: ClientConfig,
54    /// Cache for parsed full-text articles only (keyed by `epmc-ft:<source>:<id>`).
55    pub(crate) cache: Option<PmcCache>,
56}
57
58impl EuropePmcClient {
59    /// Create a new Europe PMC client with default configuration.
60    pub fn new() -> Self {
61        Self::with_config(ClientConfig::new())
62    }
63
64    /// Create a new Europe PMC client with custom configuration.
65    ///
66    /// Transport settings (timeout, user agent, retry, rate limit, cache) are
67    /// taken from `config`. The NCBI-specific `base_url` field is ignored; the
68    /// Europe PMC base URL is used instead.
69    pub fn with_config(config: ClientConfig) -> Self {
70        let rate_limiter = config.create_rate_limiter();
71
72        // rustls has no built-in provider under `rustls-tls`; install one first.
73        install_default_crypto_provider();
74
75        // reqwest's builder only fails if the TLS backend cannot be initialized,
76        // which is an unrecoverable process-level error, so this infallible
77        // constructor is allowed to `expect`.
78        #[allow(clippy::expect_used)]
79        let client = {
80            #[cfg(not(target_arch = "wasm32"))]
81            {
82                Client::builder()
83                    .user_agent(config.effective_user_agent())
84                    .timeout(Duration::from_secs(config.timeout.as_secs()))
85                    .build()
86                    .expect("Failed to create HTTP client")
87            }
88
89            #[cfg(target_arch = "wasm32")]
90            {
91                Client::builder()
92                    .user_agent(config.effective_user_agent())
93                    .build()
94                    .expect("Failed to create HTTP client")
95            }
96        };
97
98        let cache = config.cache_config.as_ref().map(create_cache);
99
100        Self {
101            client,
102            base_url: EUROPE_PMC_BASE_URL.to_string(),
103            rate_limiter,
104            cache,
105            config,
106        }
107    }
108
109    /// Create a new Europe PMC client with a custom HTTP client and default config.
110    pub fn with_client(client: Client) -> Self {
111        let config = ClientConfig::new();
112        let rate_limiter = config.create_rate_limiter();
113
114        Self {
115            client,
116            base_url: EUROPE_PMC_BASE_URL.to_string(),
117            rate_limiter,
118            cache: None,
119            config,
120        }
121    }
122
123    /// Override the base URL (e.g. to target a proxy or a wiremock test server).
124    pub fn with_base_url(mut self, base_url: String) -> Self {
125        self.base_url = base_url;
126        self
127    }
128
129    /// Clear the full-text cache, if one is configured.
130    pub async fn clear_cache(&self) {
131        if let Some(cache) = &self.cache {
132            cache.clear().await;
133            info!("Cleared Europe PMC full-text cache");
134        }
135    }
136
137    /// Return the number of cached full-text entries (best-effort).
138    pub fn cache_entry_count(&self) -> u64 {
139        self.cache.as_ref().map_or(0, |cache| cache.entry_count())
140    }
141
142    /// Flush pending cache operations (useful in tests).
143    pub async fn sync_cache(&self) {
144        if let Some(cache) = &self.cache {
145            cache.sync().await;
146        }
147    }
148
149    /// Build a request executor borrowing this client's HTTP client, rate limiter, and config.
150    pub(crate) fn executor(&self) -> RequestExecutor<'_> {
151        RequestExecutor::new(&self.client, &self.rate_limiter, &self.config)
152    }
153}
154
155impl Default for EuropePmcClient {
156    fn default() -> Self {
157        Self::new()
158    }
159}