DEV Community

Miles
Miles

Posted on

Managing Application Config in Rust

One of the tenets of a Twelve-Factor app is to store application configs in the environment the application is deployed to. To pass config values to an application, developers typically have range of ways. We will look at 3 common ones using Rust. The ideas are applicable to any other language.

  1. Command line arguments
  2. Reading from Environment Variables
  3. .env file

It is usually recommended to use the Rust type system to expres your data structures. We will represent the configs our application needs with a struct:

#[derive(Debug, Default)]
pub struct Config {
    pub db_name: String,
    pub db_password: String,
    pub base_url: String,
    pub client_id: u32,
}
Enter fullscreen mode Exit fullscreen mode

As configs can come from multiple sources, I prefer to have a ConfigProvider trait that can have multiple implementations depending on where the developer reads the configs from.

pub trait ConfigProvider {
    fn get_config(&self) -> &Config;
}
Enter fullscreen mode Exit fullscreen mode

In my methods, I will use a generic parameter with trait bounds to receive any ConfigProvider implementation:

fn test_method<T>(config_provider: T)
        where T: ConfigProvider
    {
        let db_name = config_provider.get_config().db_name;
    }
Enter fullscreen mode Exit fullscreen mode

1: Reading from Command line Arguments

This is the traditional way of passing values to the binary's main function.

cargo run mybinary -- arg1 arg2 KEY1=VALUE1 KEY2=VALUE2,VALUE3,VALUE4

There is a nice crate argmap that can read the various command line argument formats and return them as common Rust types that are easier to work with.

cargo add argmap

// main.rs

pub fn main(){
    let (args, argv) = argmap::parse(env::args());
    let cmd_config_provider = CmdConfigProvider::new(argv);
}
Enter fullscreen mode Exit fullscreen mode

The parse method returns a tuple (Vec<String>, HashMap<String, Vec<String>>. The first Vec is a list of command line arguments passed without key-value pairs. The second HashMap are arguments passed as key-value pairs.

// config.rs

pub struct CmdConfigProvider(Config);

impl CmdConfigProvider {
    pub fn new(args: Vec<String>, argv: HashMap<String, Vec<String>>) -> Self {
        let db_name = args.iter().nth(1).expect("Missing config");
        let db_password = args.iter().nth(2).expect("Missing config");
        let home_uri = argv.get("home_uri").expect("Missing config").to_vec().to_vec();
        let client_id = argv.get("client_id").expect("Missing config").to_vec().to_vec();
        let config = Config {
            db_name: db_name.to_string(),
            db_password: db_password.to_string(),
            home_uri: home_uri.first().expect("Missing config").to_string(),
            client_id: client_id.first().expect("Missing config").to_string(),
        };
        CmdConfigProvider(config)
    }
}

impl ConfigProvider for CmdConfigProvider {
    fn get_config(&self) -> &Config {
        &self.0
    }
}

impl Default for CmdConfigProvider {
    fn default() -> Self {
        Self::new(Vec::new(), HashMap::new())
    }
}
Enter fullscreen mode Exit fullscreen mode

In your main function initialize the ConfigProvider and use it around in the application:

// main.rs
let config_provider = CmdConfigProvider::new(args, argv);
test_method(config_provider);
Enter fullscreen mode Exit fullscreen mode
// file2.rs
fn test_method<T>(config_provider: T)
        where T: ConfigProvider
    {
        let db_name = config_provider.get_config().db_name;
    }

Enter fullscreen mode Exit fullscreen mode

2: Reading from Environment Variables

Here we read from the container/operating system environment variables. There is no need of a crate as Rust standard library provides this feature out of the box.

// main.rs
pub fn main(){
    let env_config_provider = EnvVarProvider::new(env::vars().collect());
    test_method(env_config_provider);
}
Enter fullscreen mode Exit fullscreen mode
// config.rs
pub struct EnvVarProvider(Config);

impl EnvVarProvider {
    pub fn new(args: HashMap<String, String>) -> Self {
        let config = Config {
            home_uri: args.get("HOME_URI").expect("Missing config").to_string(),
            db_name: args.get("DB_NAME").expect("Missing config").to_string(),
            db_password: args.get("DB_PASSWORD").expect("Missing config").to_string(),
            home_uri: args.get("HOME_URI").expect("Missing config").to_string(),
            client_id: args.get("CLEINT_ID").expect("Missing config").to_string()
        };
        EnvVarProvider(config)
    }
}

impl ConfigProvider for EnvVarProvider {
    fn get_config(&self) -> &Config {
        &self.0
    }
}

impl Default for EnvVarProvider {
    fn default() -> Self {
        Self::new(HashMap::new())
    }
}
Enter fullscreen mode Exit fullscreen mode

3: Reading from .env file

A common practice during development is to use a .env file holding the config values. You can have multiple env files according to the environment you are targeting, e.g .env.development, .env.staging and .env.production.
There is a nice crate, dotenv that handles the reading and parsing of these config files.

cargo add dotenv

You define your .env file as follows:

DB_NAME=users
DB_PASSWORD=secret
BASE_URL=localhost:5432
CLIENT_ID=2
Enter fullscreen mode Exit fullscreen mode
// config.rs
pub struct DotEnvConfigProvider(Config);

impl DotEnvConfigProvider {
    pub fn new() -> Self {
        use dotenv::dotenv;
        use std::env;
        dotenv().ok();
        let config = Config {
            db_name: env::var("DB_NAME").expect("Missing database name"),
            db_password: env::var("DB_PASSWORD").expect("Missing database password"),
            base_url: env::var("BASE_URL").expect("Missing base uri"),
            client_id: env::var("CLIENT_ID").trim().parse().expect("Missing client id"),
        };

        DotEnvConfigProvider(config)
    }
}

impl ConfigProvider for DotEnvConfigProvider {
    fn get_config(&self) -> &Config {
        &self.0
    }
}

impl Default for DotEnvConfigProvider {
    fn default() -> Self {
        Self::new()
    }
}
Enter fullscreen mode Exit fullscreen mode

In our new() methods we have used .expect() to panic if a config value is missing. This would work for mandatory config values without which the application should not startup. If the config value is not mandatory and the application can start without it or by using a default value, then we should replace the .expect with a more graceful way of handling the missing value without a panic(e.g pattern matching of Option or Result or unwrap_or or unwrap_or_else).

Top comments (14)

Collapse
 
asad199866 profile image
Asad

would have liked to see more about handling secret or sensitive configuration data securely. a good starting point though

Collapse
 
boshragirl22 profile image
Boshra Hasn

this was very helpful, thanks

Collapse
 
rimarima profile image
Rima

good intro, but I would have liked a deeper dive into advanced scenarios like dynamic config reloading or integration with configuration management tools.

Collapse
 
jollanar profile image
jollanar

The explanation of reading from environment variables is useful, especially for containerized applications. Good to know that Rust's standard library can handle it without extra crates.

Collapse
 
benurio profile image
benurio

i enjoyed this article, very informativea nd well structured

Collapse
 
rasel37 profile image
rasel

finally a clear and solution to managing configs in rust! Ive been struggling with it. The ConfigProvider trait idea is brilliant for handling different sources.

Collapse
 
sanafande profile image
Sana fande

While I found the article informative, I wish it had included more advanced techniques or real-world examples of managing complex configurations in Rust applications...

Collapse
 
mozamme60304785 profile image
Mozammel Hoque

I liked the usage of the dotenv crate for reading config values from a .env file. It simplifies the process and makes it more organized for different environments.

Collapse
 
marlainasari profile image
Marlena sari

The use of the expect() method for mandatory config values is a good practice! It ensures that the application won't start if critical config data is missing.

Collapse
 
maisalkbaily profile image
Mais alkbaily

good, easy to undestand read! lots of helpful advice

Collapse
 
ellameller1 profile image
Ella Meller

I can already tell that the section on reading from command line arguments using argmap is going to save me a lot of time.