#more #options

more-options

Provides support for options

2 stable releases

2.0.0 Feb 23, 2023
1.0.0 Oct 6, 2022

#4 in #options

MIT license

115KB
2.5K SLoC

More Options Crate

Crates.io MIT licensed

This library contains all of the fundamental abstractions for defining configuration options.

Features

This crate provides the following features:

  • Default - Abstractions for the options
  • di - Provides dependency injection extensions
  • cfg - Provides dependency injection extensions to bind configurations to options
  • async - Provides features for using options in an asynchronous context

Options Pattern

The options pattern uses classes to provide strongly typed access to groups of related settings. Options also provide a mechanism to validate configuration data. For more information, see the Options validation section.

Bind Hierarchical Configuration

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<TOptions> is required.
  • All public read-write fields of the type are bound.

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
#[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: 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<T> binds and returns the specified type. ConfigurationBinder.reify<T> may be more convenient than using ConfigurationBinder.bind. The following code shows how to use ConfigurationBinder.reify<T> with the PositionOptions struct:

pub TestModel<'a> {
    config: &'a dyn Configuration
}

impl<'a> TestModel<'a> {
    pub new(config: &dyn Configuration) -> Self {
        Self { config: config }
    }

    pub get(&self) -> String {
        let options: PositionOptions = self.config.section("Position").reify();
        format!("Title: {}\nName: {}", options.title, options.name)
    }
}

An alternative approach when using the options pattern is to bind the Position section and add it to the dependency injection service container. In the following code, PositionOptions is added to the service container with configure and bound to configuration:

pub TestModel {
    options: Rc<dyn Options<Position>>
}

impl TestModel {
    pub new(options: Rc<dyn Options<Position>>) -> Self {
        Self { options: options }
    }

    pub get(&self) -> String {
        let value = self.options.value();
        format!("Title: {}\nName: {}", value.title, value.name)
    }
}

fn main() {
    let path = PathBuf::from("./appsettings.json");
    let config = Rc::from(
        DefaultConfigurationBuilder::new()
            .add_json_file(&path)
            .build()
            .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 Traits

Options<TOptions>:

  • Does not support:
    • Reading of configuration data after the app has started.
  • Is registered as a Singleton and can be injected into any service lifetime.

OptionsSnapshot<TOptions>:

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

OptionsMonitor<TOptions>:

  • Is used to retrieve options and manage options notifications for TOptions instances.
  • Is registered as a Singleton and can be injected into any service lifetime.
  • Supports:
    • Change notifications
    • Reloadable configuration
    • Selective options invalidation (OptionsMonitorCache<TOptions>)

Post-configuration scenarios enable setting or changing options after all ConfigureOptions<TOptions> configuration occurs.

OptionsFactory<TOptions> is responsible for creating new options instances. It has a single create method. The default implementation takes all registered ConfigureOptions<TOptions> and PostConfigureOptions<TOptions> and runs all the configurations first, followed by the post-configuration.

OptionsMonitorCache<TOptions> is used by OptionsMonitor<TOptions> to cache TOptions instances. The OptionsMonitorCache<TOptions> invalidates options instances in the monitor so that the value is recomputed (try_remove). Values can be manually introduced with try_add. The clear method is used when all named instances should be recreated on demand.

Use OptionsSnapshot to Read Updated Data

Using OptionsSnapshot<TOptions>:

  • 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 app starts when using configuration providers that support reading updated configuration values.

The difference between OptionsMonitor<TOptions> and OptionsSnapshot<TOptions> is that:

  • OptionsMonitor<TOptions> is a Singleton service that retrieves current option values at any time, which is especially useful in singleton dependencies.
  • OptionsSnapshot<TOptions> is a Scoped service and provides a snapshot of the options at the time the OptionsSnapshot<TOptions> object is constructed. Options snapshots are designed for use with transient and scoped dependencies.

The following code uses OptionsSnapshot<TOptions>:

pub TestSnapModel {
    snapshot: Rc<dyn OptionsSnapshot<MyOptions>>
}

impl TestSnapModel {
    pub new(snapshot: Rc<dyn OptionsSnapshot<MyOptions>>) -> Self {
        Self { snapshot: snapshot }
    }

    pub get(&self) -> String {
        let options = self.snapshot.get(None);
        format!("Option1: {}\nOption2: {}", options.option1, options.option2)
    }
}

fn main() {
    let path = PathBuf::from("./appsettings.json");
    let config = Rc::from(
        DefaultConfigurationBuilder::new()
            .add_json_file(&path)
            .build()
            .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())
}

OptionsMonitor

The following code registers a configuration instance which MyOptions binds against:

pub TestMonitorModel {
    monitor: Rc<dyn OptionsMonitor<MyOptions>>
}

impl TestMonitorModel {
    pub new(monitor: Rc<dyn OptionsMonitor<MyOptions>>) -> Self {
        Self { monitor: monitor }
    }

    pub get(&self) -> String {
        let options = self.monitor.get(None);
        format!("Option1: {}\nOption2: {}", options.option1, options.option2)
    }
}

fn main() {
    let path = PathBuf::from("./appsettings.json");
    let config = Rc::from(
        DefaultConfigurationBuilder::new()
            .add_json_file(&path)
            .build()
            .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())
}

Use Dependency Injection to Configure Options

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<TOptions> 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<TOptions>, 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
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
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

Options Validation

Options validation enables option values to be validated.

Consider the following appsettings.json file:

{
  "MyConfig": {
    "Key1": "My Key One",
    "Key2": 10,
    "Key3": 32
  }
}

The following code:

  • Calls add_options to get an OptionsBuilder<TOptions> that binds to the MyConfigOptions struct.
  • Invokes a closure to validate the struct.
fn main() {
    let path = PathBuf::from("./appsettings.json");
    let config = Rc::from(
        DefaultConfigurationBuilder::new()
            .add_json_file(&path)
            .build()
            .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();
}

It is recommended to validate options via a closure as opposed to implementing ValidateOptions<TOptions> directly. The default validation consumers, such as OptionsFactory<TOptions>, panic if there are any validation errors as the application is considered to be in an invalid state.

ValidateOptions<TOptions>

The following struct implements ValidateOptions<TOptions>:

#[derive(Default)]
struct MyConfigValidation;

impl ValidationOptions<MyConfigOptions> for MyConfigValidation {
    fn valid(&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)
        }
    }
}

ValidateOptions enables moving the validation code out of a closure and into a struct.

Using the preceding code, validation is enabled with the following code:

fn main() {
    let path = PathBuf::from("./appsettings.json");
    let config = Rc::from(
        DefaultConfigurationBuilder::new()
            .add_json_file(&path)
            .build()
            .as_config(),
    );
    let provider = ServiceCollection::new()
        .apply_config_at::<MyOptions>(config, "MyOptions")
        .add(
            transient::<dyn ValidateOptions<MyConfigOptions>, MyConfigValidation>()
            .from(|_| Rc::new(MyConfigValidation::default())))
        .build_provider()
        .unwrap();
    let options = provider.get_required::<MyConfigOptions>();
}

Options Post-Configuration

Set post-configuration with PostConfigureOptions<TOptions>. Post-configuration runs after all ConfigureOptions<TOptions> configuration occurs:

fn main() {
    let provider = ServiceCollection::new()
        .post_configure_options::<TestOptions>(|options| options.enabled = true)
        .build_provider()
        .unwrap();
}

post_configure_options applies to all instances. To apply a named configuration use post_configure_named_options.

License

This project is licensed under the MIT license.

Dependencies

~10–265KB