1use std::{collections::HashMap, fs::File, io::prelude::Write, sync::Arc};
6
7use error_support::{convert_log_report_error, handle_error};
8
9pub mod cache;
10pub mod client;
11pub mod config;
12pub mod context;
13pub mod error;
14pub mod schema;
15pub mod service;
16#[cfg(feature = "signatures")]
17pub(crate) mod signatures;
18pub mod storage;
19
20pub(crate) mod jexl_filter;
21mod macros;
22
23pub use client::{Attachment, RemoteSettingsRecord, RemoteSettingsResponse, RsJsonObject};
24pub use config::{BaseUrl, RemoteSettingsConfig, RemoteSettingsConfig2, RemoteSettingsServer};
25pub use context::RemoteSettingsContext;
26pub use error::{trace, ApiResult, RemoteSettingsError, Result};
27
28use client::Client;
29use error::Error;
30use storage::Storage;
31
32uniffi::setup_scaffolding!("remote_settings");
33
34#[derive(uniffi::Object)]
39pub struct RemoteSettingsService {
40 internal: service::RemoteSettingsService,
42}
43
44#[uniffi::export]
45impl RemoteSettingsService {
46 #[uniffi::constructor]
57 pub fn new(storage_dir: String, config: RemoteSettingsConfig2) -> Self {
58 Self {
59 internal: service::RemoteSettingsService::new(storage_dir, config),
60 }
61 }
62
63 pub fn make_client(&self, collection_name: String) -> Arc<RemoteSettingsClient> {
67 self.internal.make_client(collection_name)
68 }
69
70 #[handle_error(Error)]
72 pub fn sync(&self) -> ApiResult<Vec<String>> {
73 self.internal.sync()
74 }
75
76 #[handle_error(Error)]
84 pub fn update_config(&self, config: RemoteSettingsConfig2) -> ApiResult<()> {
85 self.internal.update_config(config)
86 }
87}
88
89#[derive(uniffi::Object)]
93pub struct RemoteSettingsClient {
94 internal: client::RemoteSettingsClient,
96}
97
98#[uniffi::export]
99impl RemoteSettingsClient {
100 pub fn collection_name(&self) -> String {
102 self.internal.collection_name().to_owned()
103 }
104
105 #[uniffi::method(default(sync_if_empty = false))]
122 pub fn get_records(&self, sync_if_empty: bool) -> Option<Vec<RemoteSettingsRecord>> {
123 match self.internal.get_records(sync_if_empty) {
124 Ok(records) => records,
125 Err(e) => {
126 trace!("get_records error: {e}");
128 convert_log_report_error(e);
129 None
132 }
133 }
134 }
135
136 #[uniffi::method(default(sync_if_empty = false))]
141 pub fn get_records_map(
142 &self,
143 sync_if_empty: bool,
144 ) -> Option<HashMap<String, RemoteSettingsRecord>> {
145 self.get_records(sync_if_empty)
146 .map(|records| records.into_iter().map(|r| (r.id.clone(), r)).collect())
147 }
148
149 #[handle_error(Error)]
159 pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> ApiResult<Vec<u8>> {
160 self.internal.get_attachment(record)
161 }
162
163 #[handle_error(Error)]
164 pub fn sync(&self) -> ApiResult<()> {
165 self.internal.sync()
166 }
167
168 pub fn shutdown(&self) {
170 self.internal.shutdown()
171 }
172}
173
174impl RemoteSettingsClient {
175 fn new(
178 base_url: BaseUrl,
179 bucket_name: String,
180 collection_name: String,
181 #[allow(unused)] context: Option<RemoteSettingsContext>,
182 storage: Storage,
183 ) -> Self {
184 Self {
185 internal: client::RemoteSettingsClient::new(
186 base_url,
187 bucket_name,
188 collection_name,
189 context,
190 storage,
191 ),
192 }
193 }
194}
195
196#[derive(uniffi::Object)]
197pub struct RemoteSettings {
198 pub config: RemoteSettingsConfig,
199 client: Client,
200}
201
202#[uniffi::export]
203impl RemoteSettings {
204 #[uniffi::constructor]
206 #[handle_error(Error)]
207 pub fn new(remote_settings_config: RemoteSettingsConfig) -> ApiResult<Self> {
208 Ok(RemoteSettings {
209 config: remote_settings_config.clone(),
210 client: Client::new(remote_settings_config)?,
211 })
212 }
213
214 #[handle_error(Error)]
216 pub fn get_records(&self) -> ApiResult<RemoteSettingsResponse> {
217 let resp = self.client.get_records()?;
218 Ok(resp)
219 }
220
221 #[handle_error(Error)]
224 pub fn get_records_since(&self, timestamp: u64) -> ApiResult<RemoteSettingsResponse> {
225 let resp = self.client.get_records_since(timestamp)?;
226 Ok(resp)
227 }
228
229 #[handle_error(Error)]
231 pub fn download_attachment_to_path(
232 &self,
233 attachment_id: String,
234 path: String,
235 ) -> ApiResult<()> {
236 let resp = self.client.get_attachment(&attachment_id)?;
237 let mut file = File::create(path).map_err(Error::AttachmentFileError)?;
238 file.write_all(&resp).map_err(Error::AttachmentFileError)?;
239 Ok(())
240 }
241}
242
243impl RemoteSettings {
248 #[handle_error(Error)]
252 pub fn get_records_raw(&self) -> ApiResult<viaduct::Response> {
253 self.client.get_records_raw()
254 }
255
256 #[handle_error(Error)]
260 pub fn get_attachment(&self, attachment_location: &str) -> ApiResult<Vec<u8>> {
261 self.client.get_attachment(attachment_location)
262 }
263}
264
265#[cfg(test)]
266mod test {
267 use super::*;
268 use crate::RemoteSettingsRecord;
269 use mockito::{mock, Matcher};
270
271 #[test]
272 fn test_get_records() {
273 viaduct_reqwest::use_reqwest_backend();
274 let m = mock(
275 "GET",
276 "/v1/buckets/the-bucket/collections/the-collection/records",
277 )
278 .with_body(response_body())
279 .with_status(200)
280 .with_header("content-type", "application/json")
281 .with_header("etag", "\"1000\"")
282 .create();
283
284 let config = RemoteSettingsConfig {
285 server: Some(RemoteSettingsServer::Custom {
286 url: mockito::server_url(),
287 }),
288 server_url: None,
289 bucket_name: Some(String::from("the-bucket")),
290 collection_name: String::from("the-collection"),
291 };
292 let remote_settings = RemoteSettings::new(config).unwrap();
293
294 let resp = remote_settings.get_records().unwrap();
295
296 assert!(are_equal_json(JPG_ATTACHMENT, &resp.records[0]));
297 assert_eq!(1000, resp.last_modified);
298 m.expect(1).assert();
299 }
300
301 #[test]
302 fn test_get_records_since() {
303 viaduct_reqwest::use_reqwest_backend();
304 let m = mock(
305 "GET",
306 "/v1/buckets/the-bucket/collections/the-collection/records",
307 )
308 .match_query(Matcher::UrlEncoded("gt_last_modified".into(), "500".into()))
309 .with_body(response_body())
310 .with_status(200)
311 .with_header("content-type", "application/json")
312 .with_header("etag", "\"1000\"")
313 .create();
314
315 let config = RemoteSettingsConfig {
316 server: Some(RemoteSettingsServer::Custom {
317 url: mockito::server_url(),
318 }),
319 server_url: None,
320 bucket_name: Some(String::from("the-bucket")),
321 collection_name: String::from("the-collection"),
322 };
323 let remote_settings = RemoteSettings::new(config).unwrap();
324
325 let resp = remote_settings.get_records_since(500).unwrap();
326 assert!(are_equal_json(JPG_ATTACHMENT, &resp.records[0]));
327 assert_eq!(1000, resp.last_modified);
328 m.expect(1).assert();
329 }
330
331 #[allow(dead_code)]
337 fn test_download() {
338 viaduct_reqwest::use_reqwest_backend();
339 let config = RemoteSettingsConfig {
340 server: Some(RemoteSettingsServer::Custom {
341 url: "http://localhost:8888".into(),
342 }),
343 server_url: None,
344 bucket_name: Some(String::from("the-bucket")),
345 collection_name: String::from("the-collection"),
346 };
347 let remote_settings = RemoteSettings::new(config).unwrap();
348
349 remote_settings
350 .download_attachment_to_path(
351 "d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg".to_string(),
352 "test.jpg".to_string(),
353 )
354 .unwrap();
355 }
356
357 fn are_equal_json(str: &str, rec: &RemoteSettingsRecord) -> bool {
358 let r1: RemoteSettingsRecord = serde_json::from_str(str).unwrap();
359 &r1 == rec
360 }
361
362 fn response_body() -> String {
363 format!(
364 r#"
365 {{
366 "data": [
367 {},
368 {},
369 {}
370 ]
371 }}"#,
372 JPG_ATTACHMENT, PDF_ATTACHMENT, NO_ATTACHMENT
373 )
374 }
375
376 const JPG_ATTACHMENT: &str = r#"
377 {
378 "title": "jpg-attachment",
379 "content": "content",
380 "attachment": {
381 "filename": "jgp-attachment.jpg",
382 "location": "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
383 "hash": "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
384 "mimetype": "image/jpeg",
385 "size": 1374325
386 },
387 "id": "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
388 "schema": 1677694447771,
389 "last_modified": 1677694949407
390 }
391 "#;
392
393 const PDF_ATTACHMENT: &str = r#"
394 {
395 "title": "with-attachment",
396 "content": "content",
397 "attachment": {
398 "filename": "pdf-attachment.pdf",
399 "location": "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
400 "hash": "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
401 "mimetype": "application/pdf",
402 "size": 157
403 },
404 "id": "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
405 "schema": 1677694447771,
406 "last_modified": 1677694470354
407 }
408 "#;
409
410 const NO_ATTACHMENT: &str = r#"
411 {
412 "title": "no-attachment",
413 "content": "content",
414 "schema": 1677694447771,
415 "id": "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
416 "last_modified": 1677694455368
417 }
418 "#;
419}