smart_config/source/
env.rs

1use std::{env, fmt, mem, sync::Arc};
2
3use anyhow::Context as _;
4
5use super::{ConfigSource, Flat};
6use crate::{
7    Json,
8    testing::MOCK_ENV_VARS,
9    utils::JsonObject,
10    value::{FileFormat, Map, Value, ValueOrigin, WithOrigin},
11};
12
13/// Configuration sourced from environment variables.
14#[derive(Debug, Clone)]
15pub struct Environment {
16    origin: Arc<ValueOrigin>,
17    map: Map,
18}
19
20impl Default for Environment {
21    fn default() -> Self {
22        Self {
23            origin: Arc::new(ValueOrigin::EnvVars),
24            map: Map::new(),
25        }
26    }
27}
28
29impl Environment {
30    /// Loads environment variables with the specified prefix.
31    pub fn prefixed(prefix: &str) -> Self {
32        MOCK_ENV_VARS.with_borrow(|mock_vars| {
33            let mock_vars = mock_vars
34                .iter()
35                .map(|(key, value)| (key.clone(), value.clone()));
36            Self::from_iter(prefix, env::vars().chain(mock_vars))
37        })
38    }
39
40    /// Creates a custom environment.
41    pub fn from_iter<K, V>(prefix: &str, env: impl IntoIterator<Item = (K, V)>) -> Self
42    where
43        K: AsRef<str> + Into<String>,
44        V: Into<String>,
45    {
46        let origin = Arc::new(ValueOrigin::EnvVars);
47        let map = env.into_iter().filter_map(|(name, value)| {
48            let retained_name = name.as_ref().strip_prefix(prefix)?.to_lowercase();
49            Some((
50                retained_name,
51                WithOrigin {
52                    inner: Value::from(value.into()),
53                    origin: Arc::new(ValueOrigin::Path {
54                        source: origin.clone(),
55                        path: name.into(),
56                    }),
57                },
58            ))
59        });
60        let map = map.collect();
61        Self { origin, map }
62    }
63
64    /// Adds additional variables to this environment. This is useful if the added vars don't have the necessary prefix.
65    #[must_use]
66    pub fn with_vars(mut self, var_names: &[&str]) -> Self {
67        let origin = Arc::new(ValueOrigin::EnvVars);
68        let defined_vars = var_names.iter().filter_map(|&name| {
69            let value = env::var_os(name)?.into_string().ok()?;
70            Some((
71                name.to_owned(),
72                WithOrigin {
73                    inner: Value::from(value),
74                    origin: Arc::new(ValueOrigin::Path {
75                        source: origin.clone(),
76                        path: name.to_owned(),
77                    }),
78                },
79            ))
80        });
81        self.map.extend(defined_vars);
82        self
83    }
84
85    #[doc(hidden)] // FIXME: functionally incomplete ('' strings, interpolation, comments after vars)
86    pub fn from_dotenv(filename: &str, contents: &str) -> anyhow::Result<Self> {
87        let origin = Arc::new(ValueOrigin::File {
88            name: filename.to_owned(),
89            format: FileFormat::Dotenv,
90        });
91        let mut map = Map::default();
92        for line in contents.lines().map(str::trim) {
93            if line.is_empty() || line.starts_with('#') {
94                continue;
95            }
96            let (name, variable_value) = line.split_once('=').with_context(|| {
97                format!("Incorrect line for setting environment variable: {line}")
98            })?;
99            let variable_value = variable_value.trim_matches('"');
100            map.insert(
101                name.to_lowercase(),
102                WithOrigin {
103                    inner: Value::from(variable_value.to_owned()),
104                    origin: Arc::new(ValueOrigin::Path {
105                        source: origin.clone(),
106                        path: name.into(),
107                    }),
108                },
109            );
110        }
111        Ok(Self { origin, map })
112    }
113
114    /// Iterates over variables in this container.
115    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &WithOrigin)> + '_ {
116        self.map.iter().map(|(name, value)| (name.as_str(), value))
117    }
118
119    /// Strips a prefix from all contained vars and returns the filtered vars.
120    #[must_use]
121    pub fn strip_prefix(self, prefix: &str) -> Self {
122        let prefix = prefix.to_lowercase();
123        let filtered = self
124            .map
125            .into_iter()
126            .filter_map(|(name, value)| Some((name.strip_prefix(&prefix)?.to_owned(), value)));
127        Self {
128            origin: self.origin,
129            map: filtered.collect(),
130        }
131    }
132
133    /// Coerces JSON values in env variables which names end with the `__json` / `:json` suffixes and strips this suffix.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if any coercion fails; provides a list of all failed coercions. Successful coercions are still applied in this case.
138    pub fn coerce_json(&mut self) -> anyhow::Result<()> {
139        let mut coerced_values = vec![];
140        let mut errors = vec![];
141        for (key, value) in &self.map {
142            let stripped_key = key
143                .strip_suffix("__json")
144                .or_else(|| key.strip_suffix(":json"));
145            let Some(stripped_key) = stripped_key else {
146                continue;
147            };
148            let Some(value_str) = value.inner.as_plain_str() else {
149                // The value was already transformed, probably.
150                continue;
151            };
152
153            let val = match serde_json::from_str::<serde_json::Value>(value_str) {
154                Ok(val) => val,
155                Err(err) => {
156                    mem::take(&mut coerced_values);
157                    errors.push((value.origin.clone(), err));
158                    continue;
159                }
160            };
161            if !errors.is_empty() {
162                continue; // No need to record coerced values if there are coercion errors.
163            }
164
165            let root_origin = Arc::new(ValueOrigin::Synthetic {
166                source: value.origin.clone(),
167                transform: "parsed JSON string".into(),
168            });
169            let coerced_value = Json::map_value(val, &root_origin, String::new());
170            coerced_values.push((key.to_owned(), stripped_key.to_owned(), coerced_value));
171        }
172
173        for (key, stripped_key, coerced_value) in coerced_values {
174            self.map.remove(&key);
175            self.map.insert(stripped_key, coerced_value);
176        }
177
178        if errors.is_empty() {
179            Ok(())
180        } else {
181            Err(JsonCoercionErrors(errors).into())
182        }
183    }
184
185    /// Converts a [flat configuration object](crate::SerializerOptions::flat()) into a flat object
186    /// usable as the env var specification for Docker Compose etc. It uppercases and prefixes param names,
187    /// replacing `.`s with `_`s, and replaces object / JSON params with strings so that they can be parsed
188    /// via [JSON coercion](Self::coerce_json()).
189    ///
190    /// # Important
191    ///
192    /// Beware that additional transforms may be required depending on the use case. E.g., Docker Compose
193    /// requires to escape Boolean values and nulls to strings.
194    pub fn convert_flat_params(flat_params: &JsonObject, prefix: &str) -> JsonObject {
195        let vars = flat_params.iter().map(|(path, value)| {
196            let mut var_name = path.replace('.', "_").to_uppercase();
197            var_name.insert_str(0, prefix);
198            let value: serde_json::Value = match value {
199                serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
200                    var_name.push_str("__JSON");
201                    value.to_string().into()
202                }
203                simple => simple.clone(),
204            };
205            (var_name, value)
206        });
207        vars.collect()
208    }
209}
210
211#[derive(Debug)]
212struct JsonCoercionErrors(Vec<(Arc<ValueOrigin>, serde_json::Error)>);
213
214impl fmt::Display for JsonCoercionErrors {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        writeln!(
217            formatter,
218            "failed coercing flat configuration params to JSON:"
219        )?;
220        for (i, (key, err)) in self.0.iter().enumerate() {
221            writeln!(formatter, "{}. {key}: {err}", i + 1)?;
222        }
223        Ok(())
224    }
225}
226
227impl std::error::Error for JsonCoercionErrors {}
228
229impl ConfigSource for Environment {
230    type Kind = Flat;
231
232    fn into_contents(self) -> WithOrigin<Map> {
233        WithOrigin::new(self.map, self.origin)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use assert_matches::assert_matches;
240
241    use super::*;
242
243    #[test]
244    fn parsing_dotenv_contents() {
245        let env = Environment::from_dotenv(
246            "test.env",
247            r#"
248            APP_TEST=what
249            APP_OTHER="test string"
250
251            # Overwriting vars should be supported
252            APP_TEST=42
253            "#,
254        )
255        .unwrap();
256
257        assert_eq!(env.map.len(), 2, "{:?}", env.map);
258        assert_eq!(env.map["app_test"].inner.as_plain_str(), Some("42"));
259        let origin = &env.map["app_test"].origin;
260        let ValueOrigin::Path { path, source } = origin.as_ref() else {
261            panic!("unexpected origin: {origin:?}");
262        };
263        assert_eq!(path, "APP_TEST");
264        assert_matches!(
265            source.as_ref(),
266            ValueOrigin::File { name, format: FileFormat::Dotenv } if name == "test.env"
267        );
268        assert_eq!(
269            env.map["app_other"].inner.as_plain_str(),
270            Some("test string")
271        );
272
273        let env = env.strip_prefix("app_");
274        assert_eq!(env.map.len(), 2, "{:?}", env.map);
275        assert_eq!(env.map["test"].inner.as_plain_str(), Some("42"));
276        assert_matches!(env.map["test"].origin.as_ref(), ValueOrigin::Path { path, .. } if path == "APP_TEST");
277        assert_eq!(env.map["other"].inner.as_plain_str(), Some("test string"));
278    }
279
280    #[test]
281    fn converting_flat_params() {
282        let params = serde_json::json!({
283            "value": 23,
284            "flag": true,
285            "nested.option": null,
286            "nested.renamed": "first",
287            "nested.set": ["first", "second"],
288            "nested.map": { "call": 42 },
289        });
290        let params = params.as_object().unwrap();
291
292        let converted = Environment::convert_flat_params(params, "APP_");
293        assert_eq!(
294            serde_json::Value::from(converted),
295            serde_json::json!({
296                "APP_VALUE": 23,
297                "APP_FLAG": true,
298                "APP_NESTED_OPTION": null,
299                "APP_NESTED_RENAMED": "first",
300                "APP_NESTED_SET__JSON": r#"["first","second"]"#,
301                "APP_NESTED_MAP__JSON": r#"{"call":42}"#,
302            })
303        );
304    }
305}