diff --git a/confik/CHANGELOG.md b/confik/CHANGELOG.md index 09b54d4..22f75fd 100644 --- a/confik/CHANGELOG.md +++ b/confik/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Implement `Configuration` for `serde_json::Value` + - This is a very simple way to implement `try_from`, see the foreign_type_via_value for an example. + ## 0.15.2 - Add hot-reloadable configuration support with `ReloadableConfig` trait and `ReloadingConfig` wrapper. Requires the `reloading` feature (depends on `arc-swap`). diff --git a/confik/Cargo.toml b/confik/Cargo.toml index 471173a..8c887b2 100644 --- a/confik/Cargo.toml +++ b/confik/Cargo.toml @@ -44,6 +44,7 @@ ipnetwork = ["dep:ipnetwork"] js_option = ["dep:js_option"] rust_decimal = ["dep:rust_decimal"] secrecy = ["dep:secrecy"] +serde_json = ["dep:serde_json"] url = ["dep:url"] uuid = ["dep:uuid"] @@ -66,6 +67,7 @@ signal-hook = { version = "0.4", optional = true } tracing = { version = "0.1", optional = true } # Destination types +# NB: serde_json::Value is also a destination type, but is specified under source types ahash = { version = "0.8", optional = true, features = ["serde"] } bigdecimal = { version = "0.4", optional = true, features = ["serde"] } bytesize = { version = "2", optional = true, features = ["serde"] } @@ -110,3 +112,7 @@ required-features = ["toml", "ahash"] [[example]] name = "reloading" required-features = ["toml", "reloading"] + +[[example]] +name = "foreign_type_via_value" +required-features = ["toml", "serde_json"] diff --git a/confik/examples/foreign_type_via_value.rs b/confik/examples/foreign_type_via_value.rs new file mode 100644 index 0000000..87386f9 --- /dev/null +++ b/confik/examples/foreign_type_via_value.rs @@ -0,0 +1,43 @@ +use confik::{Configuration, TomlSource}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct ForeignType { + item_1: usize, + item_2: String, +} + +#[derive(Configuration)] +struct Wrapper(serde_json::Value); + +impl TryFrom for ForeignType { + type Error = serde_json::Error; + + fn try_from(wrapper: Wrapper) -> Result { + serde_json::from_value(wrapper.0) + } +} + +#[derive(Configuration)] +struct Config { + #[confik(try_from = Wrapper)] + foreign_type: ForeignType, +} + +fn main() { + let toml_1 = r#" + [foreign_type] + item_1 = 3 + "#; + let toml_2 = r#" + foreign_type.item_2 = "hello" + "#; + + let config = Config::builder() + .override_with(TomlSource::new(toml_1)) + .override_with(TomlSource::new(toml_2)) + .try_build() + .unwrap(); + assert_eq!(config.foreign_type.item_1, 3); + assert_eq!(config.foreign_type.item_2, "hello"); +} diff --git a/confik/src/third_party.rs b/confik/src/third_party.rs index 50b32cc..589ebde 100644 --- a/confik/src/third_party.rs +++ b/confik/src/third_party.rs @@ -268,3 +268,51 @@ mod ahash { type Builder = KeyedContainerBuilder>, Self>; } } + +#[cfg(feature = "serde_json")] +mod serde_json { + use serde_json::Value; + + use crate::{Configuration, ConfigurationBuilder}; + + impl Configuration for Value { + type Builder = Self; + } + + impl ConfigurationBuilder for Value { + type Target = Self; + + fn merge(self, other: Self) -> Self { + match (self, other) { + ( + primitive @ (Self::Null | Self::Bool(_) | Self::Number(_) | Self::String(_)), + _, + ) => primitive, + (Self::Array(mut me), Self::Array(other)) => { + me.extend(other); + Self::Array(me) + } + (arr @ Self::Array(_), _) => arr, + (Self::Object(mut me), Self::Object(other)) => { + me.extend(other); + Self::Object(me) + } + (obj @ Self::Object(_), _) => obj, + } + } + + fn try_build(self) -> Result { + Ok(self) + } + + fn contains_non_secret_data(&self) -> Result { + Ok(match self { + Self::Null => false, + Self::Array(arr) => !arr.is_empty(), + Self::Object(map) => !map.is_empty(), + Self::String(s) => !s.is_empty(), + Self::Bool(_) | Self::Number(_) => true, + }) + } + } +} diff --git a/confik/tests/third_party.rs b/confik/tests/third_party.rs index d2ef14c..cd2c94d 100644 --- a/confik/tests/third_party.rs +++ b/confik/tests/third_party.rs @@ -127,6 +127,108 @@ mod bigdecimal { } } +#[cfg(feature = "serde_json")] +mod serde_json { + use confik::ConfigurationBuilder as _; + use serde_json::{json, Value}; + + #[test] + fn merge_left_type_wins_over_right_type() { + // When the two sides have different types, the left always wins + assert_eq!(Value::Null.merge(json!({"key": "value"})), Value::Null); + assert_eq!(json!(true).merge(json!([1, 2, 3])), json!(true)); + assert_eq!(json!(42).merge(json!({"key": "value"})), json!(42)); + assert_eq!(json!("hello").merge(json!(99)), json!("hello")); + assert_eq!(json!([1, 2]).merge(json!({"key": "value"})), json!([1, 2])); + assert_eq!( + json!({"key": "value"}).merge(json!([1, 2])), + json!({"key": "value"}) + ); + } + + #[test] + fn merge_arrays_are_concatenated() { + assert_eq!(json!([1, 2]).merge(json!([3, 4])), json!([1, 2, 3, 4])); + } + + #[test] + fn merge_objects_combine_disjoint_keys() { + assert_eq!( + json!({"a": 1}).merge(json!({"b": 2})), + json!({"a": 1, "b": 2}) + ); + } + + #[test] + fn try_build_returns_value_unchanged() { + let value = json!({"key": "value", "num": 42}); + assert_eq!(value.clone().try_build().unwrap(), value); + } + + #[test] + fn contains_non_secret_data() { + for value in [Value::Null, json!(""), json!([]), json!({})] { + assert!(!value.contains_non_secret_data().unwrap()); + } + for value in [ + json!(true), + json!(42), + json!("hello"), + json!([1, 2, 3]), + json!({"key": "value"}), + ] { + assert!(value.contains_non_secret_data().unwrap()); + } + } + + #[cfg(feature = "toml")] + mod toml { + use confik::{Configuration, TomlSource}; + use serde_json::{json, Value}; + + #[derive(Configuration)] + struct Config { + data: Value, + } + + #[test] + fn value_loads_from_toml() { + let toml = r#" + [data] + key = "hello" + num = 42 + "#; + + let config = Config::builder() + .override_with(TomlSource::new(toml)) + .try_build() + .unwrap(); + + assert_eq!(config.data, json!({"key": "hello", "num": 42})); + } + + #[test] + fn objects_merged_from_multiple_sources() { + let toml_1 = r#" + [data] + item_1 = 1 + "#; + let toml_2 = r#" + [data] + item_2 = 2 + "#; + + let config = Config::builder() + .override_with(TomlSource::new(toml_1)) + .override_with(TomlSource::new(toml_2)) + .try_build() + .unwrap(); + + assert_eq!(config.data, json!({"item_1": 1, "item_2": 2})); + } + } +} + #[cfg(feature = "js_option")] mod js_option { use confik::Configuration;