nimbus_cli/sources/
experiment.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use anyhow::{bail, Result};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9use std::{
10    fmt::Display,
11    path::{Path, PathBuf},
12};
13
14use crate::value_utils::{read_from_file, try_find_mut_features_from_branch, CliUtils, Patch};
15use crate::{
16    cli::{Cli, CliCommand, ExperimentArgs},
17    config, feature_utils,
18    sources::ExperimentListSource,
19    value_utils, NimbusApp, USER_AGENT,
20};
21
22use super::experiment_list::decode_list_slug;
23
24#[derive(Clone, Debug, PartialEq)]
25pub(crate) enum ExperimentSource {
26    FromList {
27        slug: String,
28        list: ExperimentListSource,
29    },
30    FromFeatureFiles {
31        app: NimbusApp,
32        feature_id: String,
33        files: Vec<PathBuf>,
34    },
35    FromApiV6 {
36        slug: String,
37        endpoint: String,
38    },
39    WithPatchFile {
40        patch: PathBuf,
41        inner: Box<ExperimentSource>,
42    },
43    #[cfg(test)]
44    FromTestFixture {
45        file: PathBuf,
46    },
47}
48
49// Create ExperimentSources from &str and Cli.
50
51impl ExperimentSource {
52    fn try_from_slug<'a>(
53        value: &'a str,
54        production: &'a str,
55        stage: &'a str,
56    ) -> Result<(&'a str, &'a str, bool)> {
57        let tokens: Vec<&str> = value.splitn(3, '/').collect();
58
59        let (is_production, is_preview) = match tokens.as_slice() {
60            [_] => decode_list_slug("")?,
61            [first, _] => decode_list_slug(first)?,
62            [first, second, _] => decode_list_slug(&format!("{first}/{second}"))?,
63            _ => unreachable!(),
64        };
65
66        let endpoint = if is_production { production } else { stage };
67
68        Ok(match tokens.last() {
69            Some(slug) => (slug, endpoint, is_preview),
70            _ => bail!(format!(
71                "Can't unpack '{value}' into an experiment; try stage/SLUG, or SLUG"
72            )),
73        })
74    }
75
76    fn try_from_rs(value: &str) -> Result<Self> {
77        let p = config::rs_production_server();
78        let s = config::rs_stage_server();
79        let (slug, endpoint, is_preview) = Self::try_from_slug(value, &p, &s)?;
80        Ok(Self::FromList {
81            slug: slug.to_string(),
82            list: ExperimentListSource::FromRemoteSettings {
83                endpoint: endpoint.to_string(),
84                is_preview,
85            },
86        })
87    }
88
89    fn try_from_url(value: &str) -> Result<Self> {
90        if !value.contains("://") {
91            anyhow::bail!("A URL must start with https://, '{value}' does not");
92        }
93        let value = value.replacen("://", "/", 1);
94
95        let parts: Vec<&str> = value.split('/').collect();
96
97        Ok(match parts.as_slice() {
98            [scheme, endpoint, "nimbus", slug]
99            | [scheme, endpoint, "nimbus", slug, _]
100            | [scheme, endpoint, "nimbus", slug, _, ""]
101            | [scheme, endpoint, "api", "v6", "experiments", slug, ""] => Self::FromApiV6 {
102                slug: slug.to_string(),
103                endpoint: format!("{scheme}://{endpoint}"),
104            },
105            _ => anyhow::bail!("Unrecognized URL from which to to get an experiment"),
106        })
107    }
108
109    fn try_from_api(value: &str) -> Result<Self> {
110        let p = config::api_v6_production_server();
111        let s = config::api_v6_stage_server();
112        let (slug, endpoint, _) = Self::try_from_slug(value, &p, &s)?;
113        Ok(Self::FromApiV6 {
114            slug: slug.to_string(),
115            endpoint: endpoint.to_string(),
116        })
117    }
118
119    pub(crate) fn try_from_file(file: &Path, slug: &str) -> Result<Self> {
120        Ok(ExperimentSource::FromList {
121            slug: slug.to_string(),
122            list: file.try_into()?,
123        })
124    }
125
126    #[cfg(test)]
127    pub(crate) fn from_fixture(filename: &str) -> Self {
128        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
129        let file = dir.join("test/fixtures").join(filename);
130        Self::FromTestFixture { file }
131    }
132}
133
134impl TryFrom<&ExperimentArgs> for ExperimentSource {
135    type Error = anyhow::Error;
136
137    fn try_from(value: &ExperimentArgs) -> Result<Self> {
138        let experiment = &value.experiment;
139        let is_urlish = experiment.contains("://");
140        let experiment = match &value.file {
141            Some(_) if is_urlish => {
142                anyhow::bail!("Cannot load an experiment from a file and a URL at the same time")
143            }
144            None if is_urlish => Self::try_from_url(experiment.as_str())?,
145            Some(file) => Self::try_from_file(file, experiment)?,
146            _ if value.use_rs => Self::try_from_rs(experiment)?,
147            _ => Self::try_from_api(experiment.as_str())?,
148        };
149        Ok(match &value.patch {
150            Some(file) => Self::WithPatchFile {
151                patch: file.clone(),
152                inner: Box::new(experiment),
153            },
154            _ => experiment,
155        })
156    }
157}
158
159impl TryFrom<&Cli> for ExperimentSource {
160    type Error = anyhow::Error;
161
162    fn try_from(value: &Cli) -> Result<Self> {
163        Ok(match &value.command {
164            CliCommand::Validate { experiment, .. }
165            | CliCommand::Enroll { experiment, .. }
166            | CliCommand::Features { experiment, .. } => experiment.try_into()?,
167            CliCommand::TestFeature {
168                feature_id,
169                files,
170                patch,
171                ..
172            } => {
173                let experiment = Self::FromFeatureFiles {
174                    app: value.into(),
175                    feature_id: feature_id.clone(),
176                    files: files.clone(),
177                };
178                match patch {
179                    Some(f) => Self::WithPatchFile {
180                        patch: f.clone(),
181                        inner: Box::new(experiment),
182                    },
183                    _ => experiment,
184                }
185            }
186            _ => unreachable!("Cli Arg not supporting getting an experiment source"),
187        })
188    }
189}
190
191// Get the experiment itself from the experiment source.
192
193impl Display for ExperimentSource {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        match self {
196            Self::FromList { slug, .. } | Self::FromApiV6 { slug, .. } => f.write_str(slug),
197            Self::FromFeatureFiles { feature_id, .. } => {
198                f.write_str(&format!("{feature_id}-experiment"))
199            }
200            Self::WithPatchFile { inner, .. } => f.write_str(&format!("{inner} (patched)")),
201            #[cfg(test)]
202            Self::FromTestFixture { file } => f.write_str(&format!("{file:?}")),
203        }
204    }
205}
206
207impl TryFrom<&ExperimentSource> for Value {
208    type Error = anyhow::Error;
209
210    fn try_from(value: &ExperimentSource) -> Result<Value> {
211        Ok(match value {
212            ExperimentSource::FromList { slug, list } => {
213                let value = Value::try_from(list)?;
214                value_utils::try_find_experiment(&value, slug)?
215            }
216            ExperimentSource::FromApiV6 { slug, endpoint } => {
217                let url = format!("{endpoint}/api/v6/experiments/{slug}/");
218                let req = reqwest::blocking::Client::builder()
219                    .user_agent(USER_AGENT)
220                    .gzip(true)
221                    .build()?
222                    .get(url);
223
224                req.send()?.json()?
225            }
226            ExperimentSource::FromFeatureFiles {
227                app,
228                feature_id,
229                files,
230            } => feature_utils::create_experiment(app, feature_id, files)?,
231
232            ExperimentSource::WithPatchFile { patch, inner } => patch_experiment(inner, patch)?,
233
234            #[cfg(test)]
235            ExperimentSource::FromTestFixture { file } => value_utils::read_from_file(file)?,
236        })
237    }
238}
239
240fn patch_experiment(experiment: &ExperimentSource, patch: &PathBuf) -> Result<Value> {
241    let mut value: Value = experiment
242        .try_into()
243        .map_err(|e| anyhow::Error::msg(format!("Problem loading experiment: {e}")))?;
244
245    let patch: FeatureDefaults = read_from_file(patch)
246        .map_err(|e| anyhow::Error::msg(format!("Problem loading patch file: {e}")))?;
247
248    for b in value.get_mut_array("branches")? {
249        for (feature_id, value) in try_find_mut_features_from_branch(b)? {
250            match patch.features.get(&feature_id) {
251                Some(v) => value.patch(v),
252                _ => true,
253            };
254        }
255    }
256    Ok(value)
257}
258
259#[derive(Deserialize, Serialize)]
260struct FeatureDefaults {
261    #[serde(flatten)]
262    features: BTreeMap<String, Value>,
263}
264
265#[cfg(test)]
266mod unit_tests {
267    use super::*;
268    #[test]
269    fn test_experiment_source_from_rs() -> Result<()> {
270        let release = ExperimentListSource::try_from_rs("")?;
271        let stage = ExperimentListSource::try_from_rs("stage")?;
272        let release_preview = ExperimentListSource::try_from_rs("preview")?;
273        let stage_preview = ExperimentListSource::try_from_rs("stage/preview")?;
274        let slug = "my-slug".to_string();
275        assert_eq!(
276            ExperimentSource::try_from_rs("my-slug")?,
277            ExperimentSource::FromList {
278                list: release.clone(),
279                slug: slug.clone()
280            }
281        );
282        assert_eq!(
283            ExperimentSource::try_from_rs("release/my-slug")?,
284            ExperimentSource::FromList {
285                list: release,
286                slug: slug.clone()
287            }
288        );
289        assert_eq!(
290            ExperimentSource::try_from_rs("stage/my-slug")?,
291            ExperimentSource::FromList {
292                list: stage,
293                slug: slug.clone()
294            }
295        );
296        assert_eq!(
297            ExperimentSource::try_from_rs("preview/my-slug")?,
298            ExperimentSource::FromList {
299                list: release_preview.clone(),
300                slug: slug.clone()
301            }
302        );
303        assert_eq!(
304            ExperimentSource::try_from_rs("release/preview/my-slug")?,
305            ExperimentSource::FromList {
306                list: release_preview,
307                slug: slug.clone()
308            }
309        );
310        assert_eq!(
311            ExperimentSource::try_from_rs("stage/preview/my-slug")?,
312            ExperimentSource::FromList {
313                list: stage_preview,
314                slug
315            }
316        );
317
318        assert!(ExperimentSource::try_from_rs("not-real/preview/my-slug").is_err());
319        assert!(ExperimentSource::try_from_rs("release/not-real/my-slug").is_err());
320
321        Ok(())
322    }
323
324    #[test]
325    fn test_experiment_source_from_api() -> Result<()> {
326        let release = config::api_v6_production_server();
327        let stage = config::api_v6_stage_server();
328        let slug = "my-slug".to_string();
329        assert_eq!(
330            ExperimentSource::try_from_api("my-slug")?,
331            ExperimentSource::FromApiV6 {
332                slug: slug.to_string(),
333                endpoint: release.clone()
334            }
335        );
336        assert_eq!(
337            ExperimentSource::try_from_api("release/my-slug")?,
338            ExperimentSource::FromApiV6 {
339                slug: slug.to_string(),
340                endpoint: release.clone()
341            }
342        );
343        assert_eq!(
344            ExperimentSource::try_from_api("stage/my-slug")?,
345            ExperimentSource::FromApiV6 {
346                slug: slug.to_string(),
347                endpoint: stage.clone()
348            }
349        );
350        assert_eq!(
351            ExperimentSource::try_from_api("preview/my-slug")?,
352            ExperimentSource::FromApiV6 {
353                slug: slug.to_string(),
354                endpoint: release.clone()
355            }
356        );
357        assert_eq!(
358            ExperimentSource::try_from_api("release/preview/my-slug")?,
359            ExperimentSource::FromApiV6 {
360                slug: slug.to_string(),
361                endpoint: release
362            }
363        );
364        assert_eq!(
365            ExperimentSource::try_from_api("stage/preview/my-slug")?,
366            ExperimentSource::FromApiV6 {
367                slug,
368                endpoint: stage
369            }
370        );
371
372        Ok(())
373    }
374
375    #[test]
376    fn test_experiment_source_from_url() -> Result<()> {
377        let endpoint = "https://example.com";
378        let slug = "my-slug";
379        assert_eq!(
380            ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary")?,
381            ExperimentSource::FromApiV6 {
382                slug: slug.to_string(),
383                endpoint: endpoint.to_string(),
384            }
385        );
386        assert_eq!(
387            ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary/")?,
388            ExperimentSource::FromApiV6 {
389                slug: slug.to_string(),
390                endpoint: endpoint.to_string(),
391            }
392        );
393        assert_eq!(
394            ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/results#overview")?,
395            ExperimentSource::FromApiV6 {
396                slug: slug.to_string(),
397                endpoint: endpoint.to_string(),
398            }
399        );
400        assert_eq!(
401            ExperimentSource::try_from_url("https://example.com/api/v6/experiments/my-slug/")?,
402            ExperimentSource::FromApiV6 {
403                slug: slug.to_string(),
404                endpoint: endpoint.to_string(),
405            }
406        );
407        let endpoint = "http://localhost:8080";
408        assert_eq!(
409            ExperimentSource::try_from_url("http://localhost:8080/nimbus/my-slug/summary")?,
410            ExperimentSource::FromApiV6 {
411                slug: slug.to_string(),
412                endpoint: endpoint.to_string(),
413            }
414        );
415        assert_eq!(
416            ExperimentSource::try_from_url("http://localhost:8080/api/v6/experiments/my-slug/")?,
417            ExperimentSource::FromApiV6 {
418                slug: slug.to_string(),
419                endpoint: endpoint.to_string(),
420            }
421        );
422
423        Ok(())
424    }
425}