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}