Config Validator
A simple proc-macro to allow the integration of the config and clap crates.
Config Validator
This post is about validating user settings during the initialization of your application. The context here is to allow the user to have multiple ways of configuring the application, so it facilitates different deployment methods and debugging issues.
Goal
- The user must be able to configure the application through...
- Command line arguments
- Config file (yaml/json)
- Environment variables
- The settings must be easily usable throughout the application.
- The user must be able to issue different commands to the application in addition to the configuration.
Solution
There are crates available that can achieve most of the goals above, but I couldn't find one that can do it all. My solution is to create a proc-macro that allows other crates to be combined so all the goals above can be achieved.
For this solution we will be ...
- Using the Config crate to load configuration from different sources and apply them in priority order.
- Using the Clap crate to process command line arguments and build a help command.
- Implementing the config::Source trait for clap:ArgMatches, so we can use Clap as a Config source.
- Implementing a proc-macro to validate the overall configuration while allowing optional or mandatory field notation to be used.
The Config crate
The Config crate allows combining configurations from different source based on priority. It also provides standard implementations for .yaml and .json files as well as environment variables. The Config crate can do much more but for the scope of this article this is what you need to know.
Here is a simple example of how to configure the Config crate to parse the settings from a file and then overwrite it with settings from environment variables.
// filename: config_simple/src/lib.rs
use ;
use ;
use env;
const DEFAULT_CONFIG_FILE_PATH: &str = "config.yaml";
/// sample-app configuration structure
# filename: config_simple/config.yaml
name: "Joe"
address: "123 Main St"
phone_number: "555-12
The Clap crate
The Clap crate allows the handling of command line arguments as well as generating a help command to list all options available. Just like the Config crate, Clap has many other features that I won't be using in this article.
Below is a simple example on how to use the clap crate. Note that the use of the clap_derive::Parse proc-macro was intentionally avoided, so the application have access to the arguments and commands from the command line.
Check the test section to understand how cli commands can be used with this approach.
// filename: clap_simple/src/lib.rs
use ;
use clap_derive;
use EnumString;
/// configuration structure
/// List available cli commands
Turning Clap into a Config source
Now we are starting to build our solution. The idea is to implement the config::Source trait for clap::ArgMatches. After that we can add clap as the third config source in the config builder.
Check the test section for usage examples.
// filename: clap_config_source/src/lib.rs
use ArgMatches;
use ;
use HashMap;
/// A wrapper for `clap::ArgMatches`
/// Implements the `config::Source` trait for ClapSource
The Validated proc-macro
Thus far, the solution seems to be working fine, but there are some issues with it. The main problem is how to deal with optional vs mandatory fields in two different levels, clap and config.
If we define a field in the Settings struct as mandatory (not an Option<_>), the clap source will require the argument in the command line even though the argument may be present in the config file or environment variable.
To illustrate the problem, let's imagine the following example.
A Settings struct with optional and mandatory fields.
/// configuration structure
And let's say we declare the config sources like this:
let matches = cli
.try_get_matches_from
.unwrap;
// app configuration
let clap_source = new;
let c = builder
.add_source
.add_source
.add_source
.add_source
.set_default?
.build
.unwrap;
Since we are parsing the cli first, to pass the arg_matches to the ClapSource object, it will fail because the --phone-number argument is mandatory and was not provided. The simple solution to this problem is to make all fields in the Settings structure optional.
/// configuration structure
Now the clap parsing won't fail anymore.
The problem with this approach is that it makes the use of the Settings object inconvenient. Even though it is safe to use unwrap() on settings.phone_number throughout the application since it has a default value associated with, it is not ideal.
To fix this problem, I created a proc-macro to validate the overall settings and provide concrete access methods to all fields.
Here is an example of how it can be used.
// filename: config_validator/tests/test.rs
use Validate;
use ;
use default;
/// configuration structure
The trick behind the Validate proc-macro is that it will generate a new struct, in case all mandatory arguments are present, with getters according to each field configuration. The new struct name is prefixed with "Validated". So in the example above, the settings are present in a struct named ValidatedSettings.
Here is the Validate macro expansion for the example above. Note that optional field getters return an Option<_> type while mandatory field getters return the concrete type.
// ======================================
// Recursive expansion of Validate macro
// ======================================
;
Wrapping all up into a single example
// filename: config_validator_example/src/settings.rs
use Result;
use ;
use ClapSource;
use ;
use Validate;
use ;
use EnumString;
const DEFAULT_LOG_CONFIG: &str = "files/log_config.yaml";
const DEFAULT_APP_CONFIG: &str = "files/app_config.yaml";
/// List available cli commands
// filename: config_validator_example/src/main.rs
use FromStr;
use info;
use ;
use Settings;
use crate;
# filename: config_validator_example/files/app_config.yaml
name: "Jack"
phone_number: "555-4321"
# filename: config_validator_example/files/log_config.yaml
appenders:
stdout:
kind: console
encoder:
pattern: " {{{l}}} {m}{n}"
root:
level: info
appenders:
- stdout
Here are some sample outputs from the example above.
config_validator_example % cargo run -- -h
List available cli commands
Usage: config-validator-example [OPTIONS] [COMMAND]
Commands:
start Starts the application
say-hello Say Hello back to user
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-file <CONFIG_FILE> [default: files/app_config.yaml]
-l, --log-config-file <LOG_CONFIG_FILE>
-n, --name <NAME>
-a, --address <ADDRESS>
-p, --phone-number <PHONE_NUMBER>
-h, --help Print help
config_validator_example % cargo run -- start
{INFO} command start
{INFO} config_file: "files/app_config.yaml"
{INFO} log_config_file: "files/log_config.yaml"
{INFO} address: None
{INFO} name: Some("Jack")
{INFO} phone_number: "555-4321"
config_validator_example % cargo run -- --phone-number "555-1234" start
{INFO} command start
{INFO} config_file: "files/app_config.yaml"
{INFO} log_config_file: "files/log_config.yaml"
{INFO} address: None
{INFO} name: Some("Jack")
{INFO} phone_number: "555-1234"
config_validator_example % PHONE_NUMBER="555-7890" cargo run -- start
{INFO} command start
{INFO} config_file: "files/app_config.yaml"
{INFO} log_config_file: "files/log_config.yaml"
{INFO} address: None
{INFO} name: Some("Jack")
{INFO} phone_number: "555-7890
config_validator_example % cargo run -- SayHello
error: unrecognized subcommand 'SayHello'
tip: a similar subcommand exists: 'say-hello'
Usage: config-validator-example [OPTIONS] [COMMAND]
For more information, try '--help'.
config_validator_example % cargo run -- say-hello
{INFO} Hello User
For these final examples the phone_number argument has been removed from the app_config.yaml file.
# filename: config_validator_example/files/app_config.yaml
name: "Jack"
config_validator_example % cargo run -- start
thread 'main' panicked at config_validator_example/src/settings.rs:17:76:
[config-validator] mandatory field "phone_number" not provided for struct "Settings"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
config_validator_example % PHONE_NUMBER="555-7890" cargo run -- start
{INFO} command start
{INFO} config_file: "files/app_config.yaml"
{INFO} log_config_file: "files/log_config.yaml"
{INFO} address: None
{INFO} name: Some("Jack")
{INFO} phone_number: "555-7890"
config_validator_example % cargo run -- --phone-number "555-1234" start
{INFO} command start
{INFO} config_file: "files/app_config.yaml"
{INFO} log_config_file: "files/log_config.yaml"
{INFO} address: None
{INFO} name: Some("Jack")
{INFO} phone_number: "555-1234"
The code presented in this article is available on GitHub.