Rust application configuration

Govinda Attal
3 min readJul 9, 2023

--

Lately, I have been exploring Rust language primarily to write 12-factor cloud-native apps.

12-factor principles are strict when it comes to the separation of config from code. So, the configuration can be provided to the application in different ways. To name a few:

  • config files
  • environment variables

In this article, I will share certain practices that I have used professionally and so have mapped same to Rust-based projects. Overall I will restrict this article to the application configuration. Happy to hear your thoughts if you see scope for improvement or any feedback.

Project Setup

As a service will be an executable after compilation, we can stick with the default binary target src/main.rs. One can notice that the project setup also includes additional files. I will discuss them below.

.
├── Cargo.toml
├── config
│ └── app.yaml
└── src
├── cmd.rs
├── config.rs
├── error.rs
├── main.rs
└── prelude.rs

Error Variants

error.rs: This file lists different error variants that may be used across the application. Here by popular choice, I use thiserror crate for error handling as it really keeps it simple.

#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
ConfigError(#[from] twelf::Error),
}

Preludes

prelude.rs: This file will use the enum Error (defined above) and will define a type alias for Result which returns the application’s Error. This simplifies the function’s API signature across the application. Later we import the prelude with use crate::prelude::*; across modules within the crate. It is a balancing act that we don’t want to misuse prelude.

pub use crate::error::Error;

pub type Result<T> = std::result::Result<T, Error>;

CLI Arguments

cmd.rs: A service binary may have zero or more sub-commands and zero or more arguments/flags for the root command or different sub-commands. I keep this logic in cmd.rs. Here by popular choice, I use clap crate to manage the parsing of CLI arguments. One can notice that config_path can be made available to the application either directly as a flag/option or using an environment variable APP_CONFIG_PATH .

use clap::Parser;

#[derive(Parser, Debug)]
#[command(version)]
pub struct Args {
#[arg(short, long, default_value_t = String::from("./config/app.yaml"), env("APP_CONFIG_PATH"))]
pub config_path: String,
}
pub fn parse() -> Args {
Args::parse()
}

Configuration

config.rs: This file defines the service configuration struct which lists attributes of base types and custom types. Here I use twelf crate, as it provides config macro which makes it easy to define application configuration using a struct and then at runtime configuration building capability with its layering approach. Here one may notice that due to the simplicity of thiserror crate we were able to map twelf::Error to Error defined in this application.

use crate::prelude::Result as AppResult;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use twelf::{config, Layer};

pub fn load(path: PathBuf) -> AppResult<Config> {
let path = path.into();
// Layer from different sources to build configuration. Order matters!
let conf = Config::with_layers(&[
Layer::Yaml(path),
Layer::Env(Some(String::from("APP_"))),
])?;
Ok(conf)
}
#[config]
#[derive(Debug, Default, Serialize)]
pub struct Config {
pub log: Log,
pub port: u16,
pub db: Database,
}
//---snip---//

Binary Crate Dependencies

Cargo.toml lists key crates (mentioned above) as dependencies.

#---snip---#
[dependencies]
clap = { version = "4.3.5", features = ["derive", "env"] }
twelf = { version="0.11.0", features = ["yaml"]}
thiserror= "1.0.40"
tokio = { version = "1.28.2", features = ["full"] }
#---snip---#

Application

Here one may notice that first, we parse command line arguments thus getting a valid config file path. Later we load the configuration from the given config file.

#![allow(unused)]

mod cmd;
mod config;
mod error;
mod prelude;
use crate::prelude::*;

//---snip---//
#[tokio::main]
async fn main() -> Result<()> {
let args = cmd::parse();
let conf = config::load(PathBuf::from(args.config_path))?;

LogBuilder::with_level(&conf.log.level)
.with_default_writer(new_writer(io::stdout()))
.init();
log::info!(conf = log::as_serde!(conf); "configuration");
sleep(Duration::from_millis(100)).await;
Ok(())
}
$ cargo run -q -- --config-path=./config/app.yaml
{"conf":{"log":{"level":"INFO"},"port":9080,"db":{"url":"postgres://localhost:5432/postgres"}},"level":"INFO","message":"configuration","target":"app_a","timestamp":1688836890547}

Conclusion

In this article, we noticed that we could easily separate configuration from code. Configuration file path can be provided as a flag/option or via an environment variable. clap crate makes it super easy. Similarly, we can define application configuration using a struct and twelf crate can help us with building layered configuration at runtime.

GitHub source repo: https://github.com/govinda-attal/app-a/tree/1-config

--

--