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