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}