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