1use std::collections::HashSet;
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.to_owned().feature_id)
79 .collect::<Vec<_>>()
80 })
81 .collect::<HashSet<_>>();
82
83 feature_ids.into_iter().collect()
84 }
85
86 #[cfg(test)]
87 pub(crate) fn patch(&self, patch: Value) -> Self {
88 let mut experiment = serde_json::to_value(self).unwrap();
89 if let (Some(e), Some(w)) = (experiment.as_object(), patch.as_object()) {
90 let mut e = e.clone();
91 for (key, value) in w {
92 e.insert(key.clone(), value.clone());
93 }
94 experiment = serde_json::to_value(e).unwrap();
95 }
96 serde_json::from_value(experiment).unwrap()
97 }
98}
99
100impl ExperimentMetadata for Experiment {
101 fn get_slug(&self) -> String {
102 self.slug.clone()
103 }
104
105 fn is_rollout(&self) -> bool {
106 self.is_rollout
107 }
108}
109
110pub fn parse_experiments(payload: &str) -> Result<Vec<Experiment>> {
111 let value: Value = match serde_json::from_str(payload) {
115 Ok(v) => v,
116 Err(e) => {
117 return Err(NimbusError::JSONError(
118 "value = nimbus::schema::parse_experiments::serde_json::from_str".into(),
119 e.to_string(),
120 ));
121 }
122 };
123 let data = value
124 .get("data")
125 .ok_or(NimbusError::InvalidExperimentFormat)?;
126 let mut res = Vec::new();
127 for exp in data
128 .as_array()
129 .ok_or(NimbusError::InvalidExperimentFormat)?
130 {
131 match serde_json::from_value::<Experiment>(exp.clone()) {
135 Ok(exp) => res.push(exp),
136 Err(e) => {
137 trace!("Malformed experiment data: {:#?}", exp);
138 warn!(
139 "Malformed experiment found! Experiment {}, Error: {}",
140 exp.get("id").unwrap_or(&serde_json::json!("ID_NOT_FOUND")),
141 e
142 );
143 }
144 }
145 }
146 Ok(res)
147}
148
149#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
150#[serde(rename_all = "camelCase")]
151pub struct FeatureConfig {
152 pub feature_id: String,
153 #[serde(default)]
157 pub value: Map<String, Value>,
158}
159
160impl Defaults for FeatureConfig {
161 fn defaults(&self, fallback: &Self) -> Result<Self> {
162 if self.feature_id != fallback.feature_id {
163 Err(NimbusError::InternalError(
165 "Cannot merge feature configs from different features",
166 ))
167 } else {
168 Ok(FeatureConfig {
169 feature_id: self.feature_id.clone(),
170 value: self.value.defaults(&fallback.value)?,
171 })
172 }
173 }
174}
175
176#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
179pub struct Branch {
180 pub slug: String,
181 pub ratio: i32,
182 #[serde(skip_serializing_if = "Option::is_none")]
187 pub feature: Option<FeatureConfig>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub features: Option<Vec<FeatureConfig>>,
190}
191
192impl Branch {
193 pub(crate) fn get_feature_configs(&self) -> Vec<FeatureConfig> {
194 match (&self.features, &self.feature) {
197 (Some(features), _) => features.clone(),
198 (None, Some(feature)) => vec![feature.clone()],
199 _ => Default::default(),
200 }
201 }
202
203 #[cfg(feature = "stateful")]
204 pub(crate) fn get_feature_props_and_values(&self) -> Vec<(String, String, Value)> {
205 self.get_feature_configs()
206 .iter()
207 .flat_map(|fc| {
208 fc.value
209 .iter()
210 .map(|(k, v)| (fc.feature_id.clone(), k.clone(), v.clone()))
211 })
212 .collect()
213 }
214}
215
216fn default_buckets() -> u32 {
217 DEFAULT_TOTAL_BUCKETS
218}
219
220#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
223#[serde(rename_all = "camelCase")]
224pub struct BucketConfig {
225 pub randomization_unit: RandomizationUnit,
226 pub namespace: String,
227 pub start: u32,
228 pub count: u32,
229 #[serde(default = "default_buckets")]
230 pub total: u32,
231}
232
233#[allow(unused)]
234#[cfg(test)]
235impl BucketConfig {
236 pub(crate) fn always() -> Self {
237 Self {
238 start: 0,
239 count: default_buckets(),
240 total: default_buckets(),
241 ..Default::default()
242 }
243 }
244}
245
246pub struct AvailableExperiment {
248 pub slug: String,
249 pub user_facing_name: String,
250 pub user_facing_description: String,
251 pub branches: Vec<ExperimentBranch>,
252 pub reference_branch: Option<String>,
253}
254
255pub struct ExperimentBranch {
256 pub slug: String,
257 pub ratio: i32,
258}
259
260impl From<Experiment> for AvailableExperiment {
261 fn from(exp: Experiment) -> Self {
262 Self {
263 slug: exp.slug,
264 user_facing_name: exp.user_facing_name,
265 user_facing_description: exp.user_facing_description,
266 branches: exp.branches.into_iter().map(|b| b.into()).collect(),
267 reference_branch: exp.reference_branch,
268 }
269 }
270}
271
272impl From<Branch> for ExperimentBranch {
273 fn from(branch: Branch) -> Self {
274 Self {
275 slug: branch.slug,
276 ratio: branch.ratio,
277 }
278 }
279}
280
281#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
284#[serde(rename_all = "snake_case")]
285#[derive(Default)]
286pub enum RandomizationUnit {
287 #[default]
288 NimbusId,
289 UserId,
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}