From cf12f98fec3516a97134b27215ef6affe21d8eef Mon Sep 17 00:00:00 2001 From: Tema Bolshakov Date: Wed, 5 Jun 2024 22:47:28 +0200 Subject: [PATCH] Initial commit --- .github/workflows/main.yml | 31 +++ .gitignore | 12 ++ .rspec | 3 + .standard.yml | 3 + Gemfile | 11 ++ Gemfile.lock | 136 +++++++++++++ LICENSE.txt | 21 +++ README.md | 178 ++++++++++++++++++ Rakefile | 10 + Steepfile | 31 +++ bin/console | 11 ++ bin/setup | 8 + configx.gemspec | 43 +++++ lib/config_x.rb | 29 +++ lib/config_x/builder.rb | 57 ++++++ lib/config_x/config.rb | 44 +++++ lib/config_x/config_factory.rb | 113 +++++++++++ lib/config_x/env_source.rb | 36 ++++ lib/config_x/file_source.rb | 27 +++ lib/config_x/hash_source.rb | 20 ++ lib/config_x/source.rb | 7 + lib/config_x/version.rb | 5 + lib/config_x/yaml_source.rb | 19 ++ lib/configx.rb | 3 + sig/config_x.rbs | 18 ++ sig/config_x/builder.rbs | 21 +++ sig/config_x/config.rbs | 13 ++ sig/config_x/config_factory.rbs | 55 ++++++ sig/config_x/env_source.rbs | 11 ++ sig/config_x/file_source.rbs | 7 + sig/config_x/hash_source.rbs | 8 + sig/config_x/source.rbs | 8 + sig/config_x/yaml_source.rbs | 10 + sig/shims/deep_merge.rbs | 7 + sig/shims/env.rbs | 17 ++ sig/shims/zeitwerk/loader.rbs | 8 + spec/config_x/builder_spec.rb | 105 +++++++++++ spec/config_x/config_factory_spec.rb | 52 +++++ spec/config_x/config_spec.rb | 87 +++++++++ spec/config_x/env_source_spec.rb | 76 ++++++++ spec/config_x/file_source_spec.rb | 42 +++++ spec/config_x/hash_source_spec.rb | 16 ++ spec/config_x/source_spec.rb | 4 + spec/config_x/yaml_source_spec.rb | 23 +++ spec/config_x_spec.rb | 17 ++ spec/configx_spec.rb | 7 + spec/spec_helper.rb | 15 ++ spec/support/config/settings.local.yml | 5 + spec/support/config/settings.yml | 7 + .../config/settings/development.local.yml | 4 + spec/support/config/settings/development.yml | 6 + 51 files changed, 1507 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .standard.yml create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100644 Steepfile create mode 100755 bin/console create mode 100755 bin/setup create mode 100644 configx.gemspec create mode 100644 lib/config_x.rb create mode 100644 lib/config_x/builder.rb create mode 100644 lib/config_x/config.rb create mode 100644 lib/config_x/config_factory.rb create mode 100644 lib/config_x/env_source.rb create mode 100644 lib/config_x/file_source.rb create mode 100644 lib/config_x/hash_source.rb create mode 100644 lib/config_x/source.rb create mode 100644 lib/config_x/version.rb create mode 100644 lib/config_x/yaml_source.rb create mode 100644 lib/configx.rb create mode 100644 sig/config_x.rbs create mode 100644 sig/config_x/builder.rbs create mode 100644 sig/config_x/config.rbs create mode 100644 sig/config_x/config_factory.rbs create mode 100644 sig/config_x/env_source.rbs create mode 100644 sig/config_x/file_source.rbs create mode 100644 sig/config_x/hash_source.rbs create mode 100644 sig/config_x/source.rbs create mode 100644 sig/config_x/yaml_source.rbs create mode 100644 sig/shims/deep_merge.rbs create mode 100644 sig/shims/env.rbs create mode 100644 sig/shims/zeitwerk/loader.rbs create mode 100644 spec/config_x/builder_spec.rb create mode 100644 spec/config_x/config_factory_spec.rb create mode 100644 spec/config_x/config_spec.rb create mode 100644 spec/config_x/env_source_spec.rb create mode 100644 spec/config_x/file_source_spec.rb create mode 100644 spec/config_x/hash_source_spec.rb create mode 100644 spec/config_x/source_spec.rb create mode 100644 spec/config_x/yaml_source_spec.rb create mode 100644 spec/config_x_spec.rb create mode 100644 spec/configx_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/config/settings.local.yml create mode 100644 spec/support/config/settings.yml create mode 100644 spec/support/config/settings/development.local.yml create mode 100644 spec/support/config/settings/development.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c92fbe1 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,31 @@ +name: Ruby + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.3.1' + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Lint + run: bundle exec rake standard + - name: Type Check + run: bundle exec steep check + - name: Run Specs + run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d24edd --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status + diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..deacdfb --- /dev/null +++ b/.standard.yml @@ -0,0 +1,3 @@ +# For available configuration options, see: +# https://github.com/standardrb/standard +ruby_version: 3.3 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..bbeb66e --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in configx.gemspec +gemspec + +gem "rake", "~> 13.0" +gem "rspec", "~> 3.0" +gem "standard", "~> 1.3" +gem "steep" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..846f85f --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,136 @@ +PATH + remote: . + specs: + configx (0.1.0) + deep_merge + zeitwerk + +GEM + remote: https://rubygems.org/ + specs: + abbrev (0.1.2) + activesupport (7.1.3.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.8) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + csv (3.3.0) + deep_merge (1.2.2) + diff-lcs (1.5.1) + drb (2.2.1) + ffi (1.16.3) + fileutils (1.7.2) + i18n (1.14.5) + concurrent-ruby (~> 1.0) + json (2.7.1) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.6.0) + minitest (5.23.0) + mutex_m (0.2.0) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + racc (1.7.3) + rainbow (3.1.1) + rake (13.2.0) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + rbs (3.4.4) + abbrev + regexp_parser (2.9.0) + rexml (3.2.6) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.1) + rubocop (1.62.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) + rubocop-performance (1.20.2) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (1.13.0) + securerandom (0.3.1) + standard (1.35.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.62.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.3) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.3.1) + lint_roller (~> 1.1) + rubocop-performance (~> 1.20.2) + steep (1.6.0) + activesupport (>= 5.1) + concurrent-ruby (>= 1.1.10) + csv (>= 3.0.9) + fileutils (>= 1.1.0) + json (>= 2.1.0) + language_server-protocol (>= 3.15, < 4.0) + listen (~> 3.0) + logger (>= 1.3.0) + parser (>= 3.1) + rainbow (>= 2.2.2, < 4.0) + rbs (>= 3.1.0) + securerandom (>= 0.1) + strscan (>= 1.0.0) + terminal-table (>= 2, < 4) + strscan (3.1.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + zeitwerk (2.6.13) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + configx! + rake (~> 13.0) + rspec (~> 3.0) + standard (~> 1.3) + steep + +BUNDLED WITH + 2.5.10 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..45a8420 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Tëma Bolshakov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..242e2ad --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# ⚙️ConfigX + +ConfigX is a simple configuration library that you can use with your application or libraries. + +ConfigX is NOT that kind of library that allows you configuring any Ruby object, instead +it takes a different approach. It reads configuration from YAML files and environment variables +and load it into a ruby object. It's highly influenced by the [config] gem, but it does +not define a global objects and allows you having multiple independent configurations. + +## Installation + +Install the gem and add to the application's Gemfile by executing: + + $ bundle add configx + +If bundler is not being used to manage dependencies, install the gem by executing: + + $ gem install configx + +## Usage + +Start using the library as simple as loading configuration from default locations: + +```ruby +config = ConfigX.load +``` + +It loads configuration from the following locations in the specified order: + +1. `config/settings.yml` +2. `config/settings/production.yml` +3. `config/settings.local.yml` +4. `config/settings/production.local.yml` +5. Environment variables + +All the configuration source are merged an intuitive way. For instance, + +* **config/settings.yml** + +```yaml +--- +api: + enabled: false + endpoint: https://example.com + access_token: +``` + +* **config/settings/production.yml** + +```yaml +--- +api: + enabled: true +``` + +* Environment Variables + +``` +export SETTINGS__API__ACCESS_TOKEN=foobar +``` + +The resulting configuration will be: + +```ruby +config = ConfigX.load +config.api.enabled # => true +config.api.endpoint # => "https://example.com" +config.api.pretty_print # => "foobar" +``` + +### Customizing Configuration + +You can customize the configuration by passing optional arguments to the `load` method: + +```ruby +ConfigX.load( + "development", + dir_name: 'settings', + file_name: 'settings', + config_root: 'config', + env_prefix: 'SETTINGS', + env_separator: '__' +) +``` + +The first four options, `env` (positional), `dir_name`, `file_name`, and `config_root` are used to specify +the configuration files to read: + +1. `{config_root}/{file_name}.yml` +2. `{config_root}/{file_name}/{env}.yml` +3. `{config_root}/{file_name}.local.yml` +4. `{config_root}/{file_name}/{env}.local.yml` + + +The `env_prefix` and `env_separator` options are used to specify how the environment variables should be constructed. In +the above example, they start from `SETTINGS` and use `__` as a separator. + +For instance, the following environment variable: + +``` +export SETTINGS__API__ACCESS_TOKEN=foobar +``` + +corresponds to the following configuration: + +```yaml +--- +api: + access_token: foobar +``` + +You can also pass boolean value to environment variables using convenient YAML syntax: + +```sh +export SETTINGS__API__ENABLED=true +export SETTINGS__API__ENABLED=false +export SETTINGS__API__ENABLED=on +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 + +```ruby +config = ConfigX.builder + .add_source('config/settings.yml') + .load +``` + +3. Environment variables + +```ruby +config = ConfigX.builder + .add_source(ENV, prefix: "SETTINGS", separator: "__") + .load +``` + +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 +``` + +## 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. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/bolshakov/configx. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +[config]: https://rubygems.org/gems/config diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..df40677 --- /dev/null +++ b/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "standard/rake" + +task default: %i[spec standard] diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..e58ad30 --- /dev/null +++ b/Steepfile @@ -0,0 +1,31 @@ +# D = Steep::Diagnostic +# +target :lib do + signature "sig" + + check "lib" # Directory name + # check "Gemfile" # File name + # check "app/models/**/*.rb" # Glob + # # ignore "lib/templates/*.rb" + # + library "yaml" + library "pathname" + + # library "strong_json" # Gems + # + # # configure_code_diagnostics(D::Ruby.default) # `default` diagnostics setting (applies by default) + # # configure_code_diagnostics(D::Ruby.strict) # `strict` diagnostics setting + # # configure_code_diagnostics(D::Ruby.lenient) # `lenient` diagnostics setting + # # configure_code_diagnostics(D::Ruby.silent) # `silent` diagnostics setting + # # configure_code_diagnostics do |hash| # You can setup everything yourself + # # hash[D::Ruby::NoMethod] = :information + # # end +end + +# target :test do +# signature "sig", "sig-private" +# +# check "test" +# +# # library "pathname" # Standard libraries +# end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..47e1b0c --- /dev/null +++ b/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'configx' + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require 'irb' +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/configx.gemspec b/configx.gemspec new file mode 100644 index 0000000..d947270 --- /dev/null +++ b/configx.gemspec @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "lib/config_x/version" + +Gem::Specification.new do |spec| + spec.name = "configx" + spec.version = ConfigX::VERSION + spec.authors = ["Tëma Bolshakov"] + spec.email = ["tema@bolshakov.dev"] + + spec.summary = "Configuration simplified" + spec.description = <<~DESC + ConfigX is a Ruby library for configuration management. It provides battle-tested defaults + and an intuitive interface for managing settings from YAML files and environment variables. + It also offers flexibility for developers to build their own configurations. + DESC + spec.homepage = "https://github.com/bolshakov/configx" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.0.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + gemspec = File.basename(__FILE__) + spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + ls.readlines("\x0", chomp: true).reject do |f| + (f == gemspec) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "deep_merge" + spec.add_dependency "zeitwerk" + + # For more information and examples about making a new gem, check out our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/lib/config_x.rb b/lib/config_x.rb new file mode 100644 index 0000000..3e82c8e --- /dev/null +++ b/lib/config_x.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "zeitwerk" +require_relative "config_x/version" + +module ConfigX + class << self + # @api private + def loader + @loader ||= Zeitwerk::Loader.for_gem.tap do |loader| + loader.ignore("#{__dir__}/configx.rb") + end + end + + 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 + end +end + +ConfigX.loader.setup diff --git a/lib/config_x/builder.rb b/lib/config_x/builder.rb new file mode 100644 index 0000000..ddb3d99 --- /dev/null +++ b/lib/config_x/builder.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "deep_merge/core" + +module ConfigX + class Builder + class << self + def source(source, **args) + case source + in Source then source + in Hash then HashSource.new(source) + in String then FileSource.new(source) + in Pathname then FileSource.new(source) + in ENV then EnvSource.new(ENV, **args) + end + end + + # @see #initialize + def load(...) + new(...).load + end + end + + # @example + # ConfigX.new("production").load + # ConfigX.new.load + # + def initialize + @sources = [] + end + + attr_reader :sources + + def add_source(source, **args) + sources << self.class.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) + end + + def ==(other) + other.is_a?(self.class) && other.sources == sources + end + + private def read_from_sources + sources.each_with_object({}) do |source, config| + DeepMerge.deep_merge!(source.load, config, overwrite_arrays: true) + end + end + end +end diff --git a/lib/config_x/config.rb b/lib/config_x/config.rb new file mode 100644 index 0000000..4c0533c --- /dev/null +++ b/lib/config_x/config.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "ostruct" +require "deep_merge/core" + +module ConfigX + class Config < OpenStruct + 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.freeze + end + + freeze + end + + def with_fallback(fallback) + DeepMerge.deep_merge!( + to_h, + fallback.to_h, + overwrite_arrays: true + ).then { Config.new(_1) } + end + + def to_h + each_pair.each_with_object({}) do |(key, value), hash| + hash[key] = value.is_a?(Config) ? value.to_h : value + end + end + end +end diff --git a/lib/config_x/config_factory.rb b/lib/config_x/config_factory.rb new file mode 100644 index 0000000..97d5826 --- /dev/null +++ b/lib/config_x/config_factory.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module ConfigX + # This class is responsible for loading configuration settings for an application. + # It follows a specific order in loading these settings: + # 1. Reads default config + # 2. Reads all the config files provided in the order + # 3. Reads environment variables + class ConfigFactory + class << self + # Default environment variable prefix + def default_env_prefix = "SETTINGS" + + # Default environment variable separator + def default_env_separator = "__" + + # Default directory name for environment-specific settings + def default_dir_name = "settings" + + # Default environment name + def default_env = "production" + + # Default config file name + def default_file_name = "settings" + + # Default root directory for configuration + def default_config_root = "config" + + # Load method to initialize and load the configuration + def load(...) = new(...).load + end + + # Initializes a new instance of the ConfigFactory class. + # @param env [String] the environment name. + # @param env_prefix [String] the prefix for environment variables. + # @param env_separator [String] the separator for environment variables. + # @param dir_name [String] the directory name for settings. + # @param file_name [String] the file name for settings. + # @param config_root [String] the root directory for configuration. + def initialize( + env = self.class.default_env, + env_prefix: self.class.default_env_prefix, + 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 + ) + @env = env + @env_prefix = env_prefix + @env_separator = env_separator + @dir_name = dir_name + @file_name = file_name + @config_root = config_root + 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. + def load(*additional_sources) + (sources + additional_sources) + .reduce(Builder.new) { |builder, source| builder.add_source(source) } + .load + end + + private + + # Returns the sources from which to load the configuration. + # @return [Array] the sources. + def sources + [ + *setting_files, + Builder.source(ENV, prefix: env_prefix, separator: env_separator) + ] + end + + # Returns the setting files. + # @return [Array] the setting files. + def setting_files + [ + File.join(config_root, "#{file_name}.yml"), + File.join(config_root, dir_name, "#{env}.yml"), + *local_setting_files + ].freeze + end + + # Returns the local setting files. + # @return [Array] the local setting files. + def local_setting_files + [ + (File.join(config_root, "#{file_name}.local.yml") if env != "test"), + File.join(config_root, dir_name, "#{env}.local.yml") + ].compact + end + + # The root directory for configuration. + attr_reader :config_root + + # The directory name for environment-specific settings. + attr_reader :dir_name + + # The environment name. + attr_reader :env + + # The prefix for environment variables. + attr_reader :env_prefix + + # The separator for environment variables. + attr_reader :env_separator + + # The file name for settings. + attr_reader :file_name + end +end diff --git a/lib/config_x/env_source.rb b/lib/config_x/env_source.rb new file mode 100644 index 0000000..3c7cfa8 --- /dev/null +++ b/lib/config_x/env_source.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "deep_merge/core" +require "yaml" + +module ConfigX + class EnvSource < HashSource + def initialize(env, prefix:, separator:) + @env = env + @prefix = prefix + @separator = separator + end + + def ==(other) + other.is_a?(self.class) && + source == other.source && + prefix == other.prefix && + separator == other.separator + end + + protected + + def source + env.each_with_object({}) do |(key, value), config| + next unless key.start_with?(prefix + separator) + + Array(key.split(separator)[1..]) + .reverse_each + .reduce(YAML.load(value)) { |acc, k| {k.downcase => acc} } + .tap { DeepMerge.deep_merge!(_1, config) } + end + end + + attr_reader :env, :prefix, :separator + end +end diff --git a/lib/config_x/file_source.rb b/lib/config_x/file_source.rb new file mode 100644 index 0000000..75299cc --- /dev/null +++ b/lib/config_x/file_source.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "yaml" + +module ConfigX + class FileSource < Source + def initialize(path) + @path = path + end + + def load + if path && File.exist?(path) + YamlSource.new(File.read(path.to_s)).load + else + {} + end + end + + def ==(other) + other.is_a?(self.class) && other.path == path + end + + protected + + attr_reader :path + end +end diff --git a/lib/config_x/hash_source.rb b/lib/config_x/hash_source.rb new file mode 100644 index 0000000..dba2a6a --- /dev/null +++ b/lib/config_x/hash_source.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "yaml" + +module ConfigX + class HashSource < Source + attr_reader :source + protected :source + + def initialize(source) + @source = source + end + + def load = source + + def ==(other) + other.is_a?(self.class) && source == other.source + end + end +end diff --git a/lib/config_x/source.rb b/lib/config_x/source.rb new file mode 100644 index 0000000..023012d --- /dev/null +++ b/lib/config_x/source.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ConfigX + class Source + def load = raise NotImplementedError + end +end diff --git a/lib/config_x/version.rb b/lib/config_x/version.rb new file mode 100644 index 0000000..8fe6fb1 --- /dev/null +++ b/lib/config_x/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module ConfigX + VERSION = "0.1.0" +end diff --git a/lib/config_x/yaml_source.rb b/lib/config_x/yaml_source.rb new file mode 100644 index 0000000..cd10169 --- /dev/null +++ b/lib/config_x/yaml_source.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "yaml" + +module ConfigX + class YamlSource < Source + def initialize(source) + @source = source + end + + def load + YAML.load(source) || {} + end + + private + + attr_reader :source + end +end diff --git a/lib/configx.rb b/lib/configx.rb new file mode 100644 index 0000000..e9338d7 --- /dev/null +++ b/lib/configx.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "config_x" diff --git a/sig/config_x.rbs b/sig/config_x.rbs new file mode 100644 index 0000000..f2f3428 --- /dev/null +++ b/sig/config_x.rbs @@ -0,0 +1,18 @@ +module ConfigX + VERSION: String + + def self.builder: -> Builder + def self.from: (Builder::source, **untyped) -> Config + + def self.load: ( + String env, + env_prefix: String, + env_separator: String, + dir_name: String, + file_name: String, + config_root: String + ) -> Config + + + def self.loader: -> Zeitwerk::Loader +end diff --git a/sig/config_x/builder.rbs b/sig/config_x/builder.rbs new file mode 100644 index 0000000..ee066d8 --- /dev/null +++ b/sig/config_x/builder.rbs @@ -0,0 +1,21 @@ +module ConfigX + class Builder + type source = Source | Hash[untyped, untyped] | String | Pathname | Object + + def self.load: -> Config + def self.source: [T < Source] (source, **untyped) -> T + + def initialize: -> void + + attr_reader sources: Array[Source] + + + def add_source: (source, **untyped) -> self + + def load: -> Config + + private + + def read_from_sources: -> Hash[untyped, untyped] + end +end diff --git a/sig/config_x/config.rbs b/sig/config_x/config.rbs new file mode 100644 index 0000000..cd88516 --- /dev/null +++ b/sig/config_x/config.rbs @@ -0,0 +1,13 @@ +module ConfigX + class Config + def initialize: (Hash[untyped, untyped]) -> void + + def to_h: -> Hash[untyped, untyped] + + def with_fallback: (Config) -> Config + + def each_pair: -> Enumerable[[untyped, untyped]] + + def []=: (untyped, untyped) -> untyped + end +end diff --git a/sig/config_x/config_factory.rbs b/sig/config_x/config_factory.rbs new file mode 100644 index 0000000..ae5c8f5 --- /dev/null +++ b/sig/config_x/config_factory.rbs @@ -0,0 +1,55 @@ +module ConfigX + class ConfigFactory + def self.default_config_root: -> String + + def self.default_dir_name: -> String + + def self.default_env_prefix: -> String + + def self.default_env_separator: -> String + + def self.default_env: -> String + + def self.default_file_name: -> String + + def self.load: ( + String env, + env_prefix: String, + env_separator: String, + dir_name: String, + file_name: String, + config_root: String + ) -> Config + + def initialize: ( + String env, + env_prefix: String, + env_separator: String, + dir_name: String, + file_name: String, + config_root: String + ) -> void + + def load: -> Config + + private + + def local_setting_files: -> Array[String] + + def setting_files: -> Array[String] + + attr_reader config_root: String + + attr_reader dir_name: String + + attr_reader env_prefix: String + + attr_reader env_separator: String + + attr_reader env: String + + attr_reader file_name: String + + def sources: -> Array[Builder::source] + end +end diff --git a/sig/config_x/env_source.rbs b/sig/config_x/env_source.rbs new file mode 100644 index 0000000..7a93dc3 --- /dev/null +++ b/sig/config_x/env_source.rbs @@ -0,0 +1,11 @@ +module ConfigX + class EnvSource < HashSource + type env = Hash[String, String] + + def initialize: (env, prefix: String, separator: String) -> void + + attr_reader env: env + attr_reader prefix: String + attr_reader separator: String + end +end diff --git a/sig/config_x/file_source.rbs b/sig/config_x/file_source.rbs new file mode 100644 index 0000000..1a7da56 --- /dev/null +++ b/sig/config_x/file_source.rbs @@ -0,0 +1,7 @@ +module ConfigX + class FileSource < Source + def initialize: (String | Pathname) -> void + + attr_reader path: String | Pathname + end +end diff --git a/sig/config_x/hash_source.rbs b/sig/config_x/hash_source.rbs new file mode 100644 index 0000000..a606e4c --- /dev/null +++ b/sig/config_x/hash_source.rbs @@ -0,0 +1,8 @@ + +module ConfigX + class HashSource < Source + def initialize: (Source::config_hash) -> void + + attr_reader source: Source::config_hash + end +end diff --git a/sig/config_x/source.rbs b/sig/config_x/source.rbs new file mode 100644 index 0000000..3c98670 --- /dev/null +++ b/sig/config_x/source.rbs @@ -0,0 +1,8 @@ + +module ConfigX + class Source + type config_hash = Hash[untyped, untyped] + + def load: -> config_hash + end +end diff --git a/sig/config_x/yaml_source.rbs b/sig/config_x/yaml_source.rbs new file mode 100644 index 0000000..5ea46b1 --- /dev/null +++ b/sig/config_x/yaml_source.rbs @@ -0,0 +1,10 @@ + +module ConfigX + class YamlSource < Source + def initialize: (String) -> void + + private + + attr_reader source: String + end +end diff --git a/sig/shims/deep_merge.rbs b/sig/shims/deep_merge.rbs new file mode 100644 index 0000000..1927aeb --- /dev/null +++ b/sig/shims/deep_merge.rbs @@ -0,0 +1,7 @@ +module DeepMerge + def self.deep_merge!: [K1, V1, K2, V2] ( + Hash[K1, V1] source, + Hash[K2, V2] destination, + ?overwrite_arrays: bool + ) -> Hash[K1 | K2, V1 | V2] +end diff --git a/sig/shims/env.rbs b/sig/shims/env.rbs new file mode 100644 index 0000000..ce09240 --- /dev/null +++ b/sig/shims/env.rbs @@ -0,0 +1,17 @@ +# module ENV +# extend Enumerable[[String, String]] +# +# # def self.[]: (::String) -> (::String | nil) +# # def self.[]=: (::String, ::String) -> ::String +# # def self.fetch: [A] (key: ::String, default: A) -> (A | ::String) +# # def self.fetch: (key: ::String) ?{ ::String -> A } -> (::String | A) +# # def self.delete: (::String) -> (::String | nil) +# # def self.key?: (::String) -> bool +# # def self.clear: () -> void +# # def self.to_h: () -> Hash[::String, ::String] +# # def self.each: () { (::String, ::String) -> void } -> void +# # def self.each_key: () { (::String) -> void } -> void +# # def self.each_value: () { (::String) -> void } -> void +# # def self.keys: () -> Array[::String] +# # def self.values: () -> Array[::String] +# end diff --git a/sig/shims/zeitwerk/loader.rbs b/sig/shims/zeitwerk/loader.rbs new file mode 100644 index 0000000..bcbd312 --- /dev/null +++ b/sig/shims/zeitwerk/loader.rbs @@ -0,0 +1,8 @@ +module Zeitwerk + class Loader + def self.for_gem: -> instance + + def ignore: (String) -> void + def setup: -> void + end +end diff --git a/spec/config_x/builder_spec.rb b/spec/config_x/builder_spec.rb new file mode 100644 index 0000000..7b17857 --- /dev/null +++ b/spec/config_x/builder_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX::Builder do + describe ".source" do + subject { described_class.source(source) } + + context "when an instance of Source" do + let(:source) { ConfigX::YamlSource.new("") } + + it { is_expected.to be(source) } + end + + context "when an instance of Hash" do + let(:source) { {"foo" => "bar"} } + + it { is_expected.to eq(ConfigX::HashSource.new(source)) } + end + + context "when an instance of String" do + let(:source) { file.path } + let(:file) { Tempfile.new } + + after { file.unlink } + + it { is_expected.to eq(ConfigX::FileSource.new(source)) } + end + + context "when an instance of Pathname" do + let(:source) { Pathname(file.path) } + let(:file) { Tempfile.new } + + after { file.unlink } + + it { is_expected.to eq(ConfigX::FileSource.new(source)) } + end + + context "when ENV" do + subject { described_class.source(source, prefix:, separator:) } + let(:source) { ENV } + let(:prefix) { SecureRandom.uuid } + let(:separator) { SecureRandom.uuid } + + it do + is_expected.to eq(ConfigX::EnvSource.new(source, prefix:, separator:)) + end + end + end + + describe "#add_source" do + let(:config) { described_class.new } + let(:hash_source) { {"foo" => "bar"} } + + it "adds source" do + expect do + config.add_source(hash_source) + end.to change { config.sources }.to(include(ConfigX::HashSource.new(hash_source))) + end + end + + describe ".load" do + subject(:config) do + described_class + .new + .add_source(source1) + .add_source(source2) + .load + end + + let(:source1) do + { + "foo" => "bar", + "bar" => "baz", + "array" => [1, 2, 3], + "nested" => { + "one" => "one", + "tow" => 2 + } + } + end + + let(:source2) do + { + "bar" => 42, + "array" => [3, 4, 5], + "nested" => { + "one" => 1 + } + } + end + + it "merge sources" do + is_expected.to eq( + ConfigX::Config.new({ + "foo" => "bar", + "bar" => 42, + "array" => [3, 4, 5], + "nested" => { + "one" => 1, + "tow" => 2 + } + }) + ) + end + end +end diff --git a/spec/config_x/config_factory_spec.rb b/spec/config_x/config_factory_spec.rb new file mode 100644 index 0000000..0d85d4e --- /dev/null +++ b/spec/config_x/config_factory_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX::ConfigFactory do + shared_examples "option with default" do |option, default:| + describe "ConfigX::ConfigFactory##{option}" do + context "when value is not provided" do + subject { config_factory.__send__(option) } + + let(:config_factory) { described_class.new } + + it "fallbacks to default value `#{default}`" do + is_expected.to eq(default) + end + end + end + end + + include_examples "option with default", :env, default: "production" + include_examples "option with default", :env_prefix, default: "SETTINGS" + include_examples "option with default", :env_separator, default: "__" + include_examples "option with default", :dir_name, default: "settings" + include_examples "option with default", :file_name, default: "settings" + + describe ".load" do + context "when config files are present" do + subject(:config_factory) do + described_class.new( + "development", + config_root: "spec/support/config" + ).load + end + + around do |example| + settings__three = ENV["SETTINGS__THREE"] + ENV["SETTINGS__THREE"] = "environment variable" + example.run + ENV["SETTINGS__THREE"] = settings__three + end + + it "loads config files in order" do + is_expected.to have_attributes( + one: "settings/development.local.yml", + two: "settings/development.local.yml", + three: "environment variable", + four: "settings.local.yml", + five: "settings/development.yml", + six: "settings.yml" + ) + end + end + end +end diff --git a/spec/config_x/config_spec.rb b/spec/config_x/config_spec.rb new file mode 100644 index 0000000..713cb2e --- /dev/null +++ b/spec/config_x/config_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX::Config do + describe ".new" do + subject(:options) { described_class.new(hash) } + + context "when plain hash" do + let(:hash) { {"foo" => "bar", "bar" => 42} } + + it { is_expected.to have_attributes(foo: "bar", bar: 42) } + end + + context "when nested hash" do + let(:hash) do + {"foo" => {"bar" => 42}} + end + + it "converts it to options" do + is_expected.to have_attributes( + foo: have_attributes(bar: 42) + ) + end + end + + context "when hash inside an array" do + let(:hash) do + {"foo" => [664, {"bar" => 42}]} + end + + it "converts it to options" do + is_expected.to have_attributes( + foo: [ + 664, + have_attributes(bar: 42) + ] + ) + end + end + end + + describe "#to_h" do + subject { options.to_h } + + let(:options) { described_class.new(hash) } + let(:hash) do + { + foo: "bar", + bar: { + nested_option: 42, + nested: {opt: 664} + } + } + end + + it { is_expected.to eq(hash) } + end + + describe "#with_fallback" do + subject { config.with_fallback(fallback) } + let(:config) do + described_class.new( + { + foo: "bar", + bar: {baz: 42} + } + ) + end + + let(:fallback) do + described_class.new( + { + foo: "default foo", + baz: 664, + bar: {default: "default"} + } + ) + end + + it "defaults missing options to fallback" do + is_expected.to have_attributes( + foo: "bar", + baz: 664, + bar: have_attributes(baz: 42, default: "default") + ) + end + end +end diff --git a/spec/config_x/env_source_spec.rb b/spec/config_x/env_source_spec.rb new file mode 100644 index 0000000..5e4cf16 --- /dev/null +++ b/spec/config_x/env_source_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX::EnvSource do + subject(:config) { source.load } + + let(:source) { described_class.new(env, prefix: "MY_CONFIG", separator: "__") } + + context "without configuration" do + let(:env) { {"CONFIG__FOO" => "bar"} } + + it "reads nothing" do + is_expected.to be_empty + end + end + + context "with top level configuration" do + let(:env) do + { + "MY_CONFIG__FOO" => "bar", + "MY_CONFIG__BAR" => "baz" + } + end + + it "reads top-level configuration" do + is_expected.to eq({"foo" => "bar", "bar" => "baz"}) + end + end + + context "with nested configuration" do + let(:env) do + { + "MY_CONFIG__FOO__BAR__BAZ" => "one", + "MY_CONFIG__FOO__BAR__BAT" => "two", + "MY_CONFIG__FOO__BAZ" => "three" + } + end + + it "reads configuration" do + is_expected.to eq({ + "foo" => { + "bar" => { + "baz" => "one", + "bat" => "two" + }, + "baz" => "three" + } + }) + end + end + + context "with typed configuration" do + let(:env) do + { + "MY_CONFIG__ONE" => "on", + "MY_CONFIG__TWO" => "off", + "MY_CONFIG__THREE" => "true", + "MY_CONFIG__FOUR" => "false", + "MY_CONFIG__FIVE" => "null", + "MY_CONFIG__SIX" => "42", + "MY_CONFIG__SEVEN" => "64.4" + } + end + + it "parses types" do + is_expected.to eq({ + "one" => true, + "two" => false, + "three" => true, + "four" => false, + "five" => nil, + "six" => 42, + "seven" => 64.4 + }) + end + end +end diff --git a/spec/config_x/file_source_spec.rb b/spec/config_x/file_source_spec.rb new file mode 100644 index 0000000..56e8ac9 --- /dev/null +++ b/spec/config_x/file_source_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "tempfile" + +RSpec.describe ConfigX::FileSource do + subject(:config) { described_class.new(path).load } + + context "when file exists" do + let(:path) { file.path } + let(:file) { Tempfile.new } + let(:source_string) { <<~YAML } + --- + one: off + two: true + three: null + four: + bar: 42 + YAML + + before do + file.write(source_string) + file.rewind + end + + it "loads file into hash" do + is_expected.to eq({ + "one" => false, + "two" => true, + "three" => nil, + "four" => {"bar" => 42} + }) + end + end + + context "when file does not exist" do + let(:path) { SecureRandom.uuid } + + it "returns empty hash" do + is_expected.to be_empty + end + end +end diff --git a/spec/config_x/hash_source_spec.rb b/spec/config_x/hash_source_spec.rb new file mode 100644 index 0000000..4e95597 --- /dev/null +++ b/spec/config_x/hash_source_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX::HashSource do + let(:config) { described_class.new(hash).load } + + let(:hash) do + { + "one" => false, + "two" => true, + "three" => nil, + "four" => {"bar" => 42} + } + end + + it { expect(config).to eq(hash) } +end diff --git a/spec/config_x/source_spec.rb b/spec/config_x/source_spec.rb new file mode 100644 index 0000000..03df083 --- /dev/null +++ b/spec/config_x/source_spec.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX::Source do +end diff --git a/spec/config_x/yaml_source_spec.rb b/spec/config_x/yaml_source_spec.rb new file mode 100644 index 0000000..55088e0 --- /dev/null +++ b/spec/config_x/yaml_source_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX::YamlSource do + let(:config) { described_class.new(source_string).load } + + let(:source_string) { <<~YAML } + --- + one: off + two: true + three: null + four: + bar: 42 + YAML + + it "loads string into hash" do + expect(config).to eq({ + "one" => false, + "two" => true, + "three" => nil, + "four" => {"bar" => 42} + }) + end +end diff --git a/spec/config_x_spec.rb b/spec/config_x_spec.rb new file mode 100644 index 0000000..29363b2 --- /dev/null +++ b/spec/config_x_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX do + describe ".builder" do + subject(:builder) { described_class.builder } + + it { is_expected.to eq(ConfigX::Builder.new) } + end + + describe ".from" do + subject(:from) { described_class.from(source) } + + let(:source) { {foo: {bar: 42}} } + + it { is_expected.to have_attributes(foo: have_attributes(bar: 42)) } + end +end diff --git a/spec/configx_spec.rb b/spec/configx_spec.rb new file mode 100644 index 0000000..5974ba9 --- /dev/null +++ b/spec/configx_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe ConfigX do + it "has a version number" do + expect(ConfigX::VERSION).not_to be nil + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..63dc6b9 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "configx" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end diff --git a/spec/support/config/settings.local.yml b/spec/support/config/settings.local.yml new file mode 100644 index 0000000..7354c02 --- /dev/null +++ b/spec/support/config/settings.local.yml @@ -0,0 +1,5 @@ +--- +one: settings.local.yml +two: settings.local.yml +three: settings.local.yml +four: settings.local.yml diff --git a/spec/support/config/settings.yml b/spec/support/config/settings.yml new file mode 100644 index 0000000..91f0fee --- /dev/null +++ b/spec/support/config/settings.yml @@ -0,0 +1,7 @@ +--- +one: settings.yml +two: settings.yml +three: settings.yml +four: settings.yml +five: settings.yml +six: settings.yml diff --git a/spec/support/config/settings/development.local.yml b/spec/support/config/settings/development.local.yml new file mode 100644 index 0000000..1b878c5 --- /dev/null +++ b/spec/support/config/settings/development.local.yml @@ -0,0 +1,4 @@ +--- +one: settings/development.local.yml +two: settings/development.local.yml +three: settings/development.local.yml diff --git a/spec/support/config/settings/development.yml b/spec/support/config/settings/development.yml new file mode 100644 index 0000000..3f24f9f --- /dev/null +++ b/spec/support/config/settings/development.yml @@ -0,0 +1,6 @@ +--- +one: settings/development.yml +two: settings/development.yml +three: settings/development.yml +four: settings/development.yml +five: settings/development.yml