1use 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#[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 #[serde(default)]
41 pub feature_ids: Vec<String>,
42 pub targeting: Option<String>,
43 pub start_date: Option<String>, pub end_date: Option<String>, 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 }
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 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 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 #[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 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#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
175pub struct Branch {
176 pub slug: String,
177 pub ratio: i32,
178 #[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 match (&self.features, &self.feature) {
193 (Some(features), _) => features.clone(),
194 (None, Some(feature)) => vec![feature.clone()],
195 _ => Default::default(),
196 }
197 }
198
199 #[cfg(feature = "stateful")]
200 pub(crate) fn get_feature_props_and_values(&self) -> Vec<(String, String, Value)> {
201 self.get_feature_configs()
202 .iter()
203 .flat_map(|fc| {
204 fc.value
205 .iter()
206 .map(|(k, v)| (fc.feature_id.clone(), k.clone(), v.clone()))
207 })
208 .collect()
209 }
210}
211
212fn default_buckets() -> u32 {
213 DEFAULT_TOTAL_BUCKETS
214}
215
216#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
219#[serde(rename_all = "camelCase")]
220pub struct BucketConfig {
221 pub randomization_unit: RandomizationUnit,
222 pub namespace: String,
223 pub start: u32,
224 pub count: u32,
225 #[serde(default = "default_buckets")]
226 pub total: u32,
227}
228
229#[allow(unused)]
230#[cfg(test)]
231impl BucketConfig {
232 pub(crate) fn always() -> Self {
233 Self {
234 start: 0,
235 count: default_buckets(),
236 total: default_buckets(),
237 ..Default::default()
238 }
239 }
240}
241
242pub struct AvailableExperiment {
244 pub slug: String,
245 pub user_facing_name: String,
246 pub user_facing_description: String,
247 pub branches: Vec<ExperimentBranch>,
248 pub reference_branch: Option<String>,
249}
250
251pub struct ExperimentBranch {
252 pub slug: String,
253 pub ratio: i32,
254}
255
256impl From<Experiment> for AvailableExperiment {
257 fn from(exp: Experiment) -> Self {
258 Self {
259 slug: exp.slug,
260 user_facing_name: exp.user_facing_name,
261 user_facing_description: exp.user_facing_description,
262 branches: exp.branches.into_iter().map(|b| b.into()).collect(),
263 reference_branch: exp.reference_branch,
264 }
265 }
266}
267
268impl From<Branch> for ExperimentBranch {
269 fn from(branch: Branch) -> Self {
270 Self {
271 slug: branch.slug,
272 ratio: branch.ratio,
273 }
274 }
275}
276
277#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
280#[serde(rename_all = "snake_case")]
281pub enum RandomizationUnit {
282 NimbusId,
283 UserId,
284}
285
286impl Default for RandomizationUnit {
287 fn default() -> Self {
288 Self::NimbusId
289 }
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 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}