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<dyn Options<SpeechSettings>>,
}
impl Person {
pub fn speak(&self) -> &str {
&self.speech.value().text
}
}
You may be wondering why you need Rc
or the Options
trait. 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. The Options
trait provides a level of indirection to realizing the settings. Options might be expensive to create or may come from an external source, such as a file, that should be differed until the settings will be used. In advanced scenarios, resolving the backed options instance may even change when an underlying configuration source, such as a file, changes.
Now that we have some settings, we can put it together in a simple application.
use crate::*;
use std::rc::Rc;
fn main() {
let options = Rc::new(options::create(SpeechSettings {
text: "Hello world!".into(),
language: "en".into(),
}));
let person = Person { speech: options };
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
pub trait Options<T> {
fn value(&self) -> Ref<T>;
}
- 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 OptionsSnapshot<T> {
fn get(&self, name: Option<&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 aSingleton
service when using dependency injection.
Options Monitor
pub trait OptionsMonitor<T> {
fn current_value(&self) -> Ref<T>;
fn get(&self, name: Option<&str>) -> Ref<T>;
fn on_change(
&self,
listener: Box<dyn Fn(Option<&str>, Ref<T>) + Send + Sync>) -> Subscription<T>;
}
- Is used to retrieve options and manage options notifications for
T
instances. - 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 (
OptionsMonitorCache
)
Options Monitor Cache
pub trait OptionsMonitorCache<T> {
fn get_or_add(
&self,
name: Option<&str>,
create_options: &dyn Fn(Option<&str>) -> T) -> Ref<T>;
fn try_add(&self, name: Option<&str>, options: T) -> bool;
fn try_remove(&self, name: Option<&str>) -> bool;
fn clear(&self);
}
- A cache of
T
instances. - Handles invaliding monitored instances when underlying changes occur.
Configure Options
pub trait ConfigureOptions<T> {
fn configure(&self, name: Option<&str>, options: &mut T);
}
- Configures options when they are being instantiated.
Post-Configure Options
pub trait PostConfigureOptions<T> {
fn post_configure(&self, name: Option<&str>, options: &mut T);
}
- Configures options after they have been instantiated.
- Enable setting or changing options after all
ConfigureOptions
configuration occurs.
Validate Options
pub trait ValidateOptions<T> {
fn validate(&self, name: Option<&str>, options: &T) -> ValidateOptionsResult;
}
- Validates options after they have been instantiated and configured.
Options Factory
pub trait OptionsFactory<T> {
fn create(&self, name: Option<&str>) -> Result<T, ValidateOptionsResult>;
}
- Responsible for creating new options instances.
- The default implementation run all configured instance of:
ConfigureOptions
PostConfigureOptions
ValidateOptions
Validation
Options validation enables configured option values to be validated. Validation is performed via ValidateOptions
, which is typically invoked during options construction through OptionsFactory
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)]
#[serde(rename_all(deserialize = "PascalCase"))]
pub struct MyConfigOptions {
pub key1: String,
pub key2: usize,
pub key3: usize,
}
The following code:
- uses dependency injection (DI).
- calls
add_options
to get anOptionsBuilder
that binds to theMyConfigOptions
struct. - invokes a closure to validate the struct.
use config::{*, ext::*};
use di::*;
use options::ext::*;
fn main() {
let config = Rc::from(
DefaultConfigurationBuilder::new()
.add_json_file("appsettings.json")
.build()
.unwrap()
.as_config(),
);
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()
.unwrap();
}
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 ValidateOptions
ValidateOptions
enables moving the validation code out of a closure and into a struct. The following struct implements ValidateOptions
:
use options::*;
#[derive(Default)]
struct MyConfigValidation;
impl ValidationOptions<MyConfigOptions> for MyConfigValidation {
fn validate(
&self,
name: Option<&str>,
options: &MyConfigOptions) -> ValidateOptionsResult
{
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() {
ValidationOptionsResult::success()
} else {
ValidationOptionsResult::fail_many(failures)
}
}
}
Using the preceding code, validation is enabled with the following code:
use config::{*, ext::*};
use di::*;
use options::{*, ext::*};
fn main() {
let config = Rc::from(
DefaultConfigurationBuilder::new()
.add_json_file("appsettings.json")
.build()
.unwrap()
.as_config(),
);
let provider = ServiceCollection::new()
.apply_config_at::<MyConfigOptions>(config, "MyOptions")
.add(transient::<dyn ValidateOptions<MyConfigOptions>, MyConfigValidation>()
.from(|_| Rc::new(MyConfigValidation::default())))
.build_provider()
.unwrap();
let options = provider.get_required::<dyn Options<MyConfigOptions>>();
println!("Key1 = {}", &options.value().key1);
}
Order of operation:
- Register options services, including
OptionsFactory
, viaapply_config_at
- Register
MyConfigValidation
as [ValidationOptions
] - Enforce validation through
ServiceProvider::get_required
, which callsOptionsFactory
, which callsMyConfigValidation::validate
Options::value
returns a validMyConfigOptions
or panics
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
Default
trait; otherwise a customOptionsFactory
is required. - binds public read-write fields.
The following code:
- calls
ConfigurationBinder::bind
to bind thePositionOptions
class to the"Position"
section. - displays the
Position
configuration data. - requires the binder feature to be enabled
- which transitively enables the serde feature
use config::*;
#[derive(Default, Deserialize)]
#[serde(rename_all(deserialize = "PascalCase"))]
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(&mut options);
format!("Title: {}\nName: {}", options.title, options.name)
}
}
ConfigurationBinder::reify
binds and returns the specified type. ConfigurationBinder::reify
may be more convenient than using ConfigurationBinder::bind
. The following code shows how to use ConfigurationBinder::reify
with the PositionOptions
struct:
use config::*;
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();
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.
Snapshot
When using OptionsSnapshot
:
- 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 OptionsSnapshot
:
use crate::*;
use config::{*, ext::*};
use options::{OptionsSnapshot, ext::*};
use std::rc::Rc;
pub TestSnapModel {
snapshot: Rc<dyn OptionsSnapshot<MyOptions>>
}
impl TestSnapModel {
pub new(snapshot: Rc<dyn OptionsSnapshot<MyOptions>>) -> Self {
Self { snapshot }
}
pub get(&self) -> String {
let options = self.snapshot.get(None);
format!("Option1: {}\nOption2: {}", options.option1, options.option2)
}
}
fn main() {
let config = Rc::from(
DefaultConfigurationBuilder::new()
.add_json_file("appsettings.json")
.build()
.unwrap()
.as_config(),
);
let provider = ServiceCollection::new()
.add(transient_as_self::<TestSnapModel>())
.apply_config_at::<MyOptions>(config, "MyOptions")
.build_provider()
.unwrap();
let model = provider.get_required::<TestSnapModel>();
println!("{}", model.get())
}
Monitor
Monitored options will reflect the current setting values whenever an underlying source changes.
The difference between OptionsMonitor
and OptionsSnapshot
is that:
OptionsMonitor
is aSingleton
service that retrieves current option values at any time, which is especially useful in singleton dependencies.OptionsSnapshot
is aScoped
service and provides a snapshot of the options at the time theOptionsSnapshot
struct is constructed. Options snapshots are designed for use withTransient
andScoped
dependencies.
The following code registers a configuration instance which MyOptions
binds against:
use crate::*;
use config::{*, ext::*};
use options::{OptionsMonitor, ext::*};
use std::rc::Rc;
pub TestMonitorModel {
monitor: Rc<dyn OptionsMonitor<MyOptions>>
}
impl TestMonitorModel {
pub new(monitor: Rc<dyn OptionsMonitor<MyOptions>>) -> Self {
Self { monitor }
}
pub get(&self) -> String {
let options = self.monitor.get(None);
format!("Option1: {}\nOption2: {}", options.option1, options.option2)
}
}
fn main() {
let config = Rc::from(
DefaultConfigurationBuilder::new()
.add_json_file("appsettings.json")
.build()
.unwrap()
.as_config(),
);
let provider = ServiceCollection::new()
.add(transient_as_self::<TestMonitorModel>())
.apply_config_at::<MyOptions>(config, "MyOptions")
.build_provider()
.unwrap();
let model = provider.get_required::<TestMonitorModel>();
println!("{}", model.get())
}
Dependency Injection
An alternative approach when using the options pattern is to bind an entire configuration or section of it through a dependency injection (DI) service container. 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::{*, ext::*};
use options::{Options, ext::*};
use serde::Deserialize;
use std::rc::Rc;
#[derive(Default, Deserialize)]
#[serde(rename_all(deserialize = "PascalCase"))]
pub struct PositionOptions {
pub title: String,
pub name: String,
}
pub TestModel {
options: Rc<dyn Options<Position>>
}
impl TestModel {
pub new(options: Rc<dyn Options<Position>>) -> Self {
Self { options }
}
pub get(&self) -> String {
let value = self.options.value();
format!("Title: {}\nName: {}", value.title, value.name)
}
}
fn main() {
let config = Rc::from(
DefaultConfigurationBuilder::new()
.add_json_file("appsettings.json")
.build()
.unwrap()
.as_config(),
);
let provider = ServiceCollection::new()
.add(transient_as_self::<TestModel>())
.apply_config::<PositionOptions>(config)
.build_provider()
.unwrap();
let model = provider.get_required::<TestModel>();
println!("{}", model.get())
}
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: Rc<Service2>,
s1: Rc<Service1>,
s3: Rc<Service3>,
s4: Rc<Service4>
s4: Rc<Service5>| {
options.property = do_something_with(s1, s2, s3, s4, s5);
});
- Implement the
ConfigureOptions
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 ConfigureOptions
, 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 PostConfigureOptions
. Post-configuration runs after all ConfigureOptions
configuration occurs.
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: Rc<Service2>,
s1: Rc<Service1>,
s3: Rc<Service3>,
s4: Rc<Service4>
s4: Rc<Service5>| {
options.property = do_something_with(s1, s2, s3, s4, s5);
});
- Implement the
PostConfigureOptions
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 PostConfigureOptions
, 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 ValidateOptions
. Validation runs after all ConfigureOptions
and PostConfigureOptions
occurs.
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: Rc<Service2>,
s1: Rc<Service1>,
s3: Rc<Service3>,
s4: Rc<Service4>
s4: Rc<Service5>| do_complex_validation(s1, s2, s3, s4, s5));
- Implement the
ValidateOptions
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 ValidateOptions
, 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 |