Skip to main content

cadmus_core/dictionary/monolingual/
client.rs

1//! HTTP client for monolingual dictionary API operations.
2//!
3//! Only monolingual dictionaries (source language == target language) are
4//! supported. Bilingual pairs present in the API response are ignored.
5
6use super::errors::MonolingualError;
7use super::metadata::DictionariesResponse;
8use crate::db::types::UnixTimestamp;
9use crate::http::Client;
10use chrono::DateTime;
11
12const MONOLINGUAL_API_URL: &str = "https://www.reader-dict.com/api/v1/dictionaries";
13
14/// Monolingual dictionary HTTP client.
15///
16/// Handles all network operations: fetching the remote metadata catalogue and
17/// downloading dictionary archives. All persistence is handled by the service
18/// layer; this type carries no database state.
19#[derive(Clone)]
20pub(super) struct MonolingualClient {
21    http: Client,
22}
23
24impl std::fmt::Debug for MonolingualClient {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("MonolingualClient").finish_non_exhaustive()
27    }
28}
29
30impl MonolingualClient {
31    /// Creates a new monolingual HTTP client.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error if the underlying HTTP client fails to build.
36    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
37    pub(super) fn new() -> Result<Self, MonolingualError> {
38        tracing::debug!("Building monolingual client");
39        let http = Client::new()?;
40        tracing::debug!("Monolingual client built successfully");
41        Ok(Self { http })
42    }
43
44    /// Fetches dictionary metadata from the remote API and returns the parsed
45    /// response.
46    ///
47    /// The caller is responsible for persisting the result to the database.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error if the HTTP request fails or the response cannot be
52    /// parsed.
53    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
54    pub(super) fn fetch_metadata(&self) -> Result<DictionariesResponse, MonolingualError> {
55        tracing::debug!("Fetching monolingual metadata from API");
56
57        let text = self
58            .http
59            .get(MONOLINGUAL_API_URL)
60            .send()
61            .map_err(|e| MonolingualError::Request(e.to_string()))?
62            .error_for_status()
63            .map_err(|e| MonolingualError::Request(e.to_string()))?
64            .text()
65            .map_err(|e| MonolingualError::Request(e.to_string()))?;
66
67        let metadata: DictionariesResponse = serde_json::from_str(&text)?;
68
69        tracing::debug!("Fetched monolingual metadata from API");
70        Ok(metadata)
71    }
72
73    /// Sends a HEAD request with `If-Modified-Since: <since>` and returns
74    /// `false` if the server responds 304 (cache still valid) or `true` if
75    /// the server responds 200 (new data available).
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the HTTP request fails or returns an unexpected
80    /// status code.
81    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(since = %since), ret(level=tracing::Level::TRACE)))]
82    pub(super) fn is_metadata_modified_since(
83        &self,
84        since: UnixTimestamp,
85    ) -> Result<bool, MonolingualError> {
86        let since_str = DateTime::from(since)
87            .format("%a, %d %b %Y %H:%M:%S GMT")
88            .to_string();
89
90        let response = self
91            .http
92            .head(MONOLINGUAL_API_URL)
93            .header("If-Modified-Since", &since_str)
94            .send()
95            .map_err(|e| MonolingualError::Request(e.to_string()))?;
96
97        match response.status() {
98            reqwest::StatusCode::NOT_MODIFIED => Ok(false),
99            s if s.is_success() => Ok(true),
100            s => Err(MonolingualError::Request(format!("unexpected status: {s}"))),
101        }
102    }
103
104    /// Downloads `url` to `dest` using chunked HTTP Range requests.
105    ///
106    /// Issues a minimal `bytes=0-0` Range request first to read `Content-Range`
107    /// and obtain the total file size, then delegates to the chunked
108    /// [`crate::http::Client::download`] method which handles retries and
109    /// adaptive chunk sizing.
110    ///
111    /// `progress_callback` receives `(bytes_downloaded_so_far, total_bytes)`
112    /// after each chunk.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the probe request fails or returns a non-2xx
117    /// status, if the `Content-Range` header is missing, or if the chunked
118    /// download fails.
119    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, progress_callback), fields(url = %url)))]
120    pub(super) fn download<F>(
121        &self,
122        url: &str,
123        dest: &std::path::Path,
124        progress_callback: &mut F,
125    ) -> Result<(), MonolingualError>
126    where
127        F: FnMut(u64, u64),
128    {
129        tracing::debug!(url = %url, "Probing content length");
130
131        let response = self
132            .http
133            .head(url)
134            .header("Range", "bytes=0-0")
135            .send()
136            .map_err(|e| MonolingualError::Request(e.to_string()))?
137            .error_for_status()
138            .map_err(|e| MonolingualError::Request(e.to_string()))?;
139
140        let total_size = response
141            .headers()
142            .get("content-range")
143            .and_then(|v| v.to_str().ok())
144            .and_then(|v| v.split('/').next_back())
145            .and_then(|s| s.parse::<u64>().ok())
146            .ok_or_else(|| MonolingualError::Request("Missing Content-Range header".to_string()))?;
147
148        tracing::debug!(url = %url, total_size, "Starting chunked download");
149
150        self.http
151            .download(
152                url,
153                total_size,
154                &dest.to_path_buf(),
155                |u| self.http.get(u),
156                progress_callback,
157            )
158            .map_err(|e| MonolingualError::Request(e.to_string()))
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn create_test_client() -> MonolingualClient {
167        crate::crypto::init_crypto_provider();
168        MonolingualClient::new().expect("failed to create client")
169    }
170
171    #[test]
172    fn test_client_creation() {
173        create_test_client();
174    }
175
176    /// Fetches live metadata from the monolingual API.
177    ///
178    /// Run with: `cargo test -- --ignored`
179    #[test]
180    #[ignore = "requires network access to www.reader-dict.com"]
181    fn test_fetch_metadata_live() {
182        let client = create_test_client();
183        let result = client.fetch_metadata();
184        assert!(result.is_ok(), "fetch_metadata failed: {:?}", result.err());
185        let metadata = result.unwrap();
186        assert!(
187            !metadata.is_empty(),
188            "Expected at least one language in response"
189        );
190        assert!(
191            metadata.get("en").and_then(|m| m.get("en")).is_some(),
192            "Expected English monolingual dictionary in response"
193        );
194    }
195
196    #[test]
197    #[ignore = "requires network access to www.reader-dict.com"]
198    fn test_is_metadata_modified_since() {
199        let client = create_test_client();
200        let old_ts = UnixTimestamp::from(chrono::NaiveDate::from_ymd_opt(2000, 1, 1).unwrap());
201        let result = client.is_metadata_modified_since(old_ts);
202        assert!(result.is_ok());
203        assert!(
204            result.unwrap(),
205            "expected server to report modified since year 2000"
206        );
207    }
208}