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