Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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::Refstd::rc::Rc
  • async: options::Refstd::sync::Arc
  • async + di: options::Refdi::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 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 (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 T instances.
  • 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:
    • Configure
    • PostConfigure
    • Validate

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 MyConfigOptions struct.
  • 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:

  1. Register options services, including an options Factory via apply_config_at
  2. Register MyConfigValidation as options Validate
  3. Enforce validation through
    1. ServiceProvider::get_required, which calls
    2. Factory, which calls
    3. MyConfigValidation::run
    4. A valid MyConfigOptions is 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 Default trait; otherwise a custom options Factory is required.
  • binds public read-write fields.

The following code:

  • calls Binder::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::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 Builder needs to be converted into a ReloadableConfiguration instead of building built a Configuration.

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.

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

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.

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 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.

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