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