derive(Config)
Schematic is a light-weight, macro-based, layered serde configuration library, with built-in support for merge strategies, validation rules, environment variables, and more!
This crate was built specifically for moon, and many of the design decisions are based around that project and its needs. Because of that, this crate is quite opinionated and won't change heavily.
Define a struct and derive the Config
trait.
```rust use schematic::Config;
struct AppConfig { #[setting(default = 3000, env = "PORT")] port: usize,
#[setting(default = true)]
secure: bool,
#[setting(default = vec!["localhost".into()])]
allowed_hosts: Vec<String>,
} ```
Then load, parse, merge, and validate the configuration from one or many sources. A source is either a file path, secure URL, or code block.
```rust use schematic::{ConfigLoader, Format};
let result = ConfigLoader::
result.config; result.layers; ```
The format for files and URLs are derived from the trailing extension.
The bulk of schematic is powered through the Config
trait and the associated derive macro. This
macro helps to generate and automate the following:
Option
.The struct that derives Config
represents the final state, after all partial layers
have been merged, and default and environment variable values have been applied. This means that all
fields (settings) should not be wrapped in Option
, unless the setting is truly optional (think
nullable in the config file).
```rust
pub struct ExampleConfig {
pub number: usize,
pub string: String,
pub boolean: bool,
pub array: Vec
This pattern provides the optimal developer experience, as you can reference the settings as-is, without having to unwrap them, or use
match
orif-let
statements!
A powerful feature of schematic is what we call partial configurations. These are a mirror of the
derived configuration, with all settings wrapped in Option
, are prefixed with
Partial
, and have common serde and derive attributes automatically applied.
For example, the ExampleConfig
above would generate the following partial struct:
```rust
pub struct PartialExampleConfig {
#[serde(skipserializingif = "Option::is_none")]
pub number: Option
#[serde(skip_serializing_if = "Option::is_none")]
pub string: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub boolean: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub array: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub optional: Option<String>,
} ```
So what are partials used for exactly? Partials are used for the entire parsing, layering, extending, and merging process, instead of the base/final configuration.
When deserializing a source with serde, we utilize the partial config as the target type, because not all fields are guaranteed to be present. This is especially true when merging multiple sources together, as each source may only contain a subset of the final config. Each source represents a layer to be merged.
Partials are also beneficial when serializing, as only settings with values will be written to the source, instead of everything! A common complaint of serde's strictness.
As stated above, partials also handle the following:
Configuration can easily be nested within other configuration using the
#[setting(nested)]
attribute. Child configuration will be deeply merged and validated alongside
the parent.
```rust
pub struct ChildConfig { // ... }
pub struct ParentConfig { #[setting(nested)] pub nested: ChildConfig,
#[setting(nested)]
pub optional_nested: Option<ChildConfig>,
} ```
The #[setting(nested)]
attribute is required, as the macro will substitute the config struct with
its partial struct variant.
Nested configuration can also be wrapped in collections, like
Vec
andHashMap
. However, these are tricky to support and may now work in all situations!
Context is an important mechanism that allows for different default values, merge strategies, and validation rules to be used, for the same configuration struct, depending on context!
To begin, a context is a struct with a default implementation.
```rust
struct ExampleContext { somevalue: bool, anothervalue: usize, } ```
Context must then be associated with a configuration through the context
attribute field.
```rust
pub struct ExampleConfig { // ... } ```
And then passed to the ConfigLoader.load_with_context
method.
```rust let context = ExampleContext { somevalue: true, anothervalue: 10, };
let result = ConfigLoader::
Refer to the default values, merge strategies, and validation rules sections for more information on how to use context.
Configuration supports basic metadata for use within error messages through the
#[config]
attribute. Right now we support a name, derived from the struct name or the serde
rename
attribute field.
Metadata can be accessed with the META
constant.
rust
ExampleConfig::META.name;
By default the Config
macro will apply
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
to the
partial configuration. The default
and deny_unknown_fields
cannot be customized, as
they ensure proper parsing and layer merging.
However, the rename_all
field can be customized, and we also support the rename
field, both via
the top-level #[config]
attribute.
```rust
struct Example { // ... } ```
The
rename
field will also update the metadata name.
Configurations typically use enums to handle value variations of a specific setting. To
simplify this process, we offer a ConfigEnum
macro/trait that can be derived for enums. At this
point in time, only unit variants are supported.
```rust
enum LogLevel { Info, Error, Debug, Off } ```
This enum will generate the following implementations:
variants
method, that returns a list of all variants. Perfect for iteration.FromStr
and TryFrom
for parsing from a string.Display
for formatting into a string.The string value/format is based on the variant name, and is converted to kebab-case by default.
This can be customized with the #[serde(rename_all = "kebab-case")]
attribute, which keeps
consistency with serde's handling.
Although ConfigEnum
only supports unit variants, we do support a catch-all variant known as the
"fallback variant", which can be defined with #[variant(fallback)]
. Fallback variants are
primarily used when parsing from a string, and will be used if no other variant matches.
However, this pattern does have a few caveats:
try_into()
.rename_all
.```rust
enum Value { Foo, Bar, Baz #[variant(fallback)] Other(String) } ```
Furthermore, all enums (not just unit enums) typically support the same derived traits, like
Clone
, Eq
, etc. To reduce boilerplate, we offer a derive_enum!
macro that will apply these
traits for you.
rust
derive_enum!(
#[derive(ConfigEnum, Default)]
enum LogLevel {
Info,
Error,
Debug,
#[default]
Off
}
);
This macro will inject the following attributes:
```rust
```
Settings are the individual fields/members of a configuration struct, and can be annotated with the
optional #[setting]
attribute.
In schematic, there are 2 forms of default values:
#[setting]
attribute, and is the first layer of the configuration to be merged.Default
to generate
the final value if none was provided. This acts more like a fallback.This section will talk about the #[setting]
attribute and default
. The default
attribute field
is used for declaring primitive values, like numbers and booleans. It can also be used for array and
tuple literals, as well as function (mainly for from()
) and macros calls.
```rust
struct AppConfig { #[setting(default = "/")] base: String,
#[setting(default = 3000)]
port: usize,
#[setting(default = true)]
secure: bool,
#[setting(default = vec!["localhost".into()])]
allowed_hosts: Vec<String>,
} ```
If you need more control or need to calculate a complex value, you can pass a reference to a
function to call. This function receives the context as the first argument (use ()
or
generics if you don't have context), and can return an optional value. If None
is returned, the
Default
value will be used instead.
```rust
fn findunusedport(ctx: &Context) -> Option
struct AppConfig { #[setting(default = findunusedport)] port: usize, } ```
Settings can also inherit values from environment variables via the env
attribute field. When
using this, variables take the highest precedence, and are merged as the last layer.
```rust
struct AppConfig { #[setting(default = 3000, env = "PORT")] port: usize, } ```
If you'd prefer to not define env
for every setting, you can instead define a prefix on the
containing struct using the env_prefix
attribute field. This will define an environment variable
for all non-nested fields in the struct, in the format of "env prefix + field name" in
UPPERSNAKECASE.
For example, the environment variable below is now APP_PORT
.
```rust
struct AppConfig { #[setting(default = 3000)] port: usize, } ```
We also support parsing environment variables into the required type. For example, the variable may be a comma separated list of values, or a JSON string.
The parse_env
attribute field can be used, which requires a path to a function to handle the
parsing, and receives the variable value as a single argument.
```rust
struct AppConfig {
#[setting(env = "ALLOWEDHOSTS", parseenv = schematic::env::splitcomma)]
allowedhosts: Vec
We provide a handful of built-in parsing functions in the
env
module.
When defining a custom parse function, you should return an error with ConfigError::Message
if
parsing fails.
```rust use schematic::ConfigError;
pub fn customparse(var: String) -> Result
Configs can extend other configs, generating an accurate layer chain, via the extend
attribute
field. Extended configs can either be a file path (relative from the current config) or a secure
URL. For example:
yaml
extends:
- "./another/file.yml"
- "https://domain.com/some/other/file.yml"
When defining extend
, we currently support 3 types of patterns. The first is with a single string,
which only allows a single file to be extended.
```rust
struct AppConfig {
#[setting(extend, validate = schematic::validate::extends_string)]
extends: Option
The second is with a list of strings, allowing multiple files to be extended. This is the YAML example above.
```rust
struct AppConfig {
#[setting(extend, validate = schematic::validate::extends_list)]
extends: Option
And lastly, supporting both a string or a list, using our built-in enum.
```rust
struct AppConfig {
#[setting(extend, validate = schematic::validate::extends_from)]
extends: Option
We suggest making this field optional, so that extending is not required by consumers!
A common requirement for configuration is to merge multiple sources/layers into a final result. By
default schematic will replace the previous value with the next value if the next value is Some
,
but sometimes you want far more control, like shallow merging or deep merging collections.
This can be achieved with the merge
attribute field, which requires a path to a function to call.
```rust
struct AppConfig {
#[setting(merge = schematic::merge::appendvec)]
allowedhosts: Vec
We provide a handful of built-in merge functions in the
merge
module.
When defining a custom merge function, the previous value, next value, and context are passed as
arguments, and the function must return an optional merged result. If None
is provided, neither
value will be used.
Here's an example of the merge function above.
```rust
pub fn append_vec
Ok(Some(prev))
} ```
What kind of configuration crate would this be without built-in validation? As such, we support it as a first-class feature, with built-in validation rules provided by garde.
In schematic, validation does not happen as part of the serde parsing process, and instead happens for each partial configuration to be merged.
Validation can be applied on a per-setting basis with the validate
attribute field, which requires
a path to a function to call. Furthermore, some functions are factories which can be called to
produce a validator.
```rust
struct AppConfig { #[setting(validate = schematic::validate::alphanumeric)] secret_key: String,
#[setting(validate = schematic::validate::regex("^\.env"))]
env_file: String,
} ```
We provide a handful of built-in validation functions in the
validate
module.
When defining a custom validate function, the value to check is passed as the first argument, the
current partial as the second, and the context as the third. The ValidateError
type
must be used for failures.
```rust use schematic::ValidateError;
fn validatestring( value: &str, partial: &PartialAppConfig, context: &Context ) -> Result<(), ValidateError> { if !docheck(value) { return Err(ValidateError::new("Some failure message")); }
Ok(())
} ```
If validating an item in a vector or collection, you can specifiy the nested path when erroring. This is extremely useful when building error messages.
```rust use schematic::Segment;
ValidateError::withsegments( "Some failure message", // [i].key [Segment::Index(i), Segment::Key(key.tostring())] ) ```
The rename
and skip
attribute fields are currently supported and will apply a #[serde]
attribute to the partial setting.
```rust
struct Example { #[setting(rename = "type")] type_of: SomeEnum, } ```
The following Cargo features are available:
json
(default) - Enables JSON.toml
- Enables TOML.yaml
- Enables YAML.valid_email
- Enables email validation with the schematic::validate::email
function.valid_url
- Enables URL validation with the schematic::validate::url
and url_secure
functions.