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                let collection_name = if *is_preview {
224                    "nimbus-preview".to_string()
225                } else {
226                    "nimbus-mobile-experiments".to_string()
227                };
228                let config = RemoteSettingsConfig {
229                    server: Some(RemoteSettingsServer::Custom {
230                        url: endpoint.clone(),
231                    }),
232                    server_url: None,
233                    bucket_name: None,
234                    collection_name,
235                };
236                let client = RemoteSettings::new(config)?;
237
238                let response = client.get_records_raw()?;
239                response.json::<Value>()?
240            }
241            ExperimentListSource::FromFile { file } => {
242                let v: Value = value_utils::read_from_file(file)?;
243                if v.is_array() {
244                    serde_json::json!({ "data": v })
245                } else if v.get_array("data").is_ok() {
246                    v
247                } else if v.get_array("branches").is_ok() {
248                    serde_json::json!({ "data": [v] })
249                } else {
250                    bail!(
251                        "An unrecognized recipes JSON file: {}",
252                        file.as_path().to_str().unwrap_or_default()
253                    );
254                }
255            }
256            ExperimentListSource::FromApiV6 { endpoint } => {
257                let url = format!("{endpoint}/api/v6/experiments/");
258
259                let req = viaduct::Request::get(viaduct::parse_url(&url)?)
260                    .header("User-Agent", USER_AGENT)?;
261
262                let resp = req.send()?;
263                let data: Value = resp.json()?;
264
265                fn start_date(v: &Value) -> &str {
266                    let later = "9999-99-99";
267                    match v.get("startDate") {
268                        Some(v) => v.as_str().unwrap_or(later),
269                        _ => later,
270                    }
271                }
272
273                let data = match data {
274                    Value::Array(mut array) => {
275                        array.sort_by(|p, q| {
276                            let p_time = start_date(p);
277                            let q_time = start_date(q);
278                            p_time.cmp(q_time)
279                        });
280                        Value::Array(array)
281                    }
282                    _ => data,
283                };
284                serde_json::json!({ "data": data })
285            }
286        })
287    }
288}
289
290fn filter_list(filter: &ExperimentListFilter, inner: &ExperimentListSource) -> Result<Value> {
291    let v: Value = Value::try_from(inner)?;
292    let data = v.get_array("data")?;
293    let mut array: Vec<Value> = Default::default();
294    for exp in data {
295        if let Ok(true) = filter.matches(exp) {
296            array.push(exp.to_owned());
297        }
298    }
299
300    Ok(serde_json::json!({ "data": array }))
301}
302
303#[cfg(test)]
304mod unit_tests {
305    use super::*;
306
307    #[test]
308    fn test_experiment_list_from_rs() -> Result<()> {
309        let release = config::rs_production_server();
310        let stage = config::rs_stage_server();
311        assert_eq!(
312            ExperimentListSource::try_from_rs("")?,
313            ExperimentListSource::FromRemoteSettings {
314                endpoint: release.clone(),
315                is_preview: false
316            }
317        );
318        assert_eq!(
319            ExperimentListSource::try_from_rs("preview")?,
320            ExperimentListSource::FromRemoteSettings {
321                endpoint: release.clone(),
322                is_preview: true
323            }
324        );
325        assert_eq!(
326            ExperimentListSource::try_from_rs("release")?,
327            ExperimentListSource::FromRemoteSettings {
328                endpoint: release.clone(),
329                is_preview: false
330            }
331        );
332        assert_eq!(
333            ExperimentListSource::try_from_rs("release/preview")?,
334            ExperimentListSource::FromRemoteSettings {
335                endpoint: release.clone(),
336                is_preview: true
337            }
338        );
339        assert_eq!(
340            ExperimentListSource::try_from_rs("stage")?,
341            ExperimentListSource::FromRemoteSettings {
342                endpoint: stage.clone(),
343                is_preview: false
344            }
345        );
346        assert_eq!(
347            ExperimentListSource::try_from_rs("stage/preview")?,
348            ExperimentListSource::FromRemoteSettings {
349                endpoint: stage,
350                is_preview: true
351            }
352        );
353        assert_eq!(
354            ExperimentListSource::try_from_rs("release/preview")?,
355            ExperimentListSource::FromRemoteSettings {
356                endpoint: release,
357                is_preview: true
358            }
359        );
360
361        assert!(ExperimentListSource::try_from_rs("not-real/preview").is_err());
362        assert!(ExperimentListSource::try_from_rs("release/not-real").is_err());
363
364        Ok(())
365    }
366}