Skip to content

Commit

Permalink
Introduce typed config and drop RBS
Browse files Browse the repository at this point in the history
  • Loading branch information
bolshakov committed Oct 20, 2024
1 parent 716cd9f commit ee8feed
Show file tree
Hide file tree
Showing 28 changed files with 296 additions and 318 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,5 @@ jobs:
bundler-cache: true
- name: Lint
run: bundle exec rake standard
- name: Type Check
run: bundle exec steep check --severity-level=error
- name: Run Specs
run: bundle exec rake
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ source "https://rubygems.org"
# Specify your gem's dependencies in configx.gemspec
gemspec

gem "dry-struct"
gem "rake", "~> 13.0"
gem "rspec", "~> 3.0"
gem "standard", "~> 1.3"
gem "steep"
22 changes: 22 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,31 @@ GEM
deep_merge (1.2.2)
diff-lcs (1.5.1)
drb (2.2.1)
dry-core (1.0.1)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (1.1.0)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-struct (1.6.0)
dry-core (~> 1.0, < 2)
dry-types (>= 1.7, < 2)
ice_nine (~> 0.11)
zeitwerk (~> 2.6)
dry-types (1.7.2)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
ffi (1.16.3)
fileutils (1.7.2)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
json (2.7.1)
language_server-protocol (3.17.0.3)
lint_roller (1.1.0)
Expand Down Expand Up @@ -127,6 +148,7 @@ PLATFORMS

DEPENDENCIES
configx!
dry-struct
rake (~> 13.0)
rspec (~> 3.0)
standard (~> 1.3)
Expand Down
58 changes: 21 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ The resulting configuration will be:
config = ConfigX.load
config.api.enabled # => true
config.api.endpoint # => "https://example.com"
config.api.pretty_print # => "foobar"
config.api.access_token # => "foobar"
```

### Customizing Configuration
Expand Down Expand Up @@ -120,54 +120,38 @@ export SETTINGS__API__ENABLED=off

Environment variables have the highest priority and override the values from the configuration files.

### Builder Interface

When you don't need to load configuration from the predefined default locations, you can use the builder interface
which enables you to load configuration from any source:

1. Plain ruby Hash

```ruby
config = ConfigX.builder
.add_source({api: {enabled: true}})
.load

config.api.enabled # => true
```

2. YAML file
Sometimes you may want to just load configuration from a single source, for example, from for testing purposes:

```ruby
config = ConfigX.builder
.add_source('config/settings.yml')
.load
config = ConfigX.from({api: {enabled: true}})
config.api.enabled # => true
```

3. Environment variables
### Typed Config

```ruby
config = ConfigX.builder
.add_source(ENV, prefix: "SETTINGS", separator: "__")
.load
```
ConfigX allows you to define typed configuration using the `ConfigX::Config` class which uses the `dry-struct` library
under the hood.

You can also use the builder interface to load configuration from multiple sources:

```ruby
config = ConfigX.builder
.add_source({api: {enabled: true}})
.add_source('config/settings.yml')
.add_source(ENV, prefix: "SETTINGS", separator: "__")
.load
```ruby
class Config < ConfigX::Config
attribute :api do
attribute :enabled, Types::Bool.default(false)
attribute :endpoint, Types::String
attribute :access_token, Types::String
end
end
```

Sometimes you may want to just load configuration from a single source, for example, from for testing purposes:
You can then load the configuration using the `load` method:

```ruby
config = ConfigX.from({api: {enabled: true}})
config.api.enabled # => true
config = ConfigX.load(config_class: Config)
config.api.enabled #=> true
config.api.endpoint #=> "https://example.com"
```

Using typed configuration allows you to define the structure of the configuration and automatically cast values to the
specified types.
## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
Expand Down
11 changes: 7 additions & 4 deletions lib/config_x.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@
module ConfigX
class << self
# @api private
# @return [Zeitwerk::Loader]
def loader
@loader ||= Zeitwerk::Loader.for_gem.tap do |loader|
loader.ignore("#{__dir__}/configx.rb")
end
end

# @return [Configurable]
def load(...) = ConfigFactory.load(...)

def builder = Builder.new

# Loads config from the given source
# @example
# config = ConfigX.from({api: {endpoint: "http://example.com", enabled: true}})
# config.api.endpoint # => "http://example.com"
# config.api.enabled # => true
#
def from(source, **args) = builder.add_source(source, **args).load
# @return [ConfigX::UntypedConfig]
def from(source, **args) = builder.add_source(source, **args).load(config_class: UntypedConfig)

# @return [ConfigX::Builder]
private def builder = Builder.new
end
end

Expand Down
14 changes: 4 additions & 10 deletions lib/config_x/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ def source(source, **args)

# Loads the configuration.
#
# @return [Config]
# @return [UntypedConfig]
# @see #initialize
def load(...)
new(...).load
end
def load(...) = new.load(...)
end

# @example
Expand All @@ -49,12 +47,8 @@ def add_source(source, **args)
self
end

# Loads config in the following order:
# 1. Reads default config
# 2. Reads all the config files provided in the order
# 3. Reads environment variables
def load
Config.new(read_from_sources)
def load(config_class:)
config_class.new(read_from_sources)
end

def ==(other)
Expand Down
44 changes: 8 additions & 36 deletions lib/config_x/config.rb
Original file line number Diff line number Diff line change
@@ -1,43 +1,15 @@
# frozen_string_literal: true

require "ostruct"
begin
require "dry-struct"
rescue LoadError
raise "The dry-struct gem is required for ConfigX::TypedConfig"
end

module ConfigX
# The Config class extends OpenStruct to provide a flexible configuration object.
class Config < OpenStruct
include Configurable

# @param members [Hash] the initial configuration hash
# @raise [ArgumentError] if any of the keys are not convertible to strings
def initialize(members)
super({})

members.each do |key, value|
raise ArgumentError, "option keys should be strings" unless key.respond_to?(:to_s)

key = key.to_s
class Config < Dry::Struct
transform_keys(&:to_sym)

if value.is_a?(Hash)
value = self.class.new(value)
elsif value.is_a?(Array)
value = value.map do |element|
element.is_a?(Hash) ? self.class.new(element) : element
end
end

self[key] = value
end

freeze
end

# Converts the Config object to a hash.
#
# @return [Hash] the configuration as a hash
def to_h
each_pair.each_with_object({}) do |(key, value), hash|
hash[key] = value.is_a?(self.class) ? value.to_h : value
end
end
include Configurable
end
end
15 changes: 12 additions & 3 deletions lib/config_x/config_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def default_file_name = "settings"
# Default root directory for configuration
def default_config_root = "config"

# Default configuration class
# @return [Class<ConfigX::Configurable>]
def default_config_class = UntypedConfig

# Load method to initialize and load the configuration
def load(...) = new(...).load
end
Expand All @@ -43,23 +47,25 @@ def initialize(
env_separator: self.class.default_env_separator,
dir_name: self.class.default_dir_name,
file_name: self.class.default_file_name,
config_root: self.class.default_config_root
config_root: self.class.default_config_root,
config_class: self.class.default_config_class
)
@env = env
@env_prefix = env_prefix
@env_separator = env_separator
@dir_name = dir_name
@file_name = file_name
@config_root = config_root
@config_class = config_class
end

# Loads the configuration from the sources and additional sources.
# @param additional_sources [Array] additional sources to load configuration from.
# @return [Config] the loaded configuration.
# @return [UntypedConfig] the loaded configuration.
def load(*additional_sources)
(sources + additional_sources)
.reduce(Builder.new) { |builder, source| builder.add_source(source) }
.load
.load(config_class:)
end

private
Expand Down Expand Up @@ -109,5 +115,8 @@ def local_setting_files

# The file name for settings.
attr_reader :file_name

# The configuration class.
attr_reader :config_class
end
end
1 change: 1 addition & 0 deletions lib/config_x/configurable.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# frozen_string_literal: true

require "deep_merge/core"

module ConfigX
Expand Down
43 changes: 43 additions & 0 deletions lib/config_x/untyped_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

require "ostruct"

module ConfigX
# The Config class extends OpenStruct to provide a flexible configuration object.
class UntypedConfig < OpenStruct
include Configurable

# @param members [Hash] the initial configuration hash
# @raise [ArgumentError] if any of the keys are not convertible to strings
def initialize(members)
super({})

members.each do |key, value|
raise ArgumentError, "option keys should be strings" unless key.respond_to?(:to_s)

key = key.to_s

if value.is_a?(Hash)
value = self.class.new(value)
elsif value.is_a?(Array)
value = value.map do |element|
element.is_a?(Hash) ? self.class.new(element) : element
end
end

self[key] = value
end

freeze
end

# Converts the Config object to a hash.
#
# @return [Hash] the configuration as a hash
def to_h
each_pair.each_with_object({}) do |(key, value), hash|
hash[key] = value.is_a?(self.class) ? value.to_h : value
end
end
end
end
18 changes: 0 additions & 18 deletions sig/config_x.rbs

This file was deleted.

21 changes: 0 additions & 21 deletions sig/config_x/builder.rbs

This file was deleted.

Loading

0 comments on commit ee8feed

Please sign in to comment.