Skip to main content

apollo_configuration/
expansion.rs

1//! Traits and types to customize `${}` expansion functionality.
2
3use crate::YamlLocationData;
4use crate::errors::ExpandError;
5use crate::errors::ExpandErrorKind;
6use jsonschema::paths::LazyLocation;
7use jsonschema::paths::Location;
8use miette::SourceSpan;
9use std::borrow::Cow;
10use std::collections::HashMap;
11use std::env::VarError;
12use std::sync::Arc;
13
14/// Trait for resolving `${env.VAR_NAME}` style expansions.
15pub trait VariableProvider {
16    /// Return the value to substitute for a `${env.VAR_NAME}` style expansion.
17    fn get<'a>(&'a self, name: &str) -> Result<Cow<'a, str>, VarError>;
18}
19
20impl VariableProvider for () {
21    fn get<'a>(&'a self, _name: &str) -> Result<Cow<'a, str>, VarError> {
22        Err(VarError::NotPresent)
23    }
24}
25
26/// A variable provider that returns values from environment variables using [`std::env::var`].
27#[derive(Debug, Clone, Default)]
28pub struct EnvVariables;
29impl VariableProvider for EnvVariables {
30    fn get<'a>(&'a self, name: &str) -> Result<Cow<'a, str>, VarError> {
31        std::env::var(name).map(Cow::Owned)
32    }
33}
34
35/// A variable provider that returns values from a fixed map of data, for example for mocking.
36#[derive(Debug, Clone, Default)]
37pub struct MapVariables(pub HashMap<String, String>);
38impl VariableProvider for MapVariables {
39    fn get<'a>(&'a self, name: &str) -> Result<Cow<'a, str>, VarError> {
40        self.0
41            .get(name)
42            .map(|value| Cow::Borrowed(value.as_str()))
43            .ok_or(VarError::NotPresent)
44    }
45}
46
47fn expand_variable<'v>(
48    variable: &str,
49    span: SourceSpan,
50    vars: &'v dyn VariableProvider,
51) -> Result<Cow<'v, str>, ExpandErrorKind> {
52    let Some((kind, argument)) = variable.split_once('.') else {
53        return Err(ExpandErrorKind::UnprefixedExpansion {
54            span,
55            name: variable.to_string(),
56        });
57    };
58    // XXX(@goto-bus-stop): We could instead give the whole value to the variable provider,
59    // so users could add more than just `env.` (and `file.` which will probably be default in
60    // future).
61    match kind {
62        "env" => match vars.get(argument) {
63            Ok(value) => Ok(value),
64            Err(VarError::NotPresent) => Err(ExpandErrorKind::NotPresent { span }),
65            Err(VarError::NotUnicode(_)) => Err(ExpandErrorKind::NotUnicode { span }),
66        },
67        _ => Err(ExpandErrorKind::InvalidExpansion { span }),
68    }
69}
70
71fn expand_inner(
72    value: &mut serde_json::Value,
73    vars: &dyn VariableProvider,
74    location_data: &YamlLocationData,
75    path: LazyLocation,
76    errors: &mut Vec<ExpandErrorKind>,
77) {
78    match value {
79        serde_json::Value::Object(map) => {
80            for (key, value) in map.iter_mut() {
81                expand_inner(value, vars, location_data, path.push(key.as_str()), errors);
82            }
83        }
84        serde_json::Value::Array(vec) => {
85            for (key, value) in vec.iter_mut().enumerate() {
86                expand_inner(value, vars, location_data, path.push(key), errors);
87            }
88        }
89        serde_json::Value::String(string) => {
90            let location = Location::from(&path);
91            // XXX(@goto-bus-stop): ideally this would point to the actual variable, not the whole
92            // string
93            let span = location_data
94                .resolve_instance_span(&location)
95                .unwrap_or_else(|| SourceSpan::new(0.into(), 0));
96            match shellexpand::env_with_context(string, |name| {
97                // XXX(@goto-bus-stop): we could allow using variable expansion on numeric fields
98                // if we used schema type information here
99                expand_variable(name, span, vars).map(Some)
100            }) {
101                Ok(Cow::Borrowed(_)) => {
102                    // Unchanged
103                }
104                Ok(Cow::Owned(expanded)) => {
105                    *string = expanded;
106                }
107                Err(err) => errors.push(err.cause),
108            }
109        }
110        _ => {}
111    }
112}
113
114pub(crate) fn expand(
115    value: &mut serde_json::Value,
116    vars: &dyn VariableProvider,
117    location_data: Option<&YamlLocationData>,
118) -> Result<(), ExpandError> {
119    let no_yaml = YamlLocationData::empty();
120    let mut errors = vec![];
121    expand_inner(
122        value,
123        vars,
124        location_data.unwrap_or(&no_yaml),
125        LazyLocation::new(),
126        &mut errors,
127    );
128
129    if errors.is_empty() {
130        Ok(())
131    } else {
132        Err(ExpandError {
133            source_code: location_data
134                .map(|data| data.source_code.clone())
135                .unwrap_or_else(|| Arc::from("")),
136            errors,
137        })
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::MapVariables;
144    use super::VariableProvider;
145    use super::expand;
146    use miette::EyreContext as _;
147    use serde_json::json;
148    use std::collections::HashMap;
149    use std::ffi::OsString;
150
151    #[test]
152    fn expand_env_variable() {
153        let mut json = json!("${env.REPLACE}");
154        let vars = MapVariables(HashMap::from([(
155            "REPLACE".to_string(),
156            "value".to_string(),
157        )]));
158        expand(&mut json, &vars, None).expect("should succeed");
159        assert_eq!(json.as_str(), Some("value"));
160    }
161
162    #[test]
163    fn expand_env_variable_inside_string() {
164        let mut json = json!("this will be ${env.REPLACE}");
165        let vars = MapVariables(HashMap::from([(
166            "REPLACE".to_string(),
167            "value".to_string(),
168        )]));
169        expand(&mut json, &vars, None).expect("should succeed");
170        assert_eq!(json.as_str(), Some("this will be value"));
171    }
172
173    #[test]
174    fn expand_env_variable_inside_nesting() {
175        let mut json = json!({
176            "object": {
177                "array": ["${env.REPLACE}"],
178            }
179        });
180        let vars = MapVariables(HashMap::from([(
181            "REPLACE".to_string(),
182            "value".to_string(),
183        )]));
184        expand(&mut json, &vars, None).expect("should succeed");
185        assert_eq!(json["object"]["array"][0].as_str(), Some("value"));
186    }
187
188    #[test]
189    fn missing_variable_error() {
190        let mut json = json!({
191            "object": {
192                "array": ["${env.REPLACE}"],
193            }
194        });
195        let vars = MapVariables(HashMap::new());
196        let err = expand(&mut json, &vars, None).expect_err("should fail");
197        println!("{:?}", err);
198    }
199
200    #[test]
201    fn missing_defaulted_variable() {
202        let mut json = json!({
203            "object": {
204                "array": ["${env.REPLACE:-default}"],
205            }
206        });
207        let vars = MapVariables(HashMap::new());
208        expand(&mut json, &vars, None).expect("should succeed");
209        assert_eq!(json["object"]["array"][0].as_str(), Some("default"));
210    }
211
212    #[test]
213    fn unprefixed_replacement() {
214        let mut json = json!({
215            "object": {
216                "array": ["$REPLACE"],
217            }
218        });
219        let vars = MapVariables(HashMap::new());
220        let err = expand(&mut json, &vars, None).expect_err("should fail");
221        println!("{:?}", miette::Report::new(err));
222    }
223
224    #[test]
225    fn unsupported_prefix() {
226        let mut json = json!({
227            "object": {
228                "array": ["${other.REPLACE}"],
229            }
230        });
231        let vars = MapVariables(HashMap::new());
232        let err = expand(&mut json, &vars, None).expect_err("should fail");
233        println!("{:?}", miette::Report::new(err));
234    }
235
236    #[test]
237    fn collects_all_errors() {
238        let mut json = json!([
239            "$REPLACE ${other.REPLACE}",
240            { "works": "${env.REPLACE}" },
241            { "nested": "${env.DOESNOTEXIST}" },
242        ]);
243        let vars = MapVariables(HashMap::from([(
244            "REPLACE".to_string(),
245            "value".to_string(),
246        )]));
247        let err = expand(&mut json, &vars, None).expect_err("should fail");
248        println!("{:?}", miette::Report::new(err));
249    }
250
251    #[test]
252    fn diagnostics() {
253        let mut json = json!([
254            "$REPLACE ${other.REPLACE}",
255            { "works": "${env.REPLACE}" },
256            { "nested": "${env.DOESNOTEXIST}" },
257        ]);
258        let source_code = r#"
259          - '$REPLACE ${other.REPLACE}'
260          - works: ${env.REPLACE}
261          - { nested: "${env.DOESNOTEXIST}" }
262        "#;
263        let marked_yaml = crate::YamlLocationData::parse(source_code).expect("valid yaml");
264        let vars = MapVariables(HashMap::from([(
265            "REPLACE".to_string(),
266            "value".to_string(),
267        )]));
268        let err = expand(&mut json, &vars, Some(&marked_yaml)).expect_err("should fail");
269        insta::assert_snapshot!(print_miette(&err), @r#"
270        apollo::configuration::expansion
271
272          × could not expand configuration values
273
274        Error: apollo::configuration::env_invalid
275
276          × invalid bare expansion
277           ╭─[2:13]
278         1 │ 
279         2 │           - '$REPLACE ${other.REPLACE}'
280           ·             ─────────────┬─────────────
281           ·                          ╰── change this to `${env.REPLACE}`
282         3 │           - works: ${env.REPLACE}
283           ╰────
284
285        Error: apollo::configuration::env_missing
286
287          × environment variable not present
288           ╭─[4:23]
289         3 │           - works: ${env.REPLACE}
290         4 │           - { nested: "${env.DOESNOTEXIST}" }
291           ·                       ───────────┬──────────
292           ·                                  ╰── environment variable not present
293         5 │         
294           ╰────
295        "#);
296    }
297
298    #[test]
299    fn not_unicode() {
300        // Like `MapVariables`, but with non-utf8 strings allowed
301        struct MockNonUtf8Variables;
302        impl VariableProvider for MockNonUtf8Variables {
303            fn get<'a>(
304                &'a self,
305                name: &str,
306            ) -> Result<std::borrow::Cow<'a, str>, std::env::VarError> {
307                match name {
308                    "NON_UTF8" => Err(std::env::VarError::NotUnicode(OsString::new())),
309                    _ => Ok(std::borrow::Cow::Owned(name.to_lowercase())),
310                }
311            }
312        }
313
314        let mut json = json!({
315            "UTF8": "${env.UTF8}",
316            "NON_UTF8": "${env.NON_UTF8}",
317        });
318        let vars = MockNonUtf8Variables;
319        let err = expand(&mut json, &vars, None).expect_err("should fail");
320        assert!(matches!(
321            err.errors.as_slice(),
322            [super::ExpandErrorKind::NotUnicode { .. }]
323        ));
324    }
325
326    fn print_miette(diagnostic: &dyn miette::Diagnostic) -> String {
327        struct F<'a>(&'a dyn miette::Diagnostic);
328        impl std::fmt::Display for F<'_> {
329            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330                miette::MietteHandlerOpts::new()
331                    .color(false)
332                    .build()
333                    .debug(self.0, f)
334            }
335        }
336
337        F(diagnostic).to_string()
338    }
339}