Deal with Option field in Struct depending on certain conditions

Currently, I have a Struct that contains all the necessary configurations to set up a client for various object storage protocols. However, depending on the protocols, the necessary configuration differs, so all my fields are Option<>.

struct StorageConfig {
    access_key: Option<String>,
    secret_key: Option<String>,
    endpoint: Option<String>,
    region: Option<String>,
    // ... other fields
}

I am currently considering implementing a validate function that determines whether the required configuration for each object storage is inputted within the struct.

impl StorageConfig {
    fn validate(&self, protocol: Protocol) -> Result<(), ConfigError> {
        match protocol {
            Protocol::A => {
                if self.access_key.is_none() || self.secret_key.is_none() {
                    return Err(ConfigError::MissingField);
                }
            },
            // ... other protocols
        }
        Ok(())
    }
}

However, after the function it used, despite knowing that the required field is present, I would still need to be error handling on whether the field is Some() using ok_or_else() or some other methods to avoid panic.

if let Ok(_) = config.validate(Protocol::A) {
    let client = ClientA::new(
        config.access_key.as_ref().ok_or(ConfigError::MissingField)?,
        config.secret_key.as_ref().ok_or(ConfigError::MissingField)?
    );
    // ...
}

This seems redundant, as I've already checked for the presence of these fields in validate. Thus, is there a more convenient way to convert the Option<> fields into Some() during the validate function?

You could make your StorageConfig an enum with your variants being the different kinds of storages.

enum StorageConfig {
    Disk(DiskStorage),
    Other(OtherStorage),
}

struct DiskStorage {
    ...
}
struct OtherStorage {
    ...
}

↓ see below for a name for this technique ↓

4 Likes

Parse, don't validate.

For example given struct:

struct UserInput {
    a: Option<i32>,
    b: Option<i32>,
    c: Option<i32>,
}

and invariant "either a is Some, or both b and c are Some, you can represent checked data as:

enum DataInput {
    First { a: i32 },
    Second { b: i32, c: i32 },
}

and write parsing function:

fn parse_user_input(input: UserInput) -> Result<DataInput, ParseError> ( ... }

Then once you parse your initial input, you can use type system, to model precisely your problem domain.

6 Likes

How about a vector with key-value pairs?
You'd validate the completeness of all keys and integrity of the values in your ```
validate

Somewhat cowboy option:

use anyhow::{anyhow, Result};

struct StorageConfig {
    access_key: Option<String>,
    secret_key: Option<String>,
    endpoint: Option<String>,
    region: Option<String>,
    // ... other fields
}

impl StorageConfig {
    fn if_valid<F, R>(&self, f: F) -> Result<R>
    where 
        F: FnOnce(&str, &str, &str, &str) -> Result<R>
    {
        Ok(f(
            self.access_key.as_ref().ok_or(anyhow!("missing field"))?,
            self.secret_key.as_ref().ok_or(anyhow!("missing field"))?,
            self.endpoint.as_ref().ok_or(anyhow!("missing field"))?,
            self.region.as_ref().ok_or(anyhow!("missing field"))?,
        )?)
    }
    
    fn validate(&self) -> Result<()> {
        self.if_valid(|_, _, _, _| Ok(()))
    }

    fn make_client(&self) -> Result<()> {
        self.if_valid(|access_key, secret_key, endpoint, region| todo!())
    }
}

If you call validate() before make_client(), you'll still end up doing the validation twice, but at least you won't have to write the validation twice. If you want to avoid doing the validation, then the validate method should likely convert the struct to a version of the struct where the mandatory fields are not Options:

struct StorageConfig {
    access_key: Option<String>,
    secret_key: Option<String>,
    endpoint: Option<String>,
    region: Option<String>,
    // ... other fields
}

struct ValidatedStorageConfig {
    access_key: String,
    secret_key: String,
    endpoint: String,
    region: String,
    // ... other fields
}


impl StorageConfig {
    fn validate(&self) -> Result<ValidatedStorageConfig> {
        Ok(ValidatedStorageConfig {
            access_key: self.access_key.clone().ok_or(anyhow!("missing field"))?,
            secret_key: self.secret_key.clone().ok_or(anyhow!("missing field"))?,
            endpoint: self.endpoint.clone().ok_or(anyhow!("missing field"))?,
            region: self.region.clone().ok_or(anyhow!("missing field"))?,
        })
    }
}

impl ValidatedStorageConfig {
    fn make_client(&self) -> Result<()> {
        todo!()
    }
}

This is also a fairly natural place to put things like default values, or looking values up in the environment, or consolidating multiple possible configurations into the final config.

1 Like

a slight variant (or extension) to the enum approach mentioned above is to use separate types for "validated" configurations for each protocol, if your use case allows it. then, the origianl "raw" configuration type can be seen as some "builder". example:

struct RawConfig {
    access_key: Option<String>,
    secret_key: Option<String>,
    endpoint: Option<String>,
    region: Option<String>,
}
struct ConfigA {
    // required fields for protocal A:
    access_key: String,
    secret_key: String,
    // maybe also optional fields:
    //...
}
struct ConfigB {
    // required fields for protocol B
    access_key: String,
    region: String,
    // ...
}
impl RawConfig {
    fn try_parse_a(self) -> ConfigResult<ConfigA> {
        todo!()
    }
    fn try_parse_b(self) -> ConfigResult<ConfigB> {
        todo!()
    }
}

// usage:
if proto == Protocol::A {
    let config_a = config.try_parse_a()?;
    // config_a has type `ConfigA`
} else if proto == Protocol::B {
    let config_b = config.try_parse_b()?;
    // config_b has type `ConfigB`
}

note: this is NOT an alternative to the enum approach, but a supplement:

// the "sum" of all possible "validated" config
enum ProtocolConfig {
    A(ConfigA),
    B(ConfigB),
    // possibily more protocols
    //...
}
impl RawConfig {
    fn validate(self, proto: Protocol) -> ConfigResult<ProtocolConfig> {
        todo!()
    }
}

// usage
match config.validate(proto)? {
    ProtocolConfig::A(config_a) => todo!(),
    ProtocolConfig::B(config_b) => todo!(),
}
1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.