cadmus_core/dictionary/monolingual/
client.rs1use 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#[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 #[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 #[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 #[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 #[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 #[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}