Skip to content

UltraSettings is a Ruby gem that provides a flexible and documented approach to managing application configurations from multiple sources, including environment variables, runtime settings, and YAML files, with an optional web UI for easy documentation.

License

Notifications You must be signed in to change notification settings

bdurand/ultra_settings

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

78 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

UltraSettings

Continuous Integration Ruby Style Guide Gem Version

Introduction

UltraSettings is a Ruby gem designed for managing application settings from various sources providing a consistent method for accessing configuration values. It simplifies your application's configuration management by allowing settings to be defined, documented, and accessed seamlessly.

UltraSettings emphasizes well-documented configuration. You can include documentation directly in the configuration code. The gem also includes a web UI that can be mounted as a Rack app or embedded in other views allowing admin users to easily view configuration settings and documentation.

Key Features

This gem supports a three-layer hierarchy for defining configuration sources:

  1. Environment Variables
  2. Runtime Settings (modifiable within the running application)
  3. YAML Configuration Files

Settings from higher levels override those from lower ones. For example, values defined in environment variables or runtime settings will override those specified in YAML files. This hierarchy is optional — you can disable it and define specific data sources as needed for each configuration field.

Simplified Access and Type Safety

With UltraSettings, your application code does not need to worry about how or from where a setting value is loaded from. Configuration settings can be accessed using plain Ruby objects and methods simplifying the development process.

The gem also ensures type safety by typecasting settings to specific data types, so you can rely on consistent data formats without manual type coercion. Supported types include:

  • String
  • Integer
  • Float
  • Boolean
  • Time
  • Symbol
  • Array<String>

Additionally, you can define default values for settings, ensuring that a fallback value is available if a configuration is missing or does not meet constraints.

Usage

Defining Configurations

Configurations are defined as classes that extend from the UltraSettings::Configuration class. These configuration classes are singleton classes.

You can define fields on your configuration classes with the field method. This will define a method on your configuration object with the given name.

class MyServiceConfiguration < UltraSettings::Configuration
  self.fields_secret_by_default = false

  field :host, type: :string, description: "The hostname for the service"

  field :port, type: :integer, default: 80, description: "The port for the service"

  field :protocol, type: :string, default: "https", description: "The protocol for the service"

  field :timeout,
    type: :float,
    default: 1.0,
    default_if: ->(val) { val <= 0 },
    description: "Network timeout in seconds for requests to the service."

  field :auth_token,
    type: :string,
    env_var: "MY_SERVICE_TOKEN",
    runtime_setting: false,
    yaml_key: false,
    description: "Bearer token for accessing the service",
    secret: true

  # You aren't limited to just defining fields, you can define other
  # helper methods to make using the configuration easier.
  def uri
    URI("#{protocol}://#{host}:#{port}")
  end
end

Field Options

You can customize the behavior of each field using various options:

  • :type - Specifies the type of the field. The value of the setting will be cast to this type. If the value in the data source cannot be cast to the data type, then it will not be used. Supported types are:

    • :string (the default)
    • :integer
    • :float
    • :boolean (will accept case insensitive strings "true", "false", "1", "0", "t", "f", "yes", "no", "y", "n")
    • :datetime
    • :symbol
    • :array (of strings)
  • :description - Provides a description of the field. This is used for documentation purposes.

  • :default - Sets a default value for the field. The value will be cast to the specified type.

  • :default_if - Provides a condition for when the default should be used. This should be a Proc or the name of a method within the class. Useful for ensuring values meet specific constraints. This can provide protection from misconfiguration that can break the application. In the above example, the default value for timeout will be used if the value is less than or equal to 0.

  • :secret - Marks the field as secret. Secret fields are not displayed in the web UI. By default, all fields are considered secret to avoid accidentally exposing sensitive values. You can change this default behavior by setting fields_secret_by_default to false either globally or per configuration.

  • :env_var - Overrides the environment variable name used to populate the field. This is useful if the variable name does not follow the conventional pattern. Set this to false to disable loading the field from an environment variable.

  • :runtime_setting - Overrides the runtime setting name for the field. Useful if the runtime setting name does not match the conventional pattern. Set this to false to disable loading the field from runtime settings.

  • :yaml_key - Overrides the key in the YAML configuration file for this field. This is useful when the key does not match the field name. Set this to false to disable loading the field from a YAML file.

  • :static - Marks the field as a static value. Static values cannot be changed once set and are not allowed to be set from runtime settings. Use this for settings that need to be referenced at the application is initializing.

Environment Variables

Settings will first try to load values from environment variables. Environment variables are a good place to define environment specific values or sensitive values that you do not want to store in your codebase.

Default Behavior

By default, environment variables for settings are constructed using a prefix based on the configuration class name, with the field name appended. For example, a class named Configs::MySettingsConfiguration will use the prefix CONFIGS_MY_SETTINGS_, resulting in environment variables like CONFIGS_MY_SETTINGS_HOST.

Customizing Environment Variables

You can customize the behavior of environment variable naming in several ways:

  • Explicit Environment Variables: You can specify the name of the environment variable to use for a field by setting the env_var option on the field. This allows you to use a different name than the default.

  • Lowercase Environment Variables: Set env_var_upcase to false in your configuration class to use lowercase environment variable names.

  • Custom Delimiter: The delimiter between module names and before the setting name can be customized by setting env_var_delimiter on your configuration class. For example, using a delimiter of "." in Configs::MySettingsConfiguration would produce environment variables like CONFIGS.MY_SETTINGS.HOST.

  • Custom Prefix: Set env_var_prefix on your configuration class to specify an explicit prefix for environment variables. This allows for more flexibility in naming conventions.

  • Disabling Environment Variables: You can disable environment variables as a default source for fields by setting environment_variables_disabled to true in your configuration class. You can disable environent variables on individual fields by setting env_var on the field to false.

If a setting value cannot be loaded from an environment variable, then it's value will attempt to be loaded from a runtime setting.

Runtime Settings

Runtime settings are configurations loaded while your application is running, allowing for dynamic updates without needing to restart the application. This flexibility makes them ideal for settings that may change frequently or need quick adjustments.

Setting Up Runtime Settings

To enable runtime settings, set the UltraSettings.runtime_settings attribute to an object that implements a [] method and accepts a string argument. For example, to load runtime settings from a Redis database, you could use the following implementation:

class RedisRuntimeSettings
  def initialize
    @redis = Redis.new
  end

  def [](name)
    @redis.get(name)
  end
end

UltraSettings.runtime_settings = RedisRuntimeSettings.new

Using the super_settings gem

There is a companion gem super_settings that can be used as a drop in implementation for the runtime settings. You just need to set the runtime settings to the SuperSettings object.

UltraSettings.runtime_settings = SuperSettings

Customizing Runtime Settings

By default settings will be loaded from runtime settings by constructing a prefix from the configuration class name (i.e. Configs::MySettingsConfiguration uses the prefix configs.my_settings.) with the field name appended to it (e.g. configs.my_settings.host). By default runtime settings will be in all lowercase letters.

You can customize the behavior of runtime setting names with the following options:

  • Explicit Runtime Setting Names: You can specify the name of the runtime setting to use for a field by setting the runtime_setting option on the field. This allows you to use a different name than the default.

  • Uppercase Runtime Setting Names: Set runtime_setting_upcase to true in your configuration class to use uppercase runtime setting names.

  • Custom Delimiter: Change the delimiter used between module names and before the setting name by setting runtime_setting_delimiter on your configuration class. For example, using a delimiter of "/" would produce runtime settings like configs/my_settings/host.

  • Custom Prefix: Set runtime_setting_prefix on your configuration class to specify a custom prefix for runtime settings, giving you flexibility in naming conventions.

  • Disabling Runtime Settings: You can disable runtime settings as a default source for fields by setting runtime_settings_disabled to true in your configuration class. You can disable runtime settings on individual fields by setting runtime_setting on the field to false.

If a setting value cannot be loaded from the runtime settings, then it's value will attempt to be loaded from a YAML file.

YAML Files

YAML files are the final source UltraSettings will check when loading configuration values. They provide a convenient way to store default values that can be distributed with your application code.

By default settings will be loaded from a YAML file determined by its class name (i.e. Configs::MySettingsConfiguration uses the file configs/my_settings.yml). The file is searched for in the path defined by UltraSettings.yaml_config_path. If the file does not exist, the YAML source strategy will not be used.

Customizing YAML Files

  • Explicit YAML Key: You can specify the key in the YAML file to use for a field by setting the yaml_key option on the field. This allows you to use a different key than the default.

  • Custom YAML File Path: You can specify an explicit YAML file by setting configuration_file on your configuration class to the desired file path.

  • Disable YAML Source: To disable YAML files as a default source for your fields, set yaml_config_disabled to true on your configuration class. You can disable YAML files on individual fields by setting yaml_key on the field to false.

ERB Support

YAML files support ERB markup (i.e., <%= %>) that will be evaluated before the YAML is parsed. This feature allows for dynamically generated values within the YAML file.

Environment-Specific Configurations

YAML files can define environment-specific configurations. The file must contain a hash where the keys represent the names of your application environments (e.g., development, test, production). You can specify the environment to use by setting UltraSettings.yaml_config_env (default is "development").

A special key, shared, can be defined in the YAML file. The settings under this key will be merged with the environment-specific settings. Values from the specific environment will always overwrite those from shared.

Example YAML File

shared:
  timeout: 5
  port: 8000

development:
  timeout: 10
  host: localhost

production:
  host: prod.example.com

The values for the development environment would be the combination of development and shared:

{
  timeout: 10,
  port: 8000,
  host: "localhost"
}

While for production, the values would be the combination of production and shared:

{
  timeout: 5,
  port: 8000,
  host: "prod.example.com"
}

Rails Integration

In a Rails application, the YAML environment will be set to the Rails environment and YAML files will be assumed to exist in the config directory.

Removing The Hierarchy

If you prefer not to use the default hierarchy of environment variables, runtime settings, and YAML files, you can disable it. This allows you to explicitly define which data sources should be used for each field.

class MyServiceConfiguration < UtraSettings::Configuration
  self.environment_variables_disabled = false
  self.runtime_settings_disabled = false
  self.yaml_config_disabled = false

field :host, yaml_key: "host"
  field :token, env_var: "MY_SERVICE_TOKEN"
  field :timeout, runtime_setting: "my_service.timeout", default: 5
end

If you don't want the hierarchy in any configuration, then you can disable it globally.

UltraSettings.environment_variables_disabled = true
UltraSettings.runtime_settings_disabled = true
UltraSettings.yaml_config_disabled = true

Accessing settings

Configurations in UltraSettings are singleton objects, and settings are accessed by calling methods directly on these objects.

MyServiceConfiguration.instance.host

Adding Configurations to UltraSettings

To simplify access, you can add configurations to the UltraSettings object. UltraSettings will derive the configuration class name based on the name provided and define a method that returns the configuraiton object. For example:

UltraSettings.add(:my_service)
UltraSettings.my_service # => MyServiceConfiguration.instance
UltraSettings.my_service.host

Alternatively, you can explicitly specify the class name to map to a method name:

UltraSettings.add(:my, "MyServiceConfiguration")
UltraSettings.my.host

In a Rails application, you could add syntactic sugar by exposing the UltraSettings object as a helper method in application.rb.

module MyApp
  class Application < Rails::Application
    def settings
      UltraSettings
    end
  end
end

Rails.application.settings.my_service.host

Using a Helper Method

To keep your codebase clean, especially if most configurations are accessed from within a specific class, you can encapsulate the configuration access in a helper method.

class MyService

  # Reference the value as `settings.host`

  private

  def settings
    MyServiceConfiguration.instance
  end
end

Web UI

UltraSettings provides a web UI via a mountable Rack application. You can use this to view the settings values and documentation. The UI will not display the value of any setting marked as secret.

Web UI

It is strongly recommended to secure the web UI with your application's authorization framework so that it is only visible to internal admin users.

Mounting the Web UI in a Rails Application

Below is an example of mounting the web UI in a Rails application using HTTP Basic authentication.

# config/routes.rb

mount Rack::Builder.new do
  use Rack::Auth::Basic do |username, password|
    username == ENV.fetch("AUTH_USER") && password == ENV.fetch("AUTH_PASSWORD")
  end
  run UltraSettings::RackApp
end, at: "/ultra_settings"

You can specify the color scheme by setting by providing the color_scheme option to the UltraSettings::RackApp class. The default color scheme is :light. You can also set the scheme to :dark or :system.

UltraSettings::RackApp.new(color_scheme: :dark)

Embedding the Settings View in Admin Tools

If you prefer to embed the settings view directly into your own admin tools or dashboard, you can use the UltraSettings::ApplicationView class to render the settings interface within your existing views:

<h1>Configuration</h1>

<%= UltraSettings::ApplicationView.new.render(select_class: "form-select", table_class: "table table-striped") %>

This approach allows for seamless integration of the settings UI into your application's admin interface, leveraging your existing authentication and authorization mechanisms. The settings are rendered in an HTML table with navigation handled by an HTML select element. You can specify the CSS classes for these elements and use your own stylesheets to customize the appearance.

You can also embed the view for individual configurations within your own views using the UltraSettings::ConfigurationView class if you want more customization:

<h1>My Service Settings</h1>

<%= UltraSettings::ConfigurationView.new(MyServiceConfiguration.instance).render(table_class: "table table-striped") %>

Testing With UltraSettings

When writing automated tests, you may need to override configuration settings to test different scenarios. UltraSettings provides the UltraSettings.override! method to temporarily change settings within a test block. Below are examples of how to override the TestConfiguration#foo value in a test.

# Override a configuration added on the global namespace.

# Note: you must have already added the configuration with UltraSettings.add(:test)
UltraSettings.override!(test: {foo: "bar"}) do
  expect(TestConfiguration.instance.foo).to eq "bar"
end

# or directly on the configuration class

TestConfiguration.override!(foo: "bar") do
  expect(TestConfiguration.instance.foo).to eq "bar"
end

# or on the instance itself

TestConfiguration.instance.override!(foo: "bar") do
  expect(TestConfiguration.instance.foo).to eq "bar"
end

RSpec Integration

If you are using RSpec, you can simplify overriding settings by setting up a global around hook. This hook will check for the presence of a :settings metadata key and apply the overrides automatically within the test block.

# RSpec setup
RSpec.configure do |config|
  config.around(:each, :settings) do |example|
    if example.metadata[:settings].is_a?(Hash)
      UltraSettings.override!(example.metadata[:settings]) do
        example.run
      end
    else
      example.run
    end
  end
end

With this setup, you can easily specify settings overrides within individual test blocks using metadata:

it 'has the settings I want', settings: {test: {foo: "bar"}} do
  expect(UltraSettings.test.foo).to eq("bar")
end

This approach keeps your tests clean and readable while allowing for flexible configuration management during testing.

Rollout percentages

A common usage of configuration is to control rollout of new features by specifying a percentage value and then testing if a random number is less than it. If you implement this pattern in your configuration, then you should use something like the consistent_random gem to ensure you are generating consistent values without your units of work.

class MyServiceConfiguration < UltraSettings::Configuration
  field :use_http2_percentage,
    type: :float,
    default: 0.0,
    description: "Rollout percentage for using the new HTTP/2 driver"

  # Using ConsistentRandom#rand instead of Kernel#rand to ensure that we
  # get the same result within a request and don't oscillate back and forth
  # every time we check if this is enabled.
  def use_http2?
    ConsistentRandom.new("MyServiceConfiguration.use_http2").rand < use_http2_percentage
  end
end

Installation

Add this line to your application's Gemfile:

gem "ultra_settings"

Then execute:

$ bundle

Or install it yourself as:

$ gem install ultra_settings

Contributing

Open a pull request on GitHub.

Please use the standardrb syntax and lint your code with standardrb --fix before submitting.

You can start a local rack server to test the web UI by running

bundle exec rackup

You can test with some setting set by setting environment variable used in the test configuration.

MY_SERVICE_HOST=host.example.com MY_SERVICE_TOKEN=secret bundle exec rspec

You can test dark mode by setting the COLOR_SCHEME environment variable.

COLOR_SCHEME=dark bundle exec rackup

License

The gem is available as open source under the terms of the MIT License.

About

UltraSettings is a Ruby gem that provides a flexible and documented approach to managing application configurations from multiple sources, including environment variables, runtime settings, and YAML files, with an optional web UI for easy documentation.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published