remote_settings/
lib.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use 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/// Application-level Remote Settings manager.
35///
36/// This handles application-level operations, like syncing all the collections, and acts as a
37/// factory for creating clients.
38#[derive(uniffi::Object)]
39pub struct RemoteSettingsService {
40    // This struct adapts server::RemoteSettingsService into the public API
41    internal: service::RemoteSettingsService,
42}
43
44#[uniffi::export]
45impl RemoteSettingsService {
46    /// Construct a [RemoteSettingsService]
47    ///
48    /// This is typically done early in the application-startup process.
49    ///
50    /// This method performs no IO or network requests and is safe to run in a main thread that
51    /// can't be blocked.
52    ///
53    /// `storage_dir` is a directory to store SQLite files in -- one per collection. If the
54    /// directory does not exist, it will be created when the storage is first used. Only the
55    /// directory and the SQLite files will be created, any parent directories must already exist.
56    #[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    /// Create a new Remote Settings client
64    ///
65    /// This method performs no IO or network requests and is safe to run in a main thread that can't be blocked.
66    pub fn make_client(&self, collection_name: String) -> Arc<RemoteSettingsClient> {
67        self.internal.make_client(collection_name)
68    }
69
70    /// Sync collections for all active clients
71    ///
72    /// The returned list is the list of collections for which updates were seen
73    /// and then synced.
74    #[handle_error(Error)]
75    pub fn sync(&self) -> ApiResult<Vec<String>> {
76        self.internal.sync()
77    }
78
79    /// Update the remote settings config
80    ///
81    /// This will cause all current and future clients to use new config and will delete any stored
82    /// records causing the clients to return new results from the new config.
83    ///
84    /// Only intended for QA/debugging.  Swapping the remote settings server in the middle of
85    /// execution can cause weird effects.
86    #[handle_error(Error)]
87    pub fn update_config(&self, config: RemoteSettingsConfig2) -> ApiResult<()> {
88        self.internal.update_config(config)
89    }
90
91    pub fn client_url(&self) -> String {
92        self.internal.client_url().to_string()
93    }
94}
95
96/// Client for a single Remote Settings collection
97///
98/// Use [RemoteSettingsService::make_client] to create these.
99#[derive(uniffi::Object)]
100pub struct RemoteSettingsClient {
101    // This struct adapts client::RemoteSettingsClient into the public API
102    internal: client::RemoteSettingsClient,
103}
104
105#[uniffi::export]
106impl RemoteSettingsClient {
107    /// Collection this client is for
108    pub fn collection_name(&self) -> String {
109        self.internal.collection_name().to_owned()
110    }
111
112    /// Get the current set of records.
113    ///
114    /// This method normally fetches records from the last sync.  This means that it returns fast
115    /// and does not make any network requests.
116    ///
117    /// If records have not yet been synced it will return None.  Use `sync_if_empty = true` to
118    /// change this behavior and perform a network request in this case.  That this is probably a
119    /// bad idea if you want to fetch the setting in application startup or when building the UI.
120    ///
121    /// None will also be returned on disk IO errors or other unexpected errors.  The reason for
122    /// this is that there is not much an application can do in this situation other than fall back
123    /// to the same default handling as if records have not been synced.
124    ///
125    /// Application-services schedules regular dumps of the server data for specific collections.
126    /// For these collections, `get_records` will never return None.  If you would like to add your
127    /// collection to this list, please reach out to the DISCO team.
128    #[uniffi::method(default(sync_if_empty = false))]
129    pub fn get_records(&self, sync_if_empty: bool) -> Option<Vec<RemoteSettingsRecord>> {
130        match self.internal.get_records(sync_if_empty) {
131            Ok(records) => records,
132            Err(e) => {
133                // Log/report the error
134                trace!("get_records error: {e}");
135                convert_log_report_error(e);
136                // Throw away the converted result and return None, there's nothing a client can
137                // really do with an error except treat it as the None case
138                None
139            }
140        }
141    }
142
143    /// Get the current set of records as a map of record_id -> record.
144    ///
145    /// See [Self::get_records] for an explanation of when this makes network requests, error
146    /// handling, and how the `sync_if_empty` param works.
147    #[uniffi::method(default(sync_if_empty = false))]
148    pub fn get_records_map(
149        &self,
150        sync_if_empty: bool,
151    ) -> Option<HashMap<String, RemoteSettingsRecord>> {
152        self.get_records(sync_if_empty)
153            .map(|records| records.into_iter().map(|r| (r.id.clone(), r)).collect())
154    }
155
156    /// Get attachment data for a remote settings record
157    ///
158    /// Attachments are large binary blobs used for data that doesn't fit in a normal record.  They
159    /// are handled differently than other record data:
160    ///
161    ///   - Attachments are not downloaded in [RemoteSettingsService::sync]
162    ///   - This method will make network requests if the attachment is not cached
163    ///   - This method will throw if there is a network or other error when fetching the
164    ///     attachment data.
165    #[handle_error(Error)]
166    pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> ApiResult<Vec<u8>> {
167        self.internal.get_attachment(record)
168    }
169
170    #[handle_error(Error)]
171    pub fn sync(&self) -> ApiResult<()> {
172        self.internal.sync()
173    }
174
175    #[handle_error(Error)]
176    pub fn reset_storage(&self) -> ApiResult<()> {
177        self.internal.reset_storage()
178    }
179
180    /// Shutdown the client, releasing the SQLite connection used to cache records.
181    pub fn shutdown(&self) {
182        self.internal.shutdown()
183    }
184}
185
186impl RemoteSettingsClient {
187    /// Create a new client.  This is not exposed to foreign code, consumers need to call
188    /// [RemoteSettingsService::make_client]
189    fn new(
190        base_url: BaseUrl,
191        bucket_name: String,
192        collection_name: String,
193        #[allow(unused)] context: Option<RemoteSettingsContext>,
194        storage: Storage,
195    ) -> Self {
196        Self {
197            internal: client::RemoteSettingsClient::new(
198                base_url,
199                bucket_name,
200                collection_name,
201                context,
202                storage,
203            ),
204        }
205    }
206}
207
208#[derive(uniffi::Object)]
209pub struct RemoteSettings {
210    pub config: RemoteSettingsConfig,
211    client: Client,
212}
213
214#[uniffi::export]
215impl RemoteSettings {
216    /// Construct a new Remote Settings client with the given configuration.
217    #[uniffi::constructor]
218    #[handle_error(Error)]
219    pub fn new(remote_settings_config: RemoteSettingsConfig) -> ApiResult<Self> {
220        Ok(RemoteSettings {
221            config: remote_settings_config.clone(),
222            client: Client::new(remote_settings_config)?,
223        })
224    }
225
226    /// Fetch all records for the configuration this client was initialized with.
227    #[handle_error(Error)]
228    pub fn get_records(&self) -> ApiResult<RemoteSettingsResponse> {
229        let resp = self.client.get_records()?;
230        Ok(resp)
231    }
232
233    /// Fetch all records added to the server since the provided timestamp,
234    /// using the configuration this client was initialized with.
235    #[handle_error(Error)]
236    pub fn get_records_since(&self, timestamp: u64) -> ApiResult<RemoteSettingsResponse> {
237        let resp = self.client.get_records_since(timestamp)?;
238        Ok(resp)
239    }
240
241    /// Download an attachment with the provided id to the provided path.
242    #[handle_error(Error)]
243    pub fn download_attachment_to_path(
244        &self,
245        attachment_id: String,
246        path: String,
247    ) -> ApiResult<()> {
248        let resp = self.client.get_attachment(&attachment_id)?;
249        let mut file = File::create(path).map_err(Error::AttachmentFileError)?;
250        file.write_all(&resp).map_err(Error::AttachmentFileError)?;
251        Ok(())
252    }
253}
254
255// Public functions that we don't expose via UniFFI.
256//
257// The long-term plan is to create a new remote settings client, transition nimbus + suggest to the
258// new API, then delete this code.
259impl RemoteSettings {
260    /// Fetches all records for a collection that can be found in the server,
261    /// bucket, and collection defined by the [ClientConfig] used to generate
262    /// this [Client]. This function will return the raw viaduct [Response].
263    #[handle_error(Error)]
264    pub fn get_records_raw(&self) -> ApiResult<viaduct::Response> {
265        self.client.get_records_raw()
266    }
267
268    /// Downloads an attachment from [attachment_location]. NOTE: there are no
269    /// guarantees about a maximum size, so use care when fetching potentially
270    /// large attachments.
271    #[handle_error(Error)]
272    pub fn get_attachment(&self, attachment_location: &str) -> ApiResult<Vec<u8>> {
273        self.client.get_attachment(attachment_location)
274    }
275}
276
277#[cfg(test)]
278mod test {
279    use super::*;
280    use crate::RemoteSettingsRecord;
281    use mockito::{mock, Matcher};
282
283    #[test]
284    fn test_get_records() {
285        viaduct_dev::init_backend_dev();
286        let m = mock(
287            "GET",
288            "/v1/buckets/the-bucket/collections/the-collection/records",
289        )
290        .with_body(response_body())
291        .with_status(200)
292        .with_header("content-type", "application/json")
293        .with_header("etag", "\"1000\"")
294        .create();
295
296        let config = RemoteSettingsConfig {
297            server: Some(RemoteSettingsServer::Custom {
298                url: mockito::server_url(),
299            }),
300            server_url: None,
301            bucket_name: Some(String::from("the-bucket")),
302            collection_name: String::from("the-collection"),
303        };
304        let remote_settings = RemoteSettings::new(config).unwrap();
305
306        let resp = remote_settings.get_records().unwrap();
307
308        assert!(are_equal_json(JPG_ATTACHMENT, &resp.records[0]));
309        assert_eq!(1000, resp.last_modified);
310        m.expect(1).assert();
311    }
312
313    #[test]
314    fn test_get_records_since() {
315        viaduct_dev::init_backend_dev();
316        let m = mock(
317            "GET",
318            "/v1/buckets/the-bucket/collections/the-collection/records",
319        )
320        .match_query(Matcher::UrlEncoded("gt_last_modified".into(), "500".into()))
321        .with_body(response_body())
322        .with_status(200)
323        .with_header("content-type", "application/json")
324        .with_header("etag", "\"1000\"")
325        .create();
326
327        let config = RemoteSettingsConfig {
328            server: Some(RemoteSettingsServer::Custom {
329                url: mockito::server_url(),
330            }),
331            server_url: None,
332            bucket_name: Some(String::from("the-bucket")),
333            collection_name: String::from("the-collection"),
334        };
335        let remote_settings = RemoteSettings::new(config).unwrap();
336
337        let resp = remote_settings.get_records_since(500).unwrap();
338        assert!(are_equal_json(JPG_ATTACHMENT, &resp.records[0]));
339        assert_eq!(1000, resp.last_modified);
340        m.expect(1).assert();
341    }
342
343    // This test was designed as a proof-of-concept and requires a locally-run Remote Settings server.
344    // If this were to be included in CI, it would require pulling the RS docker image and scripting
345    // its configuration, as well as dynamically finding the attachment id, which would more closely
346    // mimic a real world usecase.
347    // #[test]
348    #[allow(dead_code)]
349    fn test_download() {
350        viaduct_dev::init_backend_dev();
351        let config = RemoteSettingsConfig {
352            server: Some(RemoteSettingsServer::Custom {
353                url: "http://localhost:8888".into(),
354            }),
355            server_url: None,
356            bucket_name: Some(String::from("the-bucket")),
357            collection_name: String::from("the-collection"),
358        };
359        let remote_settings = RemoteSettings::new(config).unwrap();
360
361        remote_settings
362            .download_attachment_to_path(
363                "d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg".to_string(),
364                "test.jpg".to_string(),
365            )
366            .unwrap();
367    }
368
369    fn are_equal_json(str: &str, rec: &RemoteSettingsRecord) -> bool {
370        let r1: RemoteSettingsRecord = serde_json::from_str(str).unwrap();
371        &r1 == rec
372    }
373
374    fn response_body() -> String {
375        format!(
376            r#"
377        {{
378            "data": [
379                {},
380                {},
381                {}
382            ]
383          }}"#,
384            JPG_ATTACHMENT, PDF_ATTACHMENT, NO_ATTACHMENT
385        )
386    }
387
388    const JPG_ATTACHMENT: &str = r#"
389          {
390            "title": "jpg-attachment",
391            "content": "content",
392            "attachment": {
393            "filename": "jgp-attachment.jpg",
394            "location": "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
395            "hash": "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
396            "mimetype": "image/jpeg",
397            "size": 1374325
398            },
399            "id": "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
400            "schema": 1677694447771,
401            "last_modified": 1677694949407
402          }
403        "#;
404
405    const PDF_ATTACHMENT: &str = r#"
406          {
407            "title": "with-attachment",
408            "content": "content",
409            "attachment": {
410                "filename": "pdf-attachment.pdf",
411                "location": "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
412                "hash": "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
413                "mimetype": "application/pdf",
414                "size": 157
415            },
416            "id": "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
417            "schema": 1677694447771,
418            "last_modified": 1677694470354
419          }
420        "#;
421
422    const NO_ATTACHMENT: &str = r#"
423          {
424            "title": "no-attachment",
425            "content": "content",
426            "schema": 1677694447771,
427            "id": "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
428            "last_modified": 1677694455368
429          }
430        "#;
431}