nimbus/
schema.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 https://mozilla.org/MPL/2.0/.
4
5use std::collections::{BTreeSet, HashMap};
6
7use serde_derive::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9use uuid::Uuid;
10
11use crate::defaults::Defaults;
12use crate::enrollment::ExperimentMetadata;
13use crate::error::{trace, warn};
14#[cfg(feature = "stateful")]
15use crate::stateful::firefox_labs::{FIREFOX_LABS_FEEDBACK_URL_KEY, FirefoxLabsMetadata};
16use crate::{NimbusError, Result};
17
18const DEFAULT_TOTAL_BUCKETS: u32 = 10000;
19
20#[derive(Debug, Clone)]
21#[cfg_attr(test, derive(Eq, PartialEq))]
22pub struct EnrolledExperiment {
23    pub feature_ids: Vec<String>,
24    pub slug: String,
25    pub user_facing_name: String,
26    pub user_facing_description: String,
27    pub branch_slug: String,
28    pub is_rollout: bool,
29}
30
31// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
32// ⚠️ in `test_lib_bw_compat.rs`, and may require a DB migration. ⚠️
33#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
34#[serde(rename_all = "camelCase")]
35pub struct Experiment {
36    pub schema_version: String,
37    pub slug: String,
38    pub app_name: Option<String>,
39    pub app_id: Option<String>,
40    pub channel: Option<String>,
41    pub user_facing_name: String,
42    pub user_facing_description: String,
43    pub is_enrollment_paused: bool,
44    pub bucket_config: BucketConfig,
45    pub branches: Vec<Branch>,
46    // The `feature_ids` field was added later. For compatibility with existing experiments
47    // and to avoid a db migration, we default it to an empty list when it is missing.
48    #[serde(default)]
49    pub feature_ids: Vec<String>,
50    pub targeting: Option<String>,
51    pub start_date: Option<String>, // TODO: Use a date format here
52    pub end_date: Option<String>,   // TODO: Use a date format here
53    pub proposed_duration: Option<u32>,
54    pub proposed_enrollment: u32,
55    pub reference_branch: Option<String>,
56    #[serde(default)]
57    pub is_rollout: bool,
58    pub published_date: Option<chrono::DateTime<chrono::Utc>>,
59    // N.B. records in RemoteSettings will have `id` and `filter_expression` fields,
60    // but we ignore them because they're for internal use by RemoteSettings.
61    #[serde(default)]
62    pub is_firefox_labs_opt_in: bool,
63
64    #[serde(default)]
65    pub firefox_labs_title: Option<String>,
66
67    #[serde(default)]
68    pub firefox_labs_description: Option<String>,
69
70    #[serde(default)]
71    pub firefox_labs_description_links: Option<HashMap<String, String>>,
72
73    #[serde(default)]
74    pub requires_restart: bool,
75}
76
77#[cfg_attr(not(feature = "stateful"), allow(unused))]
78impl Experiment {
79    pub(crate) fn has_branch(&self, branch_slug: &str) -> bool {
80        self.branches
81            .iter()
82            .any(|branch| branch.slug == branch_slug)
83    }
84
85    pub(crate) fn get_branch(&self, branch_slug: &str) -> Option<&Branch> {
86        self.branches.iter().find(|b| b.slug == branch_slug)
87    }
88
89    pub(crate) fn get_feature_ids(&self) -> Vec<String> {
90        let branches = &self.branches;
91        let feature_ids = branches
92            .iter()
93            .flat_map(|b| {
94                b.get_feature_configs()
95                    .iter()
96                    .map(|f| f.feature_id.clone())
97                    .collect::<Vec<_>>()
98            })
99            .collect::<BTreeSet<_>>();
100
101        // Using a BTreeSet generates the feature IDs in a sorted order, which helps
102        // make testing easier.
103        feature_ids.into_iter().collect()
104    }
105
106    #[cfg(test)]
107    pub(crate) fn patch(&self, patch: Value) -> Self {
108        let mut experiment = serde_json::to_value(self).unwrap();
109        if let (Some(e), Some(w)) = (experiment.as_object(), patch.as_object()) {
110            let mut e = e.clone();
111            for (key, value) in w {
112                e.insert(key.clone(), value.clone());
113            }
114            experiment = serde_json::to_value(e).unwrap();
115        }
116        serde_json::from_value(experiment).unwrap()
117    }
118
119    #[cfg(feature = "stateful")]
120    pub(crate) fn get_firefox_labs_metadata(&self, enrolled: bool) -> Option<FirefoxLabsMetadata> {
121        // We do not enforce at a schema level that is_firefox_labs_opt_in
122        // implies is_rollout, but only rollouts are supported so we must
123        // enforce it here.
124        if self.is_firefox_labs_opt_in
125            && self.is_rollout
126            && self.branches.len() == 1
127            && let Some(title) = self.firefox_labs_title.as_deref()
128            && let Some(description) = self.firefox_labs_description.as_deref()
129        {
130            let feedback_url = self
131                .firefox_labs_description_links
132                .as_ref()
133                .and_then(|links| links.get(FIREFOX_LABS_FEEDBACK_URL_KEY).cloned());
134
135            Some(FirefoxLabsMetadata {
136                slug: self.slug.clone(),
137                title_string_id: title.into(),
138                description_string_id: description.into(),
139                feedback_url,
140                enrolled,
141                requires_restart: self.requires_restart,
142            })
143        } else {
144            None
145        }
146    }
147
148    #[cfg(feature = "stateful")]
149    pub(crate) fn is_valid_firefox_lab(&self) -> bool {
150        self.is_firefox_labs_opt_in
151            && self.is_rollout
152            && self.branches.len() == 1
153            && self.firefox_labs_title.is_some()
154            && self.firefox_labs_description.is_some()
155    }
156}
157
158impl ExperimentMetadata for Experiment {
159    fn get_slug(&self) -> String {
160        self.slug.clone()
161    }
162
163    fn is_rollout(&self) -> bool {
164        self.is_rollout
165    }
166}
167
168pub fn parse_experiments(payload: &str) -> Result<Vec<Experiment>> {
169    // We first encode the response into a `serde_json::Value`
170    // to allow us to deserialize each experiment individually,
171    // omitting any malformed experiments
172    let value: Value = match serde_json::from_str(payload) {
173        Ok(v) => v,
174        Err(e) => {
175            return Err(NimbusError::JSONError(
176                "value = nimbus::schema::parse_experiments::serde_json::from_str".into(),
177                e.to_string(),
178            ));
179        }
180    };
181    let data = value
182        .get("data")
183        .ok_or(NimbusError::InvalidExperimentFormat)?;
184    let mut res = Vec::new();
185    for exp in data
186        .as_array()
187        .ok_or(NimbusError::InvalidExperimentFormat)?
188    {
189        // XXX: In the future it would be nice if this lived in its own versioned crate so that
190        // the schema could be decoupled from the sdk so that it can be iterated on while the
191        // sdk depends on a particular version of the schema through the Cargo.toml.
192        match serde_json::from_value::<Experiment>(exp.clone()) {
193            Ok(exp) => res.push(exp),
194            Err(e) => {
195                trace!("Malformed experiment data: {:#?}", exp);
196                warn!(
197                    "Malformed experiment found! Experiment {},  Error: {}",
198                    exp.get("id").unwrap_or(&serde_json::json!("ID_NOT_FOUND")),
199                    e
200                );
201            }
202        }
203    }
204    Ok(res)
205}
206
207#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
208#[serde(rename_all = "camelCase")]
209pub struct FeatureConfig {
210    pub feature_id: String,
211    // There is a nullable `value` field that can contain key-value config options
212    // that modify the behaviour of an application feature. Uniffi doesn't quite support
213    // serde_json yet.
214    #[serde(default)]
215    pub value: Map<String, Value>,
216}
217
218impl Defaults for FeatureConfig {
219    fn defaults(&self, fallback: &Self) -> Result<Self> {
220        if self.feature_id != fallback.feature_id {
221            // This is unlikely to happen, but if it does it's a bug in Nimbus
222            Err(NimbusError::InternalError(
223                "Cannot merge feature configs from different features",
224            ))
225        } else {
226            Ok(FeatureConfig {
227                feature_id: self.feature_id.clone(),
228                value: self.value.defaults(&fallback.value)?,
229            })
230        }
231    }
232}
233
234// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
235// ⚠️ in `test_lib_bw_compat.rs`, and may require a DB migration. ⚠️
236#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
237pub struct Branch {
238    pub slug: String,
239    pub ratio: i32,
240    // we skip serializing the `feature` and `features`
241    // fields if they are `None`, to stay aligned
242    // with the schema, where only one of them
243    // will exist
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub feature: Option<FeatureConfig>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub features: Option<Vec<FeatureConfig>>,
248}
249
250impl Branch {
251    pub(crate) fn get_feature_configs(&self) -> Vec<FeatureConfig> {
252        // Some versions of desktop need both, but features should be prioritized
253        // (https://mozilla-hub.atlassian.net/browse/SDK-440).
254        match (&self.features, &self.feature) {
255            (Some(features), _) => features.clone(),
256            (None, Some(feature)) => vec![feature.clone()],
257            _ => Default::default(),
258        }
259    }
260
261    #[cfg(feature = "stateful")]
262    pub(crate) fn get_feature_props_and_values(&self) -> Vec<(String, String, Value)> {
263        self.get_feature_configs()
264            .iter()
265            .flat_map(|fc| {
266                fc.value
267                    .iter()
268                    .map(|(k, v)| (fc.feature_id.clone(), k.clone(), v.clone()))
269            })
270            .collect()
271    }
272}
273
274fn default_buckets() -> u32 {
275    DEFAULT_TOTAL_BUCKETS
276}
277
278// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
279// ⚠️ in `test_lib_bw_compat.rs`, and may require a DB migration. ⚠️
280#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
281#[serde(rename_all = "camelCase")]
282pub struct BucketConfig {
283    pub randomization_unit: RandomizationUnit,
284    pub namespace: String,
285    pub start: u32,
286    pub count: u32,
287    #[serde(default = "default_buckets")]
288    pub total: u32,
289}
290
291#[allow(unused)]
292#[cfg(test)]
293impl BucketConfig {
294    pub(crate) fn always() -> Self {
295        Self {
296            start: 0,
297            count: default_buckets(),
298            total: default_buckets(),
299            ..Default::default()
300        }
301    }
302}
303
304// This type is passed across the FFI to client consumers, e.g. UI for testing tooling.
305pub struct AvailableExperiment {
306    pub slug: String,
307    pub user_facing_name: String,
308    pub user_facing_description: String,
309    pub branches: Vec<ExperimentBranch>,
310    pub reference_branch: Option<String>,
311}
312
313pub struct ExperimentBranch {
314    pub slug: String,
315    pub ratio: i32,
316}
317
318impl From<Experiment> for AvailableExperiment {
319    fn from(exp: Experiment) -> Self {
320        Self {
321            slug: exp.slug,
322            user_facing_name: exp.user_facing_name,
323            user_facing_description: exp.user_facing_description,
324            branches: exp.branches.into_iter().map(|b| b.into()).collect(),
325            reference_branch: exp.reference_branch,
326        }
327    }
328}
329
330impl From<Branch> for ExperimentBranch {
331    fn from(branch: Branch) -> Self {
332        Self {
333            slug: branch.slug,
334            ratio: branch.ratio,
335        }
336    }
337}
338
339// ⚠️ Attention : Changes to this type should be accompanied by a new test  ⚠️
340// ⚠️ in `test_lib_bw_compat`, and may require a DB migration. ⚠️
341#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
342#[serde(rename_all = "snake_case")]
343#[derive(Default)]
344pub enum RandomizationUnit {
345    #[default]
346    NimbusId,
347    UserId,
348}
349
350#[derive(Default)]
351pub struct AvailableRandomizationUnits {
352    pub user_id: Option<String>,
353    pub nimbus_id: Option<String>,
354}
355
356impl AvailableRandomizationUnits {
357    // Use ::with_user_id when you want to specify one, or use
358    // Default::default if you don't!
359    pub fn with_user_id(user_id: &str) -> Self {
360        Self {
361            user_id: Some(user_id.to_string()),
362            nimbus_id: None,
363        }
364    }
365
366    pub fn with_nimbus_id(nimbus_id: &Uuid) -> Self {
367        Self {
368            user_id: None,
369            nimbus_id: Some(nimbus_id.to_string()),
370        }
371    }
372
373    pub fn apply_nimbus_id(&self, nimbus_id: &Uuid) -> Self {
374        Self {
375            user_id: self.user_id.clone(),
376            nimbus_id: Some(nimbus_id.to_string()),
377        }
378    }
379
380    pub fn get_value<'a>(&'a self, wanted: &'a RandomizationUnit) -> Option<&'a str> {
381        match wanted {
382            RandomizationUnit::NimbusId => self.nimbus_id.as_deref(),
383            RandomizationUnit::UserId => self.user_id.as_deref(),
384        }
385    }
386}