avantis_utils/
config.rs

1//! A simple config module wrapping over [config::Config] module.
2//!
3//! At Avantis, we support environment config file system for most configurations
4//! include settings (ex. `DEBUG=true`) and data access (ie. database or API endpoints).
5//! We also support overriding mechanism via environment variables for credentials
6//! (ie. usernames, passwords, API keys). This keep credentials safe from accidentially
7//! upload to code repository and provide a single access point to credentials for easier
8//! rotation.
9//!
10//! For most projects, we recommend using [load_config] which take an [Environment]
11//! enum value, and return a config model struct.
12//!     
13//! 1. Create a base config file like `config/base.toml`[^1] to your project.
14//! 2. Create an environment config file like `config/develop.toml` to your project.
15//! 3. Set env to replace credentials. Use `APP` for prefix and separator `__` for hierarchy.
16//!    For example, `APP_STOCK_DB__PASSWORD` will replace config at field `stock_db.password`.
17//! 4. In your code, create a config struct which mirror configuration from earlier steps.
18//! 5. Call `load_config` with selected Environment into the struct from step 4.
19//!
20//! For example usage, see [here](https://github.com/ava-global/avantis-rust-utilities/blob/main/examples/config/main.rs)
21//! and its config files [here](https://github.com/ava-global/avantis-rust-utilities/tree/main/config).
22//!
23//! If you need to customize load mechanism, see [load_custom_config] or maybe use [config::Config] directly instead.
24//!
25//! [^1]: Any format listed in [config::FileFormat] can be used.
26
27use std::str::FromStr;
28
29use anyhow::anyhow;
30use anyhow::Result;
31use config_rs::Config;
32use config_rs::Environment as EnvironmentVariables;
33use config_rs::File;
34use config_rs::FileFormat;
35use config_rs::FileSourceFile;
36use serde::Deserialize;
37use strum::EnumString;
38
39/// Load config from selected [Environment].
40/// Returns a Result containing config struct.
41/// Convenience [load_custom_config].
42///
43/// # Example
44///
45/// ```
46/// # use serde::Deserialize;
47/// # use avantis_utils::config::load_config;
48/// # use avantis_utils::config::Environment;
49/// #[derive(Clone, Debug, Deserialize, PartialEq)]
50/// struct MyConfig {
51///     log_level: String,
52/// }
53///
54/// fn main() {
55///     let config: MyConfig = load_config(Environment::Develop).unwrap();
56///
57///     println!("{:?}", config);
58/// }
59/// ```
60pub fn load_config<'de, T: Deserialize<'de>>(environment: Environment) -> Result<T> {
61    let base_config_file = File::with_name("config/base").required(true);
62    let env_config_file = File::with_name(&format!("config/{}", environment)).required(true);
63
64    let custom_env_vars = EnvironmentVariables::with_prefix("app")
65        .prefix_separator("_")
66        .separator("__");
67
68    load_custom_config(base_config_file, env_config_file, custom_env_vars)
69}
70
71/// Load config by path from selected [Environment] and [Path].
72/// Returns a Result containing config struct.
73/// Convenience [load_custom_config].
74///
75/// # Example
76///
77/// ```
78/// # use serde::Deserialize;
79/// # use avantis_utils::config::load_config_by_path;
80/// # use avantis_utils::config::Environment;
81/// #[derive(Clone, Debug, Deserialize, PartialEq)]
82/// struct MyConfig {
83///     log_level: String,
84/// }
85///
86/// fn main() {
87///     let config: MyConfig = load_config_by_path(Environment::Develop, "config").unwrap();
88///
89///     println!("{:?}", config);
90/// }
91/// ```
92pub fn load_config_by_path<'de, T: Deserialize<'de>>(
93    environment: Environment,
94    path: &str,
95) -> Result<T> {
96    let base_config_file = File::with_name(&format!("{}/base", path)).required(true);
97    let env_config_file = File::with_name(&format!("{}/{}", path, environment)).required(true);
98
99    let custom_env_vars = EnvironmentVariables::with_prefix("app")
100        .prefix_separator("_")
101        .separator("__");
102
103    load_custom_config(base_config_file, env_config_file, custom_env_vars)
104}
105
106/// Load config from custom sources.
107/// Returns a Result containing config struct.
108///
109/// # Example
110///
111/// ```
112/// # use serde::Deserialize;
113/// # use avantis_utils::config::load_custom_config;
114/// #[derive(Clone, Debug, Deserialize, PartialEq)]
115/// struct MyConfig {
116///     log_level: String,
117/// }
118///
119/// fn main() {
120///     let config: MyConfig = load_custom_config(
121///         config_rs::File::with_name("config/base"),
122///         config_rs::File::with_name("config/test"),
123///         config_rs::Environment::with_prefix("app").separator("__"),
124///     ).unwrap();
125///
126///     println!("{:?}", config);
127/// }
128/// ```
129pub fn load_custom_config<'de, T: Deserialize<'de>>(
130    base_config_file: File<FileSourceFile, FileFormat>,
131    env_config_file: File<FileSourceFile, FileFormat>,
132    custom_env_vars: EnvironmentVariables,
133) -> Result<T> {
134    Config::builder()
135        .add_source(base_config_file)
136        .add_source(env_config_file)
137        .add_source(custom_env_vars)
138        .build()?
139        .try_deserialize()
140        .map_err(|err| {
141            anyhow!(
142                "Unable to deserialize into config with type {} with error: {}",
143                std::any::type_name::<T>(),
144                err
145            )
146        })
147}
148
149/// Application environment. Affect configuration file loaded by [load_config].
150///
151/// Any format listed in [config::FileFormat] can be used.
152#[derive(PartialEq, Eq, Debug, EnumString, strum::Display)]
153pub enum Environment {
154    /// Local environment. Will use `config/local.[FORMAT]`.
155    #[strum(serialize = "local")]
156    Local,
157
158    /// Test environment. Will use `config/test.[FORMAT]`.
159    #[strum(serialize = "test")]
160    Test,
161
162    /// Develop environment. Will use `config/develop.[FORMAT]`.
163    #[strum(serialize = "develop")]
164    Develop,
165
166    /// Production environment. Will use `config/production.[FORMAT]`.
167    #[strum(serialize = "production")]
168    Production,
169}
170
171impl Environment {
172    /// Load environment from default env `APP_ENVIRONMENT`. Return Result of Environment.
173    /// If env `APP_ENVIRONMENT` is not set, return `Ok(Environment::default())`.
174    ///
175    /// # Example
176    ///
177    /// ```
178    /// # use avantis_utils::config::Environment;
179    /// # std::env::set_var("APP_ENVIRONMENT", "develop");
180    /// let environment = Environment::from_env().unwrap();
181    /// ```
182    pub fn from_env() -> Result<Self> {
183        Self::from_custom_env("APP_ENVIRONMENT")
184    }
185
186    /// Load environment from given env. Return Result of Environment.
187    /// If env `APP_ENVIRONMENT` is not set, return `Ok(Environment::default())`.
188    ///
189    /// # Example
190    ///
191    /// ```
192    /// # use avantis_utils::config::Environment;
193    /// # std::env::set_var("CUSTOM_ENVIRONMENT", "develop");
194    /// let environment = Environment::from_custom_env("CUSTOM_ENVIRONMENT").unwrap();
195    /// ```
196    pub fn from_custom_env(key: &str) -> Result<Self> {
197        std::env::var(key)
198            .map(|environment_string| {
199                Environment::from_str(&environment_string)
200                    .map_err(|_| anyhow!("Unknown environment: {environment_string}"))
201            })
202            .unwrap_or_else(|_| Ok(Environment::default()))
203    }
204}
205
206impl Default for Environment {
207    fn default() -> Self {
208        if cfg!(test) {
209            Environment::Test
210        } else {
211            Environment::Local
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use serial_test::serial;
219
220    use super::*;
221
222    #[derive(Clone, Debug, Deserialize, PartialEq)]
223    struct MyConfig {
224        log_level: String,
225        db: MyDbConfig,
226    }
227
228    #[derive(Clone, Debug, Deserialize, PartialEq)]
229    struct MyDbConfig {
230        host: String,
231        user: String,
232        password: String,
233        db_name: String,
234        max_connections: u32,
235    }
236
237    #[test]
238    #[serial]
239    fn test_load_config_success() {
240        std::env::set_var("APP_DB__PASSWORD", "supersecurepassword");
241
242        let expected = MyConfig {
243            log_level: "info".to_string(),
244            db: MyDbConfig {
245                host: "localhost".to_string(),
246                user: "username".to_string(),
247                password: "supersecurepassword".to_string(),
248                db_name: "my_db".to_string(),
249                max_connections: 30,
250            },
251        };
252
253        let actual = load_custom_config::<MyConfig>(
254            File::with_name("config/base").required(true),
255            File::with_name("config/develop").required(true),
256            EnvironmentVariables::with_prefix("app")
257                .prefix_separator("_")
258                .separator("__"),
259        )
260        .unwrap();
261
262        assert_eq!(expected, actual);
263
264        let actual = load_config::<MyConfig>(Environment::Develop).unwrap();
265
266        assert_eq!(expected, actual);
267
268        let actual = load_config::<MyConfig>(Environment::Test).unwrap();
269
270        assert_eq!(expected, actual);
271
272        let actual = load_config::<MyConfig>(Environment::Production).unwrap();
273
274        assert_eq!(expected, actual);
275
276        std::env::remove_var("APP_DB__PASSWORD");
277    }
278
279    #[test]
280    #[serial]
281    fn test_load_config_by_path_success() {
282        std::env::set_var("APP_DB__PASSWORD", "supersecurepassword");
283
284        let expected = MyConfig {
285            log_level: "info".to_string(),
286            db: MyDbConfig {
287                host: "localhost".to_string(),
288                user: "username".to_string(),
289                password: "supersecurepassword".to_string(),
290                db_name: "my_db".to_string(),
291                max_connections: 30,
292            },
293        };
294
295        let actual = load_custom_config::<MyConfig>(
296            File::with_name("config-workspace/config/base").required(true),
297            File::with_name("config-workspace/config/develop").required(true),
298            EnvironmentVariables::with_prefix("app")
299                .prefix_separator("_")
300                .separator("__"),
301        )
302        .unwrap();
303
304        assert_eq!(expected, actual);
305
306        let actual =
307            load_config_by_path::<MyConfig>(Environment::Develop, "config-workspace/config")
308                .unwrap();
309
310        assert_eq!(expected, actual);
311
312        let actual =
313            load_config_by_path::<MyConfig>(Environment::Test, "config-workspace/config").unwrap();
314
315        assert_eq!(expected, actual);
316
317        let actual =
318            load_config_by_path::<MyConfig>(Environment::Production, "config-workspace/config")
319                .unwrap();
320
321        assert_eq!(expected, actual);
322
323        std::env::remove_var("APP_DB__PASSWORD");
324    }
325
326    #[test]
327    #[serial]
328    #[should_panic(expected = "configuration file \"config/staging\" not found")]
329    fn test_load_config_file_not_found() {
330        load_custom_config::<MyConfig>(
331            File::with_name("config/base").required(true),
332            File::with_name("config/staging").required(true),
333            EnvironmentVariables::with_prefix("app").separator("__"),
334        )
335        .unwrap();
336    }
337
338    #[test]
339    #[serial]
340    #[should_panic(
341        expected = "Unable to deserialize into config with type avantis_utils::config::tests::MyConfig with error: missing field"
342    )]
343    fn test_load_config_missing_fields() {
344        load_custom_config::<MyConfig>(
345            File::with_name("config/base").required(true),
346            File::with_name("config/base").required(true),
347            EnvironmentVariables::with_prefix("app").separator("__"),
348        )
349        .unwrap();
350    }
351
352    #[test]
353    #[serial]
354    fn test_environment_from_env() {
355        assert_eq!(Environment::Test, Environment::from_env().unwrap());
356
357        assert_eq!(
358            Environment::Test,
359            Environment::from_custom_env("APP_ENVIRONMENT").unwrap()
360        );
361
362        std::env::set_var("APP_ENVIRONMENT", "local");
363
364        assert_eq!(Environment::Local, Environment::from_env().unwrap());
365
366        assert_eq!(
367            Environment::Local,
368            Environment::from_custom_env("APP_ENVIRONMENT").unwrap()
369        );
370
371        std::env::remove_var("APP_ENVIRONMENT")
372    }
373
374    #[test]
375    #[serial]
376    #[should_panic(expected = "Unknown environment: staging")]
377    fn test_environment_from_unknown_env() {
378        std::env::set_var("APP_ENVIRONMENT_INVALID", "staging");
379
380        let result = Environment::from_custom_env("APP_ENVIRONMENT_INVALID");
381
382        std::env::remove_var("APP_ENVIRONMENT_INVALID");
383
384        result.unwrap();
385    }
386}