nimbus_cli/sources/
experiment_list.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 crate::{
6    cli::{Cli, CliCommand, ExperimentArgs, ExperimentListArgs, ExperimentListSourceArgs},
7    config,
8    value_utils::{self, CliUtils},
9    USER_AGENT,
10};
11use anyhow::{bail, Result};
12use serde_json::Value;
13use std::path::{Path, PathBuf};
14
15use super::{ExperimentListFilter, ExperimentSource};
16
17#[derive(Clone, Debug, PartialEq)]
18pub(crate) enum ExperimentListSource {
19    Empty,
20    Filtered {
21        filter: ExperimentListFilter,
22        inner: Box<ExperimentListSource>,
23    },
24    FromApiV6 {
25        endpoint: String,
26    },
27    FromFile {
28        file: PathBuf,
29    },
30    FromRemoteSettings {
31        endpoint: String,
32        is_preview: bool,
33    },
34    FromRecipes {
35        recipes: Vec<ExperimentSource>,
36    },
37}
38
39impl ExperimentListSource {
40    fn try_from_slug<'a>(
41        slug: &'a str,
42        production: &'a str,
43        stage: &'a str,
44    ) -> Result<(&'a str, bool)> {
45        let (is_production, is_preview) = decode_list_slug(slug)?;
46
47        let endpoint = if is_production { production } else { stage };
48
49        Ok((endpoint, is_preview))
50    }
51
52    pub(crate) fn try_from_rs(value: &str) -> Result<Self> {
53        let p = config::rs_production_server();
54        let s = config::rs_stage_server();
55        let (endpoint, is_preview) = Self::try_from_slug(value, &p, &s)?;
56        Ok(Self::FromRemoteSettings {
57            endpoint: endpoint.to_string(),
58            is_preview,
59        })
60    }
61
62    pub(crate) fn try_from_api(value: &str) -> Result<Self> {
63        let p = config::api_v6_production_server();
64        let s = config::api_v6_stage_server();
65        let (endpoint, _) = Self::try_from_slug(value, &p, &s)?;
66        Ok(Self::FromApiV6 {
67            endpoint: endpoint.to_string(),
68        })
69    }
70}
71
72// Returns (is_production, is_preview)
73pub(crate) fn decode_list_slug(slug: &str) -> Result<(bool, bool)> {
74    let tokens: Vec<&str> = slug.splitn(3, '/').collect();
75
76    Ok(match tokens.as_slice() {
77        [""] => (true, false),
78        ["preview"] => (true, true),
79        [server] => (is_production_server(server)?, false),
80        [server, preview] => (
81            is_production_server(server)?,
82            is_preview_collection(preview)?,
83        ),
84        _ => bail!(format!(
85            "Can't unpack '{slug}' into an experiment; try stage/SLUG, or SLUG"
86        )),
87    })
88}
89
90fn is_production_server(slug: &str) -> Result<bool> {
91    Ok(match slug {
92        "production" | "release" | "prod" | "" => true,
93        "stage" | "staging" => false,
94        _ => bail!(format!(
95            "Cannot translate '{slug}' into production or stage"
96        )),
97    })
98}
99
100fn is_preview_collection(slug: &str) -> Result<bool> {
101    Ok(match slug {
102        "preview" => true,
103        "" => false,
104        _ => bail!(format!(
105            "Cannot translate '{slug}' into preview or release collection"
106        )),
107    })
108}
109
110impl TryFrom<&Cli> for ExperimentListSource {
111    type Error = anyhow::Error;
112
113    fn try_from(value: &Cli) -> Result<Self> {
114        let list = match &value.command {
115            CliCommand::FetchList { list, .. } | CliCommand::List { list } => {
116                ExperimentListSource::try_from(list)?
117            }
118            CliCommand::Fetch {
119                experiment,
120                recipes: slugs,
121                ..
122            } => {
123                let mut recipes = vec![ExperimentSource::try_from(experiment)?];
124
125                for r in slugs {
126                    let recipe = ExperimentArgs {
127                        experiment: r.clone(),
128                        ..experiment.clone()
129                    };
130                    recipes.push(ExperimentSource::try_from(&recipe)?);
131                }
132                ExperimentListSource::FromRecipes { recipes }
133            }
134            _ => unreachable!(),
135        };
136
137        let app = value.app.clone();
138        Ok(if let Some(app) = app {
139            ExperimentListSource::Filtered {
140                filter: ExperimentListFilter::for_app(app.as_str()),
141                inner: Box::new(list),
142            }
143        } else {
144            list
145        })
146    }
147}
148
149impl TryFrom<&ExperimentListArgs> for ExperimentListSource {
150    type Error = anyhow::Error;
151
152    fn try_from(value: &ExperimentListArgs) -> Result<Self> {
153        let source = match &value.source {
154            ExperimentListSourceArgs {
155                server,
156                file: Some(file),
157                ..
158            } => {
159                if !server.is_empty() {
160                    bail!("Cannot load a list from a file AND a server")
161                } else {
162                    Self::FromFile { file: file.clone() }
163                }
164            }
165            ExperimentListSourceArgs {
166                server: s,
167                file: None,
168                use_api,
169            } => {
170                if *use_api {
171                    Self::try_from_api(s)?
172                } else {
173                    Self::try_from_rs(s)?
174                }
175            }
176        };
177        let filter: ExperimentListFilter = From::from(&value.filter);
178        Ok(if !filter.is_empty() {
179            ExperimentListSource::Filtered {
180                filter,
181                inner: Box::new(source),
182            }
183        } else {
184            source
185        })
186    }
187}
188
189impl TryFrom<&Path> for ExperimentListSource {
190    type Error = anyhow::Error;
191
192    fn try_from(value: &Path) -> Result<Self> {
193        Ok(Self::FromFile {
194            file: value.to_path_buf(),
195        })
196    }
197}
198
199// Get the experiment list
200
201impl TryFrom<&ExperimentListSource> for Value {
202    type Error = anyhow::Error;
203
204    fn try_from(value: &ExperimentListSource) -> Result<Value> {
205        Ok(match value {
206            ExperimentListSource::Empty => serde_json::json!({ "data": [] }),
207            ExperimentListSource::Filtered { filter, inner } => filter_list(filter, inner)?,
208            ExperimentListSource::FromRecipes { recipes } => {
209                let mut data: Vec<Value> = Default::default();
210
211                for r in recipes {
212                    if let Ok(v) = r.try_into() {
213                        data.push(v);
214                    }
215                }
216                serde_json::json!({ "data": data })
217            }
218            ExperimentListSource::FromRemoteSettings {
219                endpoint,
220                is_preview,
221            } => {
222                use remote_settings::{RemoteSettings, RemoteSettingsConfig, RemoteSettingsServer};
223                viaduct_reqwest::use_reqwest_backend();
224                let collection_name = if *is_preview {
225                    "nimbus-preview".to_string()
226                } else {
227                    "nimbus-mobile-experiments".to_string()
228                };
229                let config = RemoteSettingsConfig {
230                    server: Some(RemoteSettingsServer::Custom {
231                        url: endpoint.clone(),
232                    }),
233                    server_url: None,
234                    bucket_name: None,
235                    collection_name,
236                };
237                let client = RemoteSettings::new(config)?;
238
239                let response = client.get_records_raw()?;
240                response.json::<Value>()?
241            }
242            ExperimentListSource::FromFile { file } => {
243                let v: Value = value_utils::read_from_file(file)?;
244                if v.is_array() {
245                    serde_json::json!({ "data": v })
246                } else if v.get_array("data").is_ok() {
247                    v
248                } else if v.get_array("branches").is_ok() {
249                    serde_json::json!({ "data": [v] })
250                } else {
251                    bail!(
252                        "An unrecognized recipes JSON file: {}",
253                        file.as_path().to_str().unwrap_or_default()
254                    );
255                }
256            }
257            ExperimentListSource::FromApiV6 { endpoint } => {
258                let url = format!("{endpoint}/api/v6/experiments/");
259
260                let req = reqwest::blocking::Client::builder()
261                    .user_agent(USER_AGENT)
262                    .gzip(true)
263                    .build()?
264                    .get(url);
265
266                let resp = req.send()?;
267                let data: Value = resp.json()?;
268
269                fn start_date(v: &Value) -> &str {
270                    let later = "9999-99-99";
271                    match v.get("startDate") {
272                        Some(v) => v.as_str().unwrap_or(later),
273                        _ => later,
274                    }
275                }
276
277                let data = match data {
278                    Value::Array(mut array) => {
279                        array.sort_by(|p, q| {
280                            let p_time = start_date(p);
281                            let q_time = start_date(q);
282                            p_time.cmp(q_time)
283                        });
284                        Value::Array(array)
285                    }
286                    _ => data,
287                };
288                serde_json::json!({ "data": data })
289            }
290        })
291    }
292}
293
294fn filter_list(filter: &ExperimentListFilter, inner: &ExperimentListSource) -> Result<Value> {
295    let v: Value = Value::try_from(inner)?;
296    let data = v.get_array("data")?;
297    let mut array: Vec<Value> = Default::default();
298    for exp in data {
299        if let Ok(true) = filter.matches(exp) {
300            array.push(exp.to_owned());
301        }
302    }
303
304    Ok(serde_json::json!({ "data": array }))
305}
306
307#[cfg(test)]
308mod unit_tests {
309    use super::*;
310
311    #[test]
312    fn test_experiment_list_from_rs() -> Result<()> {
313        let release = config::rs_production_server();
314        let stage = config::rs_stage_server();
315        assert_eq!(
316            ExperimentListSource::try_from_rs("")?,
317            ExperimentListSource::FromRemoteSettings {
318                endpoint: release.clone(),
319                is_preview: false
320            }
321        );
322        assert_eq!(
323            ExperimentListSource::try_from_rs("preview")?,
324            ExperimentListSource::FromRemoteSettings {
325                endpoint: release.clone(),
326                is_preview: true
327            }
328        );
329        assert_eq!(
330            ExperimentListSource::try_from_rs("release")?,
331            ExperimentListSource::FromRemoteSettings {
332                endpoint: release.clone(),
333                is_preview: false
334            }
335        );
336        assert_eq!(
337            ExperimentListSource::try_from_rs("release/preview")?,
338            ExperimentListSource::FromRemoteSettings {
339                endpoint: release.clone(),
340                is_preview: true
341            }
342        );
343        assert_eq!(
344            ExperimentListSource::try_from_rs("stage")?,
345            ExperimentListSource::FromRemoteSettings {
346                endpoint: stage.clone(),
347                is_preview: false
348            }
349        );
350        assert_eq!(
351            ExperimentListSource::try_from_rs("stage/preview")?,
352            ExperimentListSource::FromRemoteSettings {
353                endpoint: stage,
354                is_preview: true
355            }
356        );
357        assert_eq!(
358            ExperimentListSource::try_from_rs("release/preview")?,
359            ExperimentListSource::FromRemoteSettings {
360                endpoint: release,
361                is_preview: true
362            }
363        );
364
365        assert!(ExperimentListSource::try_from_rs("not-real/preview").is_err());
366        assert!(ExperimentListSource::try_from_rs("release/not-real").is_err());
367
368        Ok(())
369    }
370}