setty is a facade over several configuration libraries providing turn-key config system with sane defaults.
Popular configuration crates like config and figment deal with reading and merging values from multiple sources. They leave it up to you to handle parsing using serde derives. This is a good separation of concerns, but it leaves a lot of important details to you. Like remembering to put #[serde(deny_unknown_fields)] not to realize that your production config had no effect because of a small typo.
Also, you may need features beyond parsing:
- Consistent defaults between
Default::default()and deserialization - Per-field merge strategies (e.g. do you replace arrays or combine values)
- Documentation generation
- JSONSchema generation (e.g. for Helm chart values validation)
- Auto-completion in CLI
- Deprecation mechanism
Layering more libraries and macros makes your models very verbose:
#[serde_with::skip_serializing_none]
#[derive(
Debug,
PartialEq, Eq,
better_default::Default,
serde::Deserialize, serde::Serialize,
serde_valid::Validate,
schemars::JsonSchema,
)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AppConfig {
/// Need to be explicit about using default for `serde`
#[serde(default)]
database: DatabaseConfig,
/// Note how defaults in `serde` and `Default::default()` are two separate things
#[default(AppConfig::default_hostname())]
#[serde(default = "AppConfig::default_hostname")]
#[validate(min_length = 5)]
hostname: String,
#[default(AppConfig::default_username())]
#[serde(default = "AppConfig::default_username")]
username: Username,
/// !! DO NOT USE !!!
/// Deprecation is done by leaving screamy comments
password: Option<String>
}
/// No inline default epressions in `serde` - must use functions
impl AppConfig {
fn default_hostname() -> String {
"localhost".into()
}
fn default_username() -> Username {
"root".parse().unwrap()
}
}And even if you power through this problem in your application - you'll face a composability problem of surfacing configuration from the modules you depend on. If a config object defined in a module does not use your ideal set of derive macros - you'll be forced to deplicating its structure in a temporary DTO and writing a mapping between them. Yet more boilerplate.
Use one simple macro:
/// Docstrings will appear in Markdown and JSON Schema outputs
#[derive(
// Implements all serde and schema stuff
setty::Config,
// Reuses same defaults to implement `Default` trait
setty::Default,
)]
struct AppConfig {
/// Opt-in into using `Default::default`
#[config(default)]
database: DatabaseConfig,
/// Or specify default values in-line (support full expressions)
#[config(default = "localhost")]
/// Basic validation can be delegated to `serde_valid` crate
#[config(validate(min_length = 5))]
hostname: String,
/// Use `default_str` to parse the value
#[config(default_str = "root")]
username: Username,
/// Use of deptecated values can be reported as warnings or fail strict validation
#[deprecated = "Avoid specifying password in config file"]
password: Option<String>
}Control what behavior you need via create features:
setty = {
version = "*",
features = [
# These traits will be derived for all types
"derive-clone",
"derive-debug",
"derive-partial-eq",
"derive-eq",
"derive-deserialize",
"derive-serialize",
"derive-jsonschema",
"derive-validate",
# Pick a case for struct fields (applies `#[serde(renameAll = "...")]`)
"case-fields-lower",
"case-fields-pascal",
"case-fields-camel",
"case-fields-snake",
"case-fields-kebab",
# Pick a case for enum variants (applies `#[serde(renameAll = "...")]`)
"case-enums-lower",
"case-enums-pascal",
"case-enums-camel",
"case-enums-snake",
"case-enums-kebab",
"case-enums-any", # Uses one of other cases on write but accepts any on read
# Pick input format(s)
"fmt-toml",
"fmt-json",
"fmt-yaml",
# Pick generation target formats
"gen-jsonschema",
"gen-markdown",
]
}By specifying features only at the top-level application crate - the desired derives will be applied to configs of all crates in your dependency tree allowing you to directly embed their DTOs. In other words library developers don't have to predict and align every single aspect of configuration with the app layer - they can focus only on types and validation.
- Rolling your own declarative macros (see example in
datafusion)
derive- a replacement for standard#[derive(...)]macro that will de-duplicate derivations - this is most useful for e.g.#[setty::derive(setty::Config, Clone)]which allows type to implementCloneeven when top-level featurederive-cloneis disable, and not hit duplicate trait impl error when feature is enabled.
Config- main workhorseDebug- same asstd::Debugbut recognizes defaults provided via#[config(default = $expr)]attributes
These arguments can be specified in #[config(...)] field attribute:
required- The field must be present in configdefault = $expr- Specifies expression used to initialize the value when it's not present in configdefault_parse = $str- Shorthand fordefault = "$str".parse().unwrap()
#[serde(...)]attribute will be propagated and can be used to override default behaviour (e.g.#[serde(tag = "type")])