spin_variables_env/
lib.rs

1use std::{
2    collections::HashMap,
3    env::VarError,
4    path::{Path, PathBuf},
5    sync::OnceLock,
6};
7
8use serde::Deserialize;
9use spin_expressions::{Key, Provider};
10use spin_factors::anyhow::{self, Context as _};
11use spin_world::async_trait;
12use tracing::{instrument, Level};
13
14/// Configuration for the environment variables provider.
15#[derive(Debug, Default, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct EnvVariablesConfig {
18    /// A prefix to add to variable names when resolving from the environment.
19    ///
20    /// Unless empty, joined to the variable name with an underscore.
21    #[serde(default)]
22    pub prefix: Option<String>,
23    /// Optional path to a 'dotenv' file which will be merged into the environment.
24    #[serde(default)]
25    pub dotenv_path: Option<PathBuf>,
26}
27
28const DEFAULT_ENV_PREFIX: &str = "SPIN_VARIABLE";
29
30type EnvFetcherFn = Box<dyn Fn(&str) -> Result<String, VarError> + Send + Sync>;
31
32/// A [`Provider`] that uses environment variables.
33pub struct EnvVariablesProvider {
34    prefix: Option<String>,
35    env_fetcher: EnvFetcherFn,
36    dotenv_path: Option<PathBuf>,
37    dotenv_cache: OnceLock<HashMap<String, String>>,
38}
39
40impl Default for EnvVariablesProvider {
41    fn default() -> Self {
42        Self {
43            prefix: None,
44            env_fetcher: Box::new(|s| std::env::var(s)),
45            dotenv_path: Some(".env".into()),
46            dotenv_cache: Default::default(),
47        }
48    }
49}
50
51impl EnvVariablesProvider {
52    /// Creates a new EnvProvider.
53    ///
54    /// * `prefix` - The string prefix to use to distinguish an environment variable that should be used.
55    ///   If not set, the default prefix is used.
56    /// * `env_fetcher` - The function to use to fetch an environment variable.
57    /// * `dotenv_path` - The path to the .env file to load environment variables from. If not set,
58    ///   no .env file is loaded.
59    pub fn new(
60        prefix: Option<impl Into<String>>,
61        env_fetcher: impl Fn(&str) -> Result<String, VarError> + Send + Sync + 'static,
62        dotenv_path: Option<PathBuf>,
63    ) -> Self {
64        Self {
65            prefix: prefix.map(Into::into),
66            dotenv_path,
67            env_fetcher: Box::new(env_fetcher),
68            dotenv_cache: Default::default(),
69        }
70    }
71
72    /// Gets the value of a variable from the environment.
73    fn get_sync(&self, key: &Key) -> anyhow::Result<Option<String>> {
74        let prefix = self
75            .prefix
76            .clone()
77            .unwrap_or_else(|| DEFAULT_ENV_PREFIX.to_string());
78
79        let upper_key = key.as_ref().to_ascii_uppercase();
80        let env_key = format!("{prefix}_{upper_key}");
81
82        self.query_env(&env_key)
83    }
84
85    /// Queries the environment for a variable defaulting to dotenv.
86    fn query_env(&self, env_key: &str) -> anyhow::Result<Option<String>> {
87        match (self.env_fetcher)(env_key) {
88            Err(std::env::VarError::NotPresent) => self.get_dotenv(env_key),
89            other => other
90                .map(Some)
91                .with_context(|| format!("failed to resolve env var {env_key}")),
92        }
93    }
94
95    fn get_dotenv(&self, key: &str) -> anyhow::Result<Option<String>> {
96        let Some(dotenv_path) = self.dotenv_path.as_deref() else {
97            return Ok(None);
98        };
99        let cache = match self.dotenv_cache.get() {
100            Some(cache) => cache,
101            None => {
102                let cache = load_dotenv(dotenv_path)?;
103                let _ = self.dotenv_cache.set(cache);
104                // Safe to unwrap because we just set the cache.
105                // Ensures we always get the first value set.
106                self.dotenv_cache.get().unwrap()
107            }
108        };
109        Ok(cache.get(key).cloned())
110    }
111}
112
113impl std::fmt::Debug for EnvVariablesProvider {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("EnvProvider")
116            .field("prefix", &self.prefix)
117            .field("dotenv_path", &self.dotenv_path)
118            .finish()
119    }
120}
121
122fn load_dotenv(dotenv_path: &Path) -> anyhow::Result<HashMap<String, String>> {
123    Ok(dotenvy::from_path_iter(dotenv_path)
124        .into_iter()
125        .flatten()
126        .collect::<Result<HashMap<String, String>, _>>()?)
127}
128
129#[async_trait]
130impl Provider for EnvVariablesProvider {
131    #[instrument(name = "spin_variables.get_from_env", level = Level::DEBUG, skip(self), err(level = Level::INFO))]
132    async fn get(&self, key: &Key) -> anyhow::Result<Option<String>> {
133        tokio::task::block_in_place(|| self.get_sync(key))
134    }
135
136    fn may_resolve(&self, key: &Key) -> bool {
137        matches!(self.get_sync(key), Ok(Some(_)))
138    }
139}
140
141#[cfg(test)]
142mod test {
143    use std::env::temp_dir;
144
145    use super::*;
146
147    struct TestEnv {
148        map: HashMap<String, String>,
149    }
150
151    impl TestEnv {
152        fn new() -> Self {
153            Self {
154                map: Default::default(),
155            }
156        }
157
158        fn insert(&mut self, key: &str, value: &str) {
159            self.map.insert(key.to_string(), value.to_string());
160        }
161
162        fn get(&self, key: &str) -> Result<String, VarError> {
163            self.map.get(key).cloned().ok_or(VarError::NotPresent)
164        }
165    }
166
167    #[test]
168    fn provider_get() {
169        let mut env = TestEnv::new();
170        env.insert("TESTING_SPIN_ENV_KEY1", "val");
171        let key1 = Key::new("env_key1").unwrap();
172        assert_eq!(
173            EnvVariablesProvider::new(Some("TESTING_SPIN"), move |key| env.get(key), None)
174                .get_sync(&key1)
175                .unwrap(),
176            Some("val".to_string())
177        );
178    }
179
180    #[test]
181    fn provider_get_dotenv() {
182        let dotenv_path = temp_dir().join("spin-env-provider-test");
183        std::fs::write(&dotenv_path, b"TESTING_SPIN_ENV_KEY2=dotenv_val").unwrap();
184
185        let key = Key::new("env_key2").unwrap();
186        assert_eq!(
187            EnvVariablesProvider::new(
188                Some("TESTING_SPIN"),
189                |_| Err(VarError::NotPresent),
190                Some(dotenv_path)
191            )
192            .get_sync(&key)
193            .unwrap(),
194            Some("dotenv_val".to_string())
195        );
196    }
197
198    #[test]
199    fn provider_get_missing() {
200        let key = Key::new("definitely_not_set").unwrap();
201        assert_eq!(
202            EnvVariablesProvider::new(
203                Some("TESTING_SPIN"),
204                |_| Err(VarError::NotPresent),
205                Default::default()
206            )
207            .get_sync(&key)
208            .unwrap(),
209            None
210        );
211    }
212}