1use 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#[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 #[serde(default)]
49 pub feature_ids: Vec<String>,
50 pub targeting: Option<String>,
51 pub start_date: Option<String>, pub end_date: Option<String>, 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 #[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 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 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 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 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 #[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 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#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
237pub struct Branch {
238 pub slug: String,
239 pub ratio: i32,
240 #[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 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#[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
304pub 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#[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 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}