Introduction
more-options is a crate for defining configuration options in Rust. Options can be initialized in code, bound from configuration, and/or composed through dependency injection (DI).
Crate Features
This crate provides the following features:
- default - Abstractions for options
- async - Enable options in asynchronous contexts
- di - Dependency injection extensions
- cfg - Dependency injection extensions to bind configurations to options
Contributing
more-options is free and open source. You can find the source code on GitHub
and issues and feature requests can be posted on the GitHub issue tracker.
more-options relies on the community to fix bugs and add features: if you’d like to contribute, please read the
CONTRIBUTING guide and consider opening
a pull request.
License
This project is licensed under the MIT license.
Getting Started
The simplest way to get started is to install the crate using the default features.
cargo add more-options
Example
The options pattern uses structures to provide strongly typed access to groups of related settings. Options also provide a mechanism to validate configuration data.
Let’s build the ubiquitous Hello World application. The first thing we need to do is define a couple of structures.
use options::Options;
use std::rc::Rc;
pub struct SpeechSettings {
pub text: String,
pub language: String,
}
pub struct Person {
speech: Rc<SpeechSettings>,
}
impl Person {
pub fn speak(&self) -> &str {
&self.speech.text
}
}
You may be wondering why you need Rc. In many practical applications, you’ll have a group of immutable settings which
will be used in many places. Rc (or Arc) allows a single instance of settings to be shared throughout the
application. You can also use the Ref type alias to switch between them depending on whether the async feature is
enabled.
Now that we have some options, we can put it together in a simple application.
use crate::options;
fn main() {
let options = SpeechSettings {
text: "Hello world!".into(),
language: "en".into(),
};
let person = Person { speech: options.into() };
println!("{}", person.speak());
}
Abstractions
The options framework contains a common set traits and behaviors for numerous scenarios.
Ref is a type alias depending on which features are enabled:
- default:
options::Ref→std::rc::Rc - async:
options::Ref→std::sync::Arc - async + di:
options::Ref→di::Ref
Options
Any Rust struct that represents configurable options.
- Does not support:
- Reading of configuration data after the application has started.
- Is registered as a Singleton and can be injected into any service lifetime when using dependency injection.
Options Snapshot
pub trait Snapshot<T> {
fn get(&self) -> Result<Ref<T>, validation::Error>;
fn get_unchecked(&self) -> Ref<T>;
fn get_named(&self, name: &str) -> Result<Ref<T>, validation::Error>;
fn get_named_unchecked(&self, name: &str) -> Ref<T>;
}
- Is useful in scenarios where options should be recomputed on every request.
- Is registered as Scoped and therefore can’t be injected into a Singleton service when using dependency injection.
Options Monitor
pub trait Monitor<T> {
fn get(&self) -> Result<Ref<T>, validation::Error>;
fn get_unchecked(&self) -> Ref<T>;
fn get_named(&self, name: &str) -> Result<Ref<T>, validation::Error>;
fn get_named_unchecked(&self, name: &str) -> Ref<T>;
fn on_change(
&self,
callback: Box<dyn Fn(&str, Ref<T>) + Send + Sync>) -> Subscription<T>;
}
- Is used to retrieve options and manage options notifications for
Tinstances. - Is registered as a Singleton and can be injected into any service lifetime when using dependency injection.
- Supports:
- Change notifications
- Reloadable configuration
- Selective options invalidation (MonitorCache)
Options Monitor Cache
pub trait MonitorCache<T> {
fn get_or_add(
&self,
name: &str,
create: &dyn Fn(&str) -> Result<T, validation::Error>)
-> Result<Ref<T>, validation::Error>;
fn try_add(&self, name: &str, options: T) -> bool;
fn try_remove(&self, name: &str) -> bool;
fn clear(&self);
}
- A cache of
Tinstances. - Handles invaliding monitored instances when underlying changes occur.
Configure Options
pub trait Configure<T> {
fn run(&self, name: &str, options: &mut T);
}
- Configures options when they are being instantiated.
- Can be implemented directly or maps to compatible closures.
Post-Configure Options
pub trait PostConfigure<T> {
fn run(&self, name: &str, options: &mut T);
}
- Configures options after they have been instantiated.
- Can be implemented directly or maps to compatible closures.
- Enable setting or changing options after all Configure operations occur.
Validate Options
Validation is part of the validation module.
pub trait Validate<T> {
fn run(&self, name: &str, options: &T) -> Result;
}
- Validates options after they have been instantiated and configured.
- Can be implemented directly or maps to compatible closures.
Options Factory
pub trait Factory<T> {
fn create(&self, name: &str) -> Result<T, validation::Error>;
}
- Responsible for creating new options instances.
- The default implementation run all configured instance of:
ConfigurePostConfigureValidate
Validation
Options validation enables configured option values to be validated. Validation is performed via Validate, which is typically invoked during options construction through an options Factory rather than imperatively.
Consider the following appsettings.json file:
{
"MyConfig": {
"Key1": "My Key One",
"Key2": 10,
"Key3": 32
}
}
The application settings might be bound to the following options struct:
#[derive(Default, Deserialize)]
pub struct MyConfigOptions {
pub key1: String,
pub key2: usize,
pub key3: usize,
}
The following code:
- uses dependency injection (DI).
- calls add_options to get an Builder that binds to the
MyConfigOptionsstruct. - invokes a closure to validate the struct.
use config::prelude::*;
use di::ServiceCollection;
use options::prelude::*;
use std::error::Error;
fn main() -> Result<(), Box<dyn Error + 'static>> {
let config = config::builder().add_json_file("appsettings.json").build()?;
let provider = ServiceCollection::new()
.apply_config_at::<MyConfigOptions>(config, "MyConfig")
.validate(
|options| options.key2 == 0 || options.key3 > options.key2,
"Key3 must be > than Key2.")
.build_provider()?;
Ok(())
}
Dependency injection is not required to enforce validation, but it is the simplest and fastest way to compose all of the necessary pieces together.
Implementing Validate
Validate enables moving the validation code out of a closure and into a struct. The following struct implements Validate:
use di::injectable;
use options::validation::{self, Result, Validate};
#[injectable(Validate<MyConfigOptions>)]
struct MyConfigValidation;
impl Validate<MyConfigOptions> for MyConfigValidation {
fn run(&self, name: &str, options: &MyConfigOptions) -> Result {
let failures = Vec::default();
if options.key2 < 0 || options.key2 > 1000 {
failures.push(format!("{} doesn't match Range 0 - 1000", options.key2));
}
if config.key3 <= config.key2 {
failures.push("Key3 must be > than Key2");
}
if failures.is_empty() {
validation::success()
} else {
Err(validation::Error::many(failures))
}
}
}
Using the preceding code, validation is enabled with the following code:
use config::prelude::*;
use di::ServiceCollection;
use options::prelude::*;
use std::error::Error;
fn main() -> Result<(), Box<dyn Error + 'static>> {
let config = config::builder().add_json_file("appsettings.json").build()?;
let provider = ServiceCollection::new()
.apply_config_at::<MyConfigOptions>(config, "MyOptions")
.add(MyConfigValidation::transient())
.build_provider()?;
let options = provider.get_required::<MyConfigOptions>();
println!("Key1 = {}", options.key1);
Ok(())
}
Order of operation:
- Register options services, including an options Factory via apply_config_at
- Register
MyConfigValidationas options Validate - Enforce validation through
- ServiceProvider::get_required, which calls
- Factory, which calls
MyConfigValidation::run- A valid
MyConfigOptionsis returned or panics
A panic is an unfortunate, current limitation of resolution from DI. For validation not to panic, the injected service would need to be
Result<di::Ref<MyConfigOptions>, validation::Error>, which is possible, but not ergonomic.
Configuration Binding
The preferred way to read related configuration values is using the options pattern. For example, to read the following configuration values:
{
"Position": {
"Title": "Editor",
"Name": "Joe Smith"
}
}
Create the following PositionOptions struct:
#[derive(Default)]
pub struct PositionOptions {
pub title: String,
pub name: String,
}
An options struct:
- must be public.
- should implement the
Defaulttrait; otherwise a custom options Factory is required. - binds public read-write fields.
The following code:
- calls Binder::bind to bind the
PositionOptionsclass to the"Position"section. - displays the
Positionconfiguration data. - requires the binder feature to be enabled
- which transitively enables the serde feature
use config::prelude::*;
use serde::Deserialize;
#[derive(Default, Deserialize)]
pub struct PositionOptions {
pub title: String,
pub name: String,
}
pub TestModel<'a> {
config: &'a dyn Configuration
}
impl<'a> TestModel<'a> {
pub new(config: &dyn Configuration) -> Self {
Self { config }
}
pub get(&self) -> String {
let mut options = PositionOptions::default();
let section = self.config.section("Position").bind_unchecked(&mut options);
format!("Title: {}\nName: {}", options.title, options.name)
}
}
Binder::reify binds and returns the specified type. Binder::reify may be more convenient than using
Binder::bind. The following code shows how to use Binder::reify with the PositionOptions struct:
use config::prelude::*;
pub TestModel<'a> {
config: &'a dyn Configuration
}
impl<'a> TestModel<'a> {
pub new(config: &dyn Configuration) -> Self {
Self { config }
}
pub get(&self) -> String {
let options: PositionOptions = self.config.section("Position").reify_unchecked();
format!("Title: {}\nName: {}", options.title, options.name)
}
}
Runtime Changes
The options framework supports responding to setting changes at runtime when they occur. There are a number of scenarios when that may happen, such as an underlying configuration file has changed. The options framework doesn’t understand how or what has changed, only that a change has occurred. In response to the change, the corresponding options will be updated.
Options Snapshot
When using an options Snapshot:
- options are computed once per request when accessed and cached for the lifetime of the request.
- may incur a significant performance penalty because it’s a Scoped service and is recomputed per request.
- changes to the configuration are read after the application starts when using configuration providers that support reading updated configuration values.
The following code uses an options Snapshot:
use crate::MyOptions;
use config::prelude::*;
use di::{injectable, Injectable, ServiceCollection};
use options::{prelude::*, Snapshot, Ref};
use std::error::Error;
pub struct TestSnapModel {
snapshot: Ref<dyn Snapshot<MyOptions>>
}
#[injectable]
impl TestSnapModel {
pub fn new(snapshot: Ref<dyn Snapshot<MyOptions>>) -> Self {
Self { snapshot }
}
pub fn get(&self) -> String {
let options = self.snapshot.get_unchecked();
format!("Option1: {}\nOption2: {}", options.option1, options.option2)
}
}
fn main() -> Result<(), Box<dyn Error + 'static>> {
let config = config::builder().add_json_file("appsettings.json").build()?;
let provider = ServiceCollection::new()
.add(TestSnapModel::transient())
.apply_config_at::<MyOptions>(config, "MyOptions")
.build_provider()?;
let model = provider.get_required::<TestSnapModel>();
println!("{}", model.get());
Ok(())
}
Options Monitor
Monitored options will reflect the current setting values whenever an underlying source changes.
The difference between an options Monitor and Snapshot are that:
- Monitor is a Singleton service that retrieves current option values at any time, which is especially useful in singleton dependencies.
- Snapshot is a Scoped service and provides a snapshot of the options at the time the Snapshot is constructed. Options snapshots are designed for use with Transient and Scoped dependencies.
The following code registers a configuration instance which MyOptions binds against:
use crate::MyOptions;
use config::{prelude::*, ReloadableConfiguration};
use di::{injectable, Injectable, ServiceCollection};
use options::{prelude::*, Monitor, Ref};
use std::convert::TryInto;
use std::error::Error;
pub TestMonitorModel {
monitor: Ref<dyn Monitor<MyOptions>>
}
#[injectable]
impl TestMonitorModel {
pub new(monitor: Ref<dyn Monitor<MyOptions>>) -> Self {
Self { monitor }
}
pub get(&self) -> String {
let options = self.monitor.get_unchecked();
format!("Option1: {}\nOption2: {}", options.option1, options.option2)
}
}
fn main() -> Result<(), Box<dyn Error + 'static>> {
let config: ReloadableConfiguration = config::builder()
.add_json_file("appsettings.json".is().reloadable())
.try_into()?;
let provider = ServiceCollection::new()
.add(TestMonitorModel::transient())
.apply_config_at::<MyOptions>(config, "MyOptions")
.build_provider()?;
let model = provider.get_required::<TestMonitorModel>();
println!("{}", model.get());
Ok(())
}
In order to detect and bind configuration changes, a configuration
Builderneeds to be converted into aReloadableConfigurationinstead of building built aConfiguration.
Dependency Injection
An alternative approach when using the options pattern is to bind an entire configuration or section of it through dependency injection (DI). Dependency injection extensions are provided by the more-di crate.
In the following code, PositionOptions is added to the service container with apply_config and bound to loaded
configuration:
use config::prelude::*;
use di::{injectable, Injectable, Ref, ServiceCollection};
use options::prelude::*;
use serde::Deserialize;
use std::error::Error;
#[derive(Default, Deserialize)]
pub struct PositionOptions {
pub title: String,
pub name: String,
}
pub struct TestModel {
options: Ref<PositionOptions>
}
#[injectable]
impl TestModel {
pub fn new(options: Ref<PositionOptions>) -> Self {
Self { options }
}
pub fn get(&self) -> String {
format!("Title: {}\nName: {}", self.options.title, self.options.name)
}
}
fn main() -> Result<(), Box<dyn Error + 'static>> {
let config = config::builder().add_json_file("appsettings.json").build()?;
let provider = ServiceCollection::new()
.add(TestModel::transient())
.apply_config::<PositionOptions>(config)
.build_provider()?;
let model = provider.get_required::<TestModel>();
println!("{}", model.get());
Ok(())
}
Options Configuration
Services can be accessed from dependency injection while configuring options in two ways:
- Pass a configuration function
services.add_options::<MyOptions>()
.configure(|options| options.count = 1);
services.configure_options::<MyAltOptions>(|options| options.count = 1);
services.add_named_options::<MyOtherOptions>("name")
.configure5(
|options,
s2: di::Ref<Service2>,
s1: di::Ref<Service1>,
s3: di::Ref<Service3>,
s4: di::Ref<Service4>
s4: di::Ref<Service5>| {
options.property = do_something_with(s1, s2, s3, s4, s5);
});
- Implement the Configure trait and register it as a service
It is recommended to pass a configuration closure to one of the configure functions since creating a struct is more
complex. Creating a struct is equivalent to what the framework does when calling any of the configure functions.
Calling one of the configure functions registers a transient Configure, which initializes with the specified
service types.
| Function | Description |
|---|---|
| configure | Configures the options without using any services |
| configure1 | Configures the options using a single dependency |
| configure2 | Configures the options using 2 dependencies |
| configure3 | Configures the options using 3 dependencies |
| configure4 | Configures the options using 4 dependencies |
| configure5 | Configures the options using 5 dependencies |
Options Post-Configuration
Set post-configuration with PostConfigure. Post-configuration runs after all Configure operations occur.
Services can be accessed from dependency injection while configuring options in two ways:
- Pass a configuration function
services.add_options::<MyOptions>()
.post_configure(|options| options.count = 1);
services.post_configure_options::<MyAltOptions>(|options| options.count = 1);
services.add_named_options::<MyOtherOptions>("name")
.post_configure5(
|options,
s2: di::Ref<Service2>,
s1: di::Ref<Service1>,
s3: di::Ref<Service3>,
s4: di::Ref<Service4>
s4: di::Ref<Service5>| {
options.property = do_something_with(s1, s2, s3, s4, s5);
});
- Implement the PostConfigure trait and register it as a service
post_configure_options applies to all instances. To apply a named configuration use post_configure_named_options. It
is recommended to pass a configuration closure to one of the post_configure functions since creating a struct is more
complex. Creating a struct is equivalent to what the framework does when calling any of the post_configure functions.
Calling one of the post_configure functions registers a transient PostConfigure, which initializes with the
specified service types.
| Function | Description |
|---|---|
| post_configure | Post-configures the options without using any services |
| post_configure1 | Post-configures the options using a single dependency |
| post_configure2 | Post-configures the options using 2 dependencies |
| post_configure3 | Post-configures the options using 3 dependencies |
| post_configure4 | Post-configures the options using 4 dependencies |
| post_configure5 | Post-configures the options using 5 dependencies |
Options Validation
Validation is performed with Validate. Validation runs after all Configure and PostConfigure operations occur.
Services can be accessed from dependency injection while validating options in two ways:
- Pass a validation function
services.add_options::<MyOptions>()
.configure(|options| options.count = 1)
.validate(|options| options.count > 0, "Count must be greater than 0.");
services.add_named_options::<MyOtherOptions>("name")
.configure(|options| options.count = 1)
.validate5(
|options,
s2: di::Ref<Service2>,
s1: di::Ref<Service1>,
s3: di::Ref<Service3>,
s4: di::Ref<Service4>
s4: di::Ref<Service5>| do_complex_validation(s1, s2, s3, s4, s5));
- Implement the Validate trait and register it as a service
It is recommended to pass a validation closure to one of the validate functions since creating a struct is more
complex. Creating a struct is equivalent to what the framework does when calling any of the validate functions.
Calling one of the validate functions registers a transient Validate, which initializes with the specified
service types.
| Function | Description |
|---|---|
| validate | Validates the options without using any services |
| validate1 | Validates the options using a single dependency |
| validate2 | Validates the options using 2 dependencies |
| validate3 | Validates the options using 3 dependencies |
| validate4 | Validates the options using 4 dependencies |
| validate5 | Validates the options using 5 dependencies |