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::Refstd::rc::Rc
  • async: options::Refstd::sync::Arc
  • async + di: options::Refdi::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 a Singleton 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 an OptionsBuilder that binds to the MyConfigOptions 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:

  1. Register options services, including OptionsFactory, via apply_config_at
  2. Register MyConfigValidation as [ValidationOptions]
  3. Enforce validation through
    1. ServiceProvider::get_required, which calls
    2. OptionsFactory, which calls
    3. MyConfigValidation::validate
    4. Options::value returns a valid MyConfigOptions 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 custom OptionsFactory is required.
  • binds public read-write fields.

The following code:

  • calls ConfigurationBinder::bind to bind the PositionOptions 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:

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);
            });

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.

FunctionDescription
configureConfigures the options without using any services
configure1Configures the options using a single dependency
configure2Configures the options using 2 dependencies
configure3Configures the options using 3 dependencies
configure4Configures the options using 4 dependencies
configure5Configures 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);
            });

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.

FunctionDescription
post_configurePost-configures the options without using any services
post_configure1Post-configures the options using a single dependency
post_configure2Post-configures the options using 2 dependencies
post_configure3Post-configures the options using 3 dependencies
post_configure4Post-configures the options using 4 dependencies
post_configure5Post-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));

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.

FunctionDescription
validateValidates the options without using any services
validate1Validates the options using a single dependency
validate2Validates the options using 2 dependencies
validate3Validates the options using 3 dependencies
validate4Validates the options using 4 dependencies
validate5Validates the options using 5 dependencies