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
200fn default_buckets() -> u32 {
201 DEFAULT_TOTAL_BUCKETS
202}
203
204#[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
230pub 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#[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 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}