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 = viaduct::Request::get(viaduct::parse_url(&url)?)
219                    .header("User-Agent", USER_AGENT)?;
220                req.send()?.json()?
221            }
222            ExperimentSource::FromFeatureFiles {
223                app,
224                feature_id,
225                files,
226            } => feature_utils::create_experiment(app, feature_id, files)?,
227
228            ExperimentSource::WithPatchFile { patch, inner } => patch_experiment(inner, patch)?,
229
230            #[cfg(test)]
231            ExperimentSource::FromTestFixture { file } => value_utils::read_from_file(file)?,
232        })
233    }
234}
235
236fn patch_experiment(experiment: &ExperimentSource, patch: &PathBuf) -> Result<Value> {
237    let mut value: Value = experiment
238        .try_into()
239        .map_err(|e| anyhow::Error::msg(format!("Problem loading experiment: {e}")))?;
240
241    let patch: FeatureDefaults = read_from_file(patch)
242        .map_err(|e| anyhow::Error::msg(format!("Problem loading patch file: {e}")))?;
243
244    for b in value.get_mut_array("branches")? {
245        for (feature_id, value) in try_find_mut_features_from_branch(b)? {
246            match patch.features.get(&feature_id) {
247                Some(v) => value.patch(v),
248                _ => true,
249            };
250        }
251    }
252    Ok(value)
253}
254
255#[derive(Deserialize, Serialize)]
256struct FeatureDefaults {
257    #[serde(flatten)]
258    features: BTreeMap<String, Value>,
259}
260
261#[cfg(test)]
262mod unit_tests {
263    use super::*;
264    #[test]
265    fn test_experiment_source_from_rs() -> Result<()> {
266        let release = ExperimentListSource::try_from_rs("")?;
267        let stage = ExperimentListSource::try_from_rs("stage")?;
268        let release_preview = ExperimentListSource::try_from_rs("preview")?;
269        let stage_preview = ExperimentListSource::try_from_rs("stage/preview")?;
270        let slug = "my-slug".to_string();
271        assert_eq!(
272            ExperimentSource::try_from_rs("my-slug")?,
273            ExperimentSource::FromList {
274                list: release.clone(),
275                slug: slug.clone()
276            }
277        );
278        assert_eq!(
279            ExperimentSource::try_from_rs("release/my-slug")?,
280            ExperimentSource::FromList {
281                list: release,
282                slug: slug.clone()
283            }
284        );
285        assert_eq!(
286            ExperimentSource::try_from_rs("stage/my-slug")?,
287            ExperimentSource::FromList {
288                list: stage,
289                slug: slug.clone()
290            }
291        );
292        assert_eq!(
293            ExperimentSource::try_from_rs("preview/my-slug")?,
294            ExperimentSource::FromList {
295                list: release_preview.clone(),
296                slug: slug.clone()
297            }
298        );
299        assert_eq!(
300            ExperimentSource::try_from_rs("release/preview/my-slug")?,
301            ExperimentSource::FromList {
302                list: release_preview,
303                slug: slug.clone()
304            }
305        );
306        assert_eq!(
307            ExperimentSource::try_from_rs("stage/preview/my-slug")?,
308            ExperimentSource::FromList {
309                list: stage_preview,
310                slug
311            }
312        );
313
314        assert!(ExperimentSource::try_from_rs("not-real/preview/my-slug").is_err());
315        assert!(ExperimentSource::try_from_rs("release/not-real/my-slug").is_err());
316
317        Ok(())
318    }
319
320    #[test]
321    fn test_experiment_source_from_api() -> Result<()> {
322        let release = config::api_v6_production_server();
323        let stage = config::api_v6_stage_server();
324        let slug = "my-slug".to_string();
325        assert_eq!(
326            ExperimentSource::try_from_api("my-slug")?,
327            ExperimentSource::FromApiV6 {
328                slug: slug.to_string(),
329                endpoint: release.clone()
330            }
331        );
332        assert_eq!(
333            ExperimentSource::try_from_api("release/my-slug")?,
334            ExperimentSource::FromApiV6 {
335                slug: slug.to_string(),
336                endpoint: release.clone()
337            }
338        );
339        assert_eq!(
340            ExperimentSource::try_from_api("stage/my-slug")?,
341            ExperimentSource::FromApiV6 {
342                slug: slug.to_string(),
343                endpoint: stage.clone()
344            }
345        );
346        assert_eq!(
347            ExperimentSource::try_from_api("preview/my-slug")?,
348            ExperimentSource::FromApiV6 {
349                slug: slug.to_string(),
350                endpoint: release.clone()
351            }
352        );
353        assert_eq!(
354            ExperimentSource::try_from_api("release/preview/my-slug")?,
355            ExperimentSource::FromApiV6 {
356                slug: slug.to_string(),
357                endpoint: release
358            }
359        );
360        assert_eq!(
361            ExperimentSource::try_from_api("stage/preview/my-slug")?,
362            ExperimentSource::FromApiV6 {
363                slug,
364                endpoint: stage
365            }
366        );
367
368        Ok(())
369    }
370
371    #[test]
372    fn test_experiment_source_from_url() -> Result<()> {
373        let endpoint = "https://example.com";
374        let slug = "my-slug";
375        assert_eq!(
376            ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary")?,
377            ExperimentSource::FromApiV6 {
378                slug: slug.to_string(),
379                endpoint: endpoint.to_string(),
380            }
381        );
382        assert_eq!(
383            ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary/")?,
384            ExperimentSource::FromApiV6 {
385                slug: slug.to_string(),
386                endpoint: endpoint.to_string(),
387            }
388        );
389        assert_eq!(
390            ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/results#overview")?,
391            ExperimentSource::FromApiV6 {
392                slug: slug.to_string(),
393                endpoint: endpoint.to_string(),
394            }
395        );
396        assert_eq!(
397            ExperimentSource::try_from_url("https://example.com/api/v6/experiments/my-slug/")?,
398            ExperimentSource::FromApiV6 {
399                slug: slug.to_string(),
400                endpoint: endpoint.to_string(),
401            }
402        );
403        let endpoint = "http://localhost:8080";
404        assert_eq!(
405            ExperimentSource::try_from_url("http://localhost:8080/nimbus/my-slug/summary")?,
406            ExperimentSource::FromApiV6 {
407                slug: slug.to_string(),
408                endpoint: endpoint.to_string(),
409            }
410        );
411        assert_eq!(
412            ExperimentSource::try_from_url("http://localhost:8080/api/v6/experiments/my-slug/")?,
413            ExperimentSource::FromApiV6 {
414                slug: slug.to_string(),
415                endpoint: endpoint.to_string(),
416            }
417        );
418
419        Ok(())
420    }
421}