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