1use 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#[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 #[serde(default)]
45 pub feature_ids: Vec<String>,
46 pub targeting: Option<String>,
47 pub start_date: Option<String>, pub end_date: Option<String>, 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 }
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 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 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 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 #[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 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#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
181pub struct Branch {
182 pub slug: String,
183 pub ratio: i32,
184 #[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 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#[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
248pub 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#[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 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}