north_config/
config_source.rs

1use crate::serde_utils::Merge;
2use convert_case::Casing;
3use json_dotpath::DotPaths;
4use serde::de;
5use serde_json::Value;
6use std::fmt::{Debug, Formatter};
7use std::path::PathBuf;
8
9#[cfg(any(feature = "tokio", feature = "async-std"))]
10use async_trait::async_trait;
11#[cfg(not(any(feature = "tokio", feature = "async-std")))]
12use std::io::Read;
13
14use crate::error::Error;
15use crate::utils::{import_env_vars, preamble};
16pub use convert_case::Case;
17
18/// Trait for cloning a boxed `CustomConfigSource` object.
19/// This is used in cases where we need to clone a trait object without knowing its concrete type.
20pub trait CustomConfigSourceClone {
21    fn clone_box(&self) -> Box<dyn CustomConfigSource>;
22}
23
24impl<T> CustomConfigSourceClone for T
25where
26    T: 'static + CustomConfigSource + Clone + Debug,
27{
28    fn clone_box(&self) -> Box<dyn CustomConfigSource> {
29        Box::new(self.clone())
30    }
31}
32
33impl Clone for Box<dyn CustomConfigSource> {
34    fn clone(&self) -> Box<dyn CustomConfigSource> {
35        self.clone_box()
36    }
37}
38
39impl Debug for Box<dyn CustomConfigSource> {
40    fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result {
41        Ok(())
42    }
43}
44
45/// Allows for providing a custom configuration source.
46///
47/// This trait can be implemented to define a custom configuration source. The trait
48/// extends the `CustomConfigSourceClone` trait and requires the `Self` type to be
49/// cloneable and support sending between threads (`Send`) and sharing between threads
50/// (`Sync`).
51#[cfg(not(any(feature = "tokio", feature = "async-std")))]
52pub trait CustomConfigSource: CustomConfigSourceClone + Send + Sync {
53    /// This is the only implementable member.
54    /// Here you can return a serde value
55    fn get_config_value(&self) -> Result<Value, Error>;
56}
57
58/// Allows you to provide a custom config source that can be accessed asynchronously.
59///
60/// This trait can be implemented to define a custom config source. The custom config source should implement the `CustomConfigSourceClone`, `Send`, and
61#[cfg(any(feature = "tokio", feature = "async-std"))]
62#[async_trait]
63pub trait CustomConfigSource: CustomConfigSourceClone + Send + Sync {
64    /// This is the only implementable member.
65    /// Here you can return a serde value
66    async fn get_config_value(&self) -> Result<Value, Error>;
67}
68
69/// Represents the source of configuration values.
70///
71/// This enum has three variants:
72/// - `Env`: Represents loading configuration values from OS environment variables.
73/// - `File`: Represents loading configuration values from a JSON, YAML, or TOML file.
74/// - `Custom`: Represents loading configuration values using a custom implementation of `CustomConfigSource`.
75///
76/// # Examples
77///
78/// ## Using `Env` variant with default path
79///
80/// ```
81/// use north_config::{ConfigSource, EnvSourceOptions};
82///
83/// let source = ConfigSource::Env(EnvSourceOptions::default());
84/// ```
85///
86/// ## Using `Env` variant with custom path
87///
88/// ```
89/// use north_config::{ConfigSource, EnvSourceOptions};
90///
91/// let source = ConfigSource::Env(EnvSourceOptions::default());
92/// ```
93///
94/// ## Using `File` variant
95///
96/// ```
97/// use north_config::ConfigSource;
98///
99/// let source = ConfigSource::File(String::from("config.json"), None);
100/// ```
101///
102/// ## Using `Custom` variant with a custom implementation
103///
104/// ```
105/// use north_config::{ConfigSource, CustomConfigSource, Error};
106/// #[derive(Clone, Debug)]
107/// struct MyCustomSource;
108///
109/// impl CustomConfigSource for MyCustomSource {
110///     // implementation details here
111///    fn get_config_value(&self) -> Result<serde_json::Value, Error> {
112///        todo!()
113///    }
114/// }
115///
116/// let source = ConfigSource::Custom(Box::new(MyCustomSource));
117/// ```
118#[derive(Debug, Clone)]
119pub enum ConfigSource {
120    /// # Env
121    /// loads from the OS env variables
122    /// <br />
123    ///
124    ///
125    /// # Example
126    ///
127    /// With default path
128    /// ```rust
129    ///
130    /// use north_config::{ConfigSource, EnvSourceOptions};
131    /// ConfigSource::Env(EnvSourceOptions::default());
132    /// ```
133    /// With custom path
134    /// ```rust
135    ///
136    /// use north_config::{ConfigSource, EnvSourceOptions};
137    /// ConfigSource::Env(EnvSourceOptions::default());
138    /// ```
139    Env(EnvSourceOptions),
140
141    /// loads a json, YAML, OR TOML file
142    File(String, Option<FileSourceOptions>),
143
144    /// loads a json, YAML, OR TOML file
145    Custom(Box<dyn CustomConfigSource>),
146}
147
148impl Default for ConfigSource {
149    fn default() -> Self {
150        ConfigSource::Env(EnvSourceOptions::default())
151    }
152}
153
154/// # NorthConfigOptions
155///
156/// Represents the available options for initializing a `NorthConfig`.
157///
158/// ## Fields
159///
160/// - `sources`: A list of available configuration sources from the environment.
161///   - Type: `Vec<ConfigSource>`
162///   - Description: The potential sources of configuration data from the environment.
163///   - Default: An empty vector (`Vec::new()`)
164#[derive(Debug, Clone, Default)]
165pub struct NorthConfigOptions {
166    /// a list of available env sources
167    pub sources: Vec<ConfigSource>,
168}
169
170impl NorthConfigOptions {
171    pub fn new(sources: Vec<ConfigSource>) -> NorthConfigOptions {
172        NorthConfigOptions { sources }
173    }
174}
175
176/// # EnvSourceOptions
177///
178/// struct exposes available options for configuring Environmental source.
179///
180/// ## Fields
181///
182/// ### prefix
183///
184/// Environmental variable key prefix.
185///
186/// This field defaults to `Some("NORTH_".to_string())`.
187///
188/// ### nested_separator
189///
190/// Nested key separator.
191///
192/// This field defaults to `Some("__".to_string())`.
193///
194/// ### key_case
195///
196/// String case to deserialize key to. This must match your struct fields.
197///
198/// This field defaults to `Some(Case::Snake)`.
199///
200/// ### env_file_path
201///
202/// Accepts custom env file path to load up.
203///
204/// This field defaults to `Some("None".to_string())`.
205///
206/// ### watch
207///
208/// Enable datasource change watch (Only supports Env and File sources).
209///
210/// This field defaults to `false`.
211#[derive(Debug, Clone)]
212pub struct EnvSourceOptions {
213    /// Environmental variable key prefix
214    ///
215    /// @defaults to ["NORTH_"]
216    pub prefix: Option<String>,
217
218    /// Nested key separator
219    ///
220    /// @defaults to ["__"]
221    pub nested_separator: Option<String>,
222
223    /// String case to deserialize key to. This must match your struct fields.
224    ///
225    /// @defaults to [Case::Snake]
226    pub key_case: Option<Case>,
227
228    /// Accepts custom env file path to load up
229    ///
230    /// @defaults to [None]
231    pub env_file_path: Option<String>,
232
233    /// Enable datasource change watch (Only supports Env and File sources)
234    ///
235    /// @defaults to False
236    pub watch: bool,
237}
238
239impl Default for EnvSourceOptions {
240    fn default() -> Self {
241        EnvSourceOptions {
242            prefix: Some("NORTH_".to_string()),
243            nested_separator: Some("__".to_string()),
244            key_case: Some(Case::Snake),
245            env_file_path: None,
246            watch: false,
247        }
248    }
249}
250
251#[derive(Debug, Clone)]
252pub struct FileSourceOptions {
253    pub skip_on_error: bool,
254    pub enabled_environment: bool,
255    pub watch: bool,
256}
257
258impl Default for FileSourceOptions {
259    fn default() -> Self {
260        FileSourceOptions {
261            enabled_environment: true,
262            skip_on_error: false,
263            watch: false,
264        }
265    }
266}
267
268/// # NorthConfig
269///
270/// Struct representing a configuration value of type `T`.
271///
272/// This `struct` is used as a wrapper around the configuration value to provide additional functionality and
273/// make it easier to work with.
274///
275/// ## Type parameters
276/// - `T`: Represents the type of the configuration value, which must implement the `Clone` and `DeserializeOwned` traits.
277///
278/// ## Fields
279/// - `value`: The actual configuration value of type `T`. It can be accessed and modified directly.
280#[derive(Debug, Clone, Default)]
281pub struct NorthConfig<T>
282where
283    T: Clone + de::DeserializeOwned,
284{
285    pub value: T,
286}
287
288impl<T: Clone + de::DeserializeOwned> NorthConfig<T> {
289    /// access the configuration
290    pub fn get_value(&self) -> &T {
291        &self.value
292    }
293}
294
295/// # new_config
296///
297/// creates a new instance of North Config. It accepts an array of data sources
298///
299/// Example
300/// ```rust,ignore
301///
302/// #[derive(Clone, serde::Deserialize, Debug)]
303/// struct DemoConfig {
304///     pub host: Option<String>,
305/// }
306///  use north_config::{ConfigSource, EnvSourceOptions, NorthConfigOptions};
307///  let config_options = NorthConfigOptions {
308///     sources: vec![
309///         // ConfigSource::File("/examples/configs/bootstrap.{{env}}.yaml".to_string()),
310///         ConfigSource::Env(EnvSourceOptions::default()),
311///     ],
312///  };
313///  let config = north_config::new_config::<DemoConfig>(config_options).await;
314///  let config_val = config.get_value();
315/// ```
316#[cfg(any(feature = "tokio", feature = "async-std"))]
317pub async fn new_config<T: Clone + de::DeserializeOwned>(
318    option: NorthConfigOptions,
319) -> NorthConfig<T> {
320    preamble();
321
322    let value = resolve_source::<T>(option).await;
323    NorthConfig { value }
324}
325
326/// Creates a new `NorthConfig` with the specified options.
327///
328/// # Arguments
329///
330/// * `option` - The options to configure the `NorthConfig`.
331///
332/// # Returns
333///
334/// A new `NorthConfig` with the specified options.
335///
336/// # Example
337///
338/// ```rust
339/// use north_config::{NorthConfigOptions, NorthConfig, EnvSourceOptions, ConfigSource};
340///
341/// envmnt::set("NORTH_NAMES__0__FIRST", "Run");
342///
343/// let mut env_opts = EnvSourceOptions::default();
344/// env_opts.prefix = Some("NORTH".to_string());
345///#[derive(Clone, serde::Deserialize, serde::Serialize, Debug)]
346///struct Names {
347///    pub first: String
348///}
349/// #[derive(Debug, serde::Deserialize, Clone)]
350/// struct MyConfig {
351///    pub names: Vec<Names>
352/// }
353///
354/// let option = NorthConfigOptions::new(vec![ConfigSource::Env(env_opts)]);  // replace with actual options
355/// let config: NorthConfig<MyConfig> = crate::north_config::new_config(option);
356/// ```
357///
358/// # Panics
359///
360/// This function may panic if the source for deserialization is missing or if deserialization fails.
361#[cfg(not(any(feature = "tokio", feature = "async-std")))]
362pub fn new_config<T: Clone + de::DeserializeOwned>(option: NorthConfigOptions) -> NorthConfig<T> {
363    preamble();
364
365    let value = resolve_source::<T>(option);
366    NorthConfig { value }
367}
368
369/// Asynchronously resolves the configuration source for the given options.
370///
371/// # Arguments
372///
373/// * `option` - The NorthConfigOptions containing the configuration sources.
374///
375/// # Constraints
376///
377/// The type `T` must implement Clone and serde's DeserializeOwned trait.
378///
379/// # Returns
380///
381/// The deserialized configuration value of type `T`.
382#[cfg(any(feature = "tokio", feature = "async-std"))]
383async fn resolve_source<T>(option: NorthConfigOptions) -> T
384where
385    T: Clone + de::DeserializeOwned,
386{
387    let mut current_value = Value::default();
388    let cargo_path = std::env::var("CARGO_MANIFEST_DIR").unwrap_or("./".to_string());
389
390    #[cfg(debug_assertions)]
391    let is_release = false;
392
393    #[cfg(not(debug_assertions))]
394    let is_release = true;
395
396    for s in option.clone().sources {
397        match s {
398            ConfigSource::Env(env_opt) => {
399                let value = resolve_env_source(env_opt);
400                if value.is_some() {
401                    current_value.merge(value.unwrap());
402                }
403            }
404            ConfigSource::File(original_path, options) => {
405                let value = resolve_file_source(
406                    cargo_path.clone(),
407                    original_path,
408                    is_release,
409                    options.unwrap_or_default(),
410                )
411                .await;
412                if value.is_some() {
413                    current_value.merge(value.unwrap());
414                }
415            }
416            ConfigSource::Custom(source) => {
417                let rsp = source.get_config_value().await;
418                match rsp {
419                    Ok(value) => {
420                        if value.is_object() {
421                            current_value.merge(value);
422                        }
423                    }
424                    Err(_) => {
425                        println!("Custom config was not loaded")
426                    }
427                }
428            }
429        };
430    }
431
432    serde_json::from_value::<T>(current_value).unwrap()
433}
434
435/// Resolves the configuration source based on the given options.
436///
437/// # Arguments
438///
439/// * `option` - The configuration options.
440///
441/// # Type Constraints
442///
443/// T must implement the `Clone` and `DeserializeOwned` traits.
444///
445/// # Returns
446///
447/// The resolved configuration source.
448///
449/// # Panics
450///
451/// This function will panic if the JSON value cannot be deserialized into the specified type.
452#[cfg(not(any(feature = "tokio", feature = "async-std")))]
453fn resolve_source<T>(option: NorthConfigOptions) -> T
454where
455    T: Clone + de::DeserializeOwned,
456{
457    let mut current_value = Value::default();
458    let cargo_path = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| "./".to_string());
459
460    #[cfg(debug_assertions)]
461    let is_release = false;
462
463    #[cfg(not(debug_assertions))]
464    let is_release = true;
465
466    for s in option.sources {
467        match s {
468            ConfigSource::Env(env_opt) => {
469                let value = resolve_env_source(env_opt);
470                if let Some(v) = value {
471                    current_value.merge(v);
472                }
473            }
474            ConfigSource::File(original_path, options) => {
475                let value = resolve_file_source(
476                    cargo_path.clone(),
477                    original_path,
478                    is_release,
479                    options.unwrap_or_default(),
480                );
481                if let Some(v) = value {
482                    current_value.merge(v);
483                }
484            }
485            ConfigSource::Custom(source) => {
486                let rsp = source.get_config_value();
487                match rsp {
488                    Ok(value) => {
489                        if value.is_object() {
490                            current_value.merge(value);
491                        }
492                    }
493                    Err(_) => {
494                        println!("Custom config was not loaded")
495                    }
496                }
497            }
498        };
499    }
500
501    serde_json::from_value::<T>(current_value).unwrap()
502}
503
504/// Resolve env variable source to Serde [Value]
505fn resolve_env_source(env_opt: EnvSourceOptions) -> Option<Value> {
506    let env_options = env_opt.clone();
507
508    // We want to load env if a path is passed
509    if env_opt.env_file_path.is_some() {
510        import_env_vars(env_opt.env_file_path.unwrap().as_str())
511    }
512
513    match process_envs(env_options) {
514        Ok(value) => {
515            if !value.is_null() {
516                Some(value)
517            } else {
518                log::error!("Error loading env variables as config");
519                None
520            }
521        }
522        Err(error) => {
523            println!("{:#?}", error);
524            None
525        }
526    }
527}
528
529/// Resolves the source file specified by `original_path` by replacing the `{{env}}` placeholder
530/// with either "release" or "debug" depending on the value of `is_release`.
531///
532/// The resolved file path is obtained by joining `original_path` with `cargo_path`.
533/// If the resolved file path does not exist, a panic with an appropriate message is raised.
534///
535/// The resolved file path is then passed to the `read_file_value` function to read the contents of the file asynchronously.
536///
537/// If the value read from the file is not null, it is returned wrapped in an `Option`.
538/// Otherwise, an error message is logged and `None` is returned.
539///
540/// # Arguments
541///
542/// * `cargo_path` - The path to the cargo project.
543/// * `original_path` - The original source file path with the `{{env}}` placeholder.
544/// * `is_release` - A flag indicating whether the build is a release build or not.
545///
546/// # Returns
547///
548/// An `Option` containing the value read from the resolved source file, if it exists.
549/// Otherwise, `None` is returned.
550#[cfg(any(feature = "tokio", feature = "async-std"))]
551async fn resolve_file_source(
552    cargo_path: String,
553    original_path: String,
554    is_release: bool,
555    options: FileSourceOptions,
556) -> Option<Value> {
557    let path = if options.enabled_environment {
558        match is_release {
559            true => original_path.replace("{{env}}", "release"),
560            false => original_path.replace("{{env}}", "debug"),
561        }
562    } else {
563        original_path.clone()
564    };
565
566    let path_buf = PathBuf::from(cargo_path.clone()).join(path.clone());
567    if !path_buf.exists() && !options.skip_on_error {
568        panic!("No file found in path: {}", path.clone());
569    }
570
571    if !path_buf.exists() && options.skip_on_error {
572        return None;
573    }
574
575    let file_path = path_buf.display().to_string();
576    let value = read_file_value(file_path).await;
577
578    if !value.is_null() {
579        Some(value)
580    } else {
581        log::error!("Error loading config file {original_path}");
582        None
583    }
584}
585
586/// Resolves a file source based on the given parameters.
587///
588/// # Arguments
589///
590/// - `cargo_path`: The path to the cargo directory.
591/// - `original_path`: The original path to the file source.
592/// - `is_release`: A flag indicating whether it's a release build or not.
593///
594/// # Returns
595///
596/// - `Some(Value)`: The value read from the file source if it exists and is not null.
597/// - `None`: If the file source does not exist or the value read is null.
598///
599/// # Panics
600///
601/// - If no file is found in the resolved path.
602#[cfg(not(any(feature = "tokio", feature = "async-std")))]
603fn resolve_file_source(
604    cargo_path: String,
605    original_path: String,
606    is_release: bool,
607    options: FileSourceOptions,
608) -> Option<Value> {
609    let path = if options.enabled_environment {
610        match is_release {
611            true => original_path.replace("{{env}}", "release"),
612            false => original_path.replace("{{env}}", "debug"),
613        }
614    } else {
615        original_path.clone()
616    };
617
618    let path_buf = PathBuf::from(cargo_path).join(path.clone());
619    if !path_buf.exists() && !options.skip_on_error {
620        panic!("No file found in path: {}", path);
621    }
622    if !path_buf.exists() && options.skip_on_error {
623        return None;
624    }
625
626    let file_path = path_buf.display().to_string();
627    let value = read_file_value(file_path);
628
629    if !value.is_null() {
630        Some(value)
631    } else {
632        log::error!("Error loading config file {original_path}");
633        None
634    }
635}
636
637/// Processes environment variables based on provided options.
638///
639/// # Arguments
640///
641/// * `option` - The `EnvSourceOptions` containing the processing options.
642///
643/// # Returns
644///
645/// Returns a `Result` containing the processed environment variables as a `Value` or an `Error` if an error occurs.
646///
647/// # Example
648///
649/// ```
650/// use serde_json::Value;
651/// use std::error::Error;
652///
653/// #[derive(Default)]
654/// struct EnvSourceOptions {
655///     prefix: Option<String>,
656///     nested_separator: Option<String>,
657///     key_case: Option<Case>,
658/// }
659///
660/// #[derive(Debug)]
661/// enum Case {
662///     Snake,
663///     Camel,
664///     Pascal,
665///     Kebab,
666/// }
667///
668/// fn process_envs(option: EnvSourceOptions) -> Result<Value, dyn Error> {
669///     // Implementation goes here
670///     Ok(Value::default())
671/// }
672/// ```
673fn process_envs(option: EnvSourceOptions) -> Result<Value, Error> {
674    let temp_prefix = option.prefix.unwrap_or_else(|| "NORTH_".to_string());
675    let prefix: &str = temp_prefix.as_str();
676
677    let nested_separator = option.nested_separator.unwrap_or_else(|| "__".to_string());
678    let separator: &str = nested_separator.as_str();
679
680    let case: Case = option.key_case.unwrap_or(Case::Snake);
681    let mut obj = Value::Null;
682
683    for (key, value) in std::env::vars() {
684        if !key.starts_with(prefix) {
685            continue;
686        }
687
688        let new_key = key.strip_prefix(prefix).expect("env var prefix missing");
689        let dot_key: String = new_key.replace(separator.clone(), ".").clone().to_case(case);
690        let new_value = serde_json::from_str::<serde_json::Value>(value.as_str()).expect("no value");
691        obj.dot_set(dot_key.as_str(), value).unwrap();
692    }
693
694    Ok(obj)
695}
696
697/// Asynchronously reads the contents of a file at the specified path and converts it to a `serde_json::Value`.
698/// Supports either the `tokio` or `async-std` runtime, depending on the enabled feature flag.
699///
700/// # Arguments
701///
702/// * `path` - A `String` representing the file path to read.
703///
704/// # Returns
705///
706/// An asynchronous task that resolves to a `serde_json::Value` representing the contents of the file.
707///
708/// # Panics
709///
710/// This function will panic if it is unable to open the file or if there is an error while reading from it.
711#[cfg(any(feature = "tokio", feature = "async-std"))]
712async fn read_file_value(path: String) -> Value {
713    let mut contents = String::new();
714
715    #[cfg(feature = "tokio")]
716    {
717        use tokio::io::AsyncReadExt;
718        let mut file = tokio::fs::File::open(path.clone())
719            .await
720            .expect("Unable to open file");
721        file.read_to_string(&mut contents).await.unwrap();
722    }
723
724    #[cfg(feature = "async-std")]
725    {
726        use async_std::io::ReadExt;
727        let mut file = async_std::fs::File::open(path.clone())
728            .await
729            .expect("Unable to open file");
730        file.read_to_string(&mut contents).await.unwrap();
731    };
732
733    convert_str_to_value(path, contents)
734}
735
736fn convert_str_to_value(path: String, contents: String) -> Value {
737    if path.ends_with(".yaml") || path.ends_with(".yml") {
738        #[cfg(not(feature = "yaml"))]
739        {
740            panic!("missing yaml feature for crate, please enable yaml feature")
741        }
742
743        #[cfg(feature = "yaml")]
744        {
745            let yaml: Value = serde_yaml::from_str::<Value>(&contents)
746                .expect("YAML does not have correct format.");
747            yaml
748        }
749    } else if path.ends_with(".toml") {
750        #[cfg(not(feature = "toml"))]
751        {
752            panic!("missing toml feature for crate, please enable toml feature")
753        }
754
755        #[cfg(feature = "toml")]
756        {
757            let rsp: Value =
758                toml::from_str::<Value>(&contents).expect("TOML does not have correct format.");
759            rsp
760        }
761    } else if path.ends_with(".json") {
762        let json: Value =
763            serde_json::from_str(&contents).expect("JSON does not have correct format.");
764        json
765    } else if path.ends_with(".ron") {
766        #[cfg(not(feature = "ron"))]
767        {
768            panic!("missing ron feature for crate, please enable ron feature")
769        }
770
771        #[cfg(feature = "ron")]
772        {
773            let data = ron::de::from_str(&contents).expect("RON does not have correct format.");
774            dbg!(contents.clone());
775            data
776        }
777    } else {
778        let json: Value =
779            serde_json::from_str(&contents).expect("JSON does not have correct format.");
780        json
781    }
782}
783
784/// Reads the contents of a file and converts it to a `Value`.
785///
786/// # Arguments
787///
788/// * `path` - A `String` that represents the path of the file to be read.
789///
790/// # Panics
791///
792/// This function will panic if the file cannot be opened.
793///
794/// # Returns
795///
796/// A `Value` representing the contents of the file.
797#[cfg(not(any(feature = "tokio", feature = "async-std")))]
798fn read_file_value(path: String) -> Value {
799    let mut contents = String::new();
800    let mut file = std::fs::File::open(path.clone()).expect("Unable to open file");
801    file.read_to_string(&mut contents).unwrap();
802
803    convert_str_to_value(path, contents)
804}