SergioBenitez / Figment

A hierarchical configuration library so con-free, it's unreal.
Apache License 2.0
629 stars 38 forks source link

Question: How to load vector of struct with `Env` provider? #104

Closed duncanista closed 5 months ago

duncanista commented 5 months ago

Question

I'm trying to read config with both the Yaml and Env provider. I allow users to provide me a json string (which should be an array) in the environment variables. Yet, it doesn't work, because a string is received and it's not detected as a sequence.

  1. (suggestion) Should Env provider use serde_json when reading a custom type?
  2. Is there a better approach for this? I tried creating a custom deserializer, but it doesn't get called at all. Worth mentioning that my deserializer for an enum field is properly called, yet omitted in this example.

This works for simpler types.

pub struct Config {
    pub array: Vec<String>,
}

// where APP_ARRAY=[1,2,3]
// ends up as a vector containing ["1", "2", "3"]

While

#[derive(Clone, Debug, PartialEq, Deserialize)]
pub enum SomeEnum {
     First,
     Second,
}

#[derive(Clone, Debug, PartialEq, Deserialize)]
pub struct Item {
     pub name: String,
     pub inner_enum: SomeEnum
}

#[derive(Debug, PartialEq, Deserialize)]
pub struct Config {
    pub array: Option<Vec<Item>>,
}

Would fail when loading it:

#[test]
    fn test_from_env() {
        figment::Jail::expect_with(|jail| {
            jail.clear_env();
            jail.set_env(
                "APP_ARRAY",
                r#"[{"name":"first-name","inner_enum":"First"},{"name":"second-name", "inner_enum":"Second"}]"#,
            );
            let config = Figment::new().merge(Env::prefixed("APP_")).extract().expect("should work");
            assert_eq!(
                config,
                Config {
                    array: Some(vec![
                        Item { name: "first-name".to_string(), inner_enum: SomeEnum::First },
                        Item { name: "second-name".to_string(), inner_enum: SomeEnum::Second }
                    ]),
                }
            );
            Ok(())
        });
    }
thread 'config::tests::test' panicked at src/config/mod.rs:...:
should parse config: ParseError("invalid type: found string \"[{...omitted...}]\", expected a sequence for key \"ARRAY\" in `APP_` environment variable(s)")
duncanista commented 5 months ago

I ended up doing the following and it worked, but I'm not sure if this is the recommended solution:

// ... omitted code

fn deserialize_array<'de, D>(deserializer: D) -> Result<Option<Vec<Item>>, D::Error>
where
    D: Deserializer<'de>,
{
    // Deserialize the JSON value using serde_json::Value
    let value: JsonValue = Deserialize::deserialize(deserializer)?;

    match value {
        JsonValue::String(s) => {
            let values: Vec<Item> = serde_json::from_str(&s).expect("should have been serialized");
            return Ok(Some(values));
        }
        JsonValue::Array(a) => {
            let mut values = Vec::new();
            for v in a {
                let item: Item = serde_json::from_value(v).expect("should have been serialized");
                values.push(item);
            }
            return Ok(Some(values));
        }
        _ => {
            return Ok(None);
        }
    }
}

#[derive(Debug, PartialEq, Deserialize)]
pub struct Config {
     #[serde(deserialize_with = "deserialize_array")]
     pub array: Option<Vec<Item>>,
}

// This now works for both
let path = config_directory.join("config.yaml");
let figment = Figment::new()
    .merge(Yaml::file(path))
    .merge(Env::prefixed("APP_"));
SergioBenitez commented 5 months ago

The syntax is documented here: https://docs.rs/figment/latest/figment/providers/struct.Env.html

Dict: in the form {key=value} (e.g, APP_VAR={key="value",num=10}) Array: delimited by [] (e.g, APP_VAR=[true, 1.0, -1])

Combine these to get: APP_ARRAY='[{name=foo,inner_enum=First},{name=bar,inner_enum=Second}]'