├── .ruby-version ├── spec ├── support │ ├── settings_empty.yml │ ├── dev.yml │ ├── settings.yml │ └── settings.rb ├── spec_helper.rb └── better_settings │ └── better_settings_spec.rb ├── .rspec ├── Gemfile ├── lib ├── better_settings │ └── version.rb └── better_settings.rb ├── Rakefile ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── release.yml │ ├── rubocop.yml │ └── ruby.yml ├── .gitignore ├── CHANGELOG.md ├── .rubocop_todo.yml ├── bin ├── rake ├── rspec └── rubocop ├── LICENSE.txt ├── better_settings.gemspec ├── .rubocop.yml └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.1.1 2 | -------------------------------------------------------------------------------- /spec/support/settings_empty.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format=progress 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /lib/better_settings/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BetterSettings 4 | VERSION = '1.0.2' 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/dev.yml: -------------------------------------------------------------------------------- 1 | development: 2 | language: 3 | smalltalk: 4 | paradigm: object-oriented 5 | clojure: 6 | paradigm: functional 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description 📖 2 | 3 | This pull request 4 | 5 | ### Background 📜 6 | 7 | This was happening because 8 | 9 | ### The Fix 🔨 10 | 11 | By changing 12 | 13 | ### Screenshots 📷 14 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start { add_filter '/spec/' } 5 | 6 | require 'better_settings' 7 | require 'rspec/given' 8 | require 'pry-byebug' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.5.0') 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions & Discussions 4 | url: https://github.com/ElMassimo/better_settings/discussions 5 | about: Use GitHub discussions for message-board style questions and discussions. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## BetterSettings 1.0.2 (2022-03-29) ## 2 | 3 | * Pass `aliases: true` now that Ruby 3.1.1 uses `psych-4.0.3` (#3) 4 | 5 | ## BetterSettings 1.0.1 (2021-02-07) ## 6 | 7 | * No code changes. 8 | * Allow the gem to be installed in Ruby 3.0. Thanks @pablomdiaz! 9 | 10 | ## BetterSettings 1.0.0 (2017-12-29) ## 11 | 12 | * Initial Release. 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: '14' 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npx conventional-github-releaser -p angular 20 | env: 21 | CONVENTIONAL_GITHUB_RELEASER_TOKEN: ${{secrets.GITHUB_TOKEN}} 22 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2020-11-05 13:38:45 UTC using RuboCop version 0.86.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 2 10 | # Cop supports --auto-correct. 11 | # Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. 12 | # URISchemes: http, https 13 | Layout/LineLength: 14 | Max: 250 15 | -------------------------------------------------------------------------------- /spec/support/settings.yml: -------------------------------------------------------------------------------- 1 | setting1: 2 | setting1_child: saweet 3 | deep: 4 | another: my value 5 | child: 6 | value: 2 7 | 8 | setting2: 5 9 | setting3: <%= 5 * 5 %> 10 | global: 'wat' 11 | name: test 12 | 13 | development: 14 | language: &language_defaults 15 | haskell: 16 | paradigm: functional 17 | smalltalk: 18 | paradigm: object oriented 19 | 20 | environment: development 21 | 22 | language: 23 | <<: *language_defaults 24 | 25 | collides: 26 | does: not 27 | nested: 28 | collides: 29 | does: not either 30 | 31 | nil: 32 | 'false': false 33 | 34 | array: 35 | - 36 | name: first 37 | - 38 | name: second 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug: pending triage' 6 | assignees: '' 7 | --- 8 | 9 | ### Description 📖 10 | 11 | _Provide a clear and concise description of what the bug is._ 12 | 13 | ### Reproduction 🐞 14 | 15 | _Please provide a link to a repo that can reproduce the problem you ran into (if it's not trivial)._ 16 | 17 | 18 | 19 | ### Logs 📜 20 | 21 | _Error output or similar when the error occurs:_ 22 | 23 |
24 | Output 25 | 26 | ``` 27 | 28 | ``` 29 |
30 | 31 | ### Screenshots 📷 32 | 33 | _Provide console or browser screenshots of the problem_. 34 | 35 | -------------------------------------------------------------------------------- /spec/support/settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Settings < BetterSettings 4 | source "#{ File.dirname(__FILE__) }/settings.yml" 5 | source "#{ File.dirname(__FILE__) }/settings_empty.yml" 6 | 7 | def self.custom 8 | 'CUSTOM' 9 | end 10 | 11 | def self.global 12 | 'GLOBAL' 13 | end 14 | end 15 | 16 | class DevSettings < BetterSettings 17 | source "#{ File.dirname(__FILE__) }/settings.yml", namespace: :development 18 | source "#{ File.dirname(__FILE__) }/dev.yml", namespace: 'development' 19 | end 20 | 21 | class NoSettings < BetterSettings 22 | source "#{ File.dirname(__FILE__) }/settings_empty.yml", optional: true 23 | source "#{ File.dirname(__FILE__) }/settings_none.yml", optional: true 24 | end 25 | 26 | class NoSource < BetterSettings 27 | end 28 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'pathname' 12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path('bundle', __dir__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require 'rubygems' 27 | require 'bundler/setup' 28 | 29 | load Gem.bin_path('rake', 'rake') 30 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'pathname' 12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path('bundle', __dir__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require 'rubygems' 27 | require 'bundler/setup' 28 | 29 | load Gem.bin_path('rspec-core', 'rspec') 30 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'pathname' 12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path('bundle', __dir__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require 'rubygems' 27 | require 'bundler/setup' 28 | 29 | load Gem.bin_path('rubocop', 'rubocop') 30 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Rubocop 8 | runs-on: ${{ matrix.os }} 9 | env: 10 | BUNDLE_JOBS: 4 11 | BUNDLE_RETRY: 3 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] 15 | ruby: [ 16 | 3.1.0, 17 | 3.1.1, 18 | ] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/cache@v2 23 | with: 24 | path: /home/runner/bundle 25 | key: bundle-use-ruby-gems-${{ hashFiles('**/Gemfile.lock') }} 26 | restore-keys: | 27 | bundle-use-ruby-gems- 28 | 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{ matrix.ruby }} 32 | 33 | - name: Bundle install 34 | run: | 35 | gem install bundler -v 2.1.4 36 | bundle config path /home/runner/bundle 37 | bundle install 38 | 39 | - name: Ruby linter 40 | run: bundle exec rubocop 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Máximo Mussini 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /better_settings.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('lib/better_settings/version', __dir__) 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'better_settings' 7 | s.version = BetterSettings::VERSION 8 | s.authors = ['Máximo Mussini'] 9 | s.email = ['maximomussini@gmail.com'] 10 | s.summary = 'Settings for Ruby applications: fast, immutable, better.' 11 | s.description = 'Settings solution for Ruby or Rails applications that can read ERB-enabled YAML files. Safe, performant, with friendly error messages, and no dependencies.' 12 | s.homepage = 'https://github.com/ElMassimo/better_settings' 13 | s.license = 'MIT' 14 | s.extra_rdoc_files = ['README.md'] 15 | s.files = Dir.glob('{lib}/**/*.rb') + %w[README.md] 16 | s.test_files = Dir.glob('{spec}/**/*.rb') 17 | s.require_path = 'lib' 18 | 19 | s.required_ruby_version = Gem::Requirement.new('>= 3.1.0') 20 | 21 | s.add_development_dependency 'pry-byebug' 22 | s.add_development_dependency 'rake' 23 | s.add_development_dependency 'rspec-given', '~> 3.0' 24 | s.add_development_dependency 'rubocop' 25 | s.add_development_dependency 'rubocop-rspec' 26 | s.add_development_dependency 'simplecov', '< 0.18' 27 | end 28 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: build 8 | runs-on: ${{ matrix.os }} 9 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') || matrix.ruby == 'debug' || matrix.experimental }} 10 | env: 11 | BUNDLE_JOBS: 4 12 | BUNDLE_RETRY: 3 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest] 17 | ruby: [ 18 | 3.1.0, 19 | 3.1.1, 20 | ] 21 | experimental: [false] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions/cache@v2 26 | with: 27 | path: /home/runner/bundle 28 | key: bundle-use-ruby-${{ matrix.ruby }}-${{ matrix.gemfile }}-gems-${{ hashFiles(matrix.gemfile) }}-${{ hashFiles('**/*.gemspec') }} 29 | restore-keys: | 30 | bundle-use-ruby-${{ matrix.ruby }}-${{ matrix.gemfile }}-gems- 31 | 32 | - uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby }} 35 | 36 | - name: Bundle install 37 | run: | 38 | gem install bundler -v 2.1.4 39 | bundle config path /home/runner/bundle 40 | bundle config --global gemfile ${{ matrix.gemfile }} 41 | bundle install --jobs 4 --retry 3 42 | 43 | - name: Setup Code Climate test-reporter 44 | run: | 45 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 46 | chmod +x ./cc-test-reporter 47 | ./cc-test-reporter before-build 48 | 49 | - name: Ruby specs 50 | run: bundle exec rspec 51 | 52 | - name: Upload code coverage to Code Climate 53 | run: | 54 | export GIT_BRANCH="${GITHUB_REF/refs\/heads\//}" 55 | ./cc-test-reporter after-build -r ${{secrets.CC_TEST_REPORTER_ID}} 56 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: rubocop-rspec 4 | 5 | AllCops: 6 | Exclude: 7 | - "public/**/*" 8 | - "package/**/*" 9 | - "examples/**/*" 10 | - "vendor/bundle" 11 | - "tmp/**/*" 12 | 13 | RSpec/MultipleExpectations: 14 | Enabled: false 15 | 16 | RSpec/ExampleLength: 17 | Enabled: false 18 | 19 | Style/DoubleNegation: 20 | Enabled: false 21 | 22 | Style/MultilineBlockChain: 23 | Enabled: false 24 | 25 | Layout/ElseAlignment: 26 | Enabled: false 27 | 28 | Layout/EndAlignment: 29 | Enabled: false 30 | 31 | Layout/IndentationWidth: 32 | Enabled: false 33 | 34 | Gemspec/RequiredRubyVersion: 35 | Enabled: false 36 | 37 | Layout/IndentationConsistency: 38 | Enabled: false 39 | 40 | Layout/ArgumentAlignment: 41 | Enabled: false 42 | 43 | Lint/AssignmentInCondition: 44 | Enabled: false 45 | 46 | Layout/FirstArgumentIndentation: 47 | Enabled: false 48 | 49 | Style/Documentation: 50 | Enabled: false 51 | 52 | Naming/RescuedExceptionsVariableName: 53 | PreferredName: error 54 | 55 | Layout/SpaceInsideStringInterpolation: 56 | EnforcedStyle: space 57 | 58 | Layout/MultilineMethodCallIndentation: 59 | EnforcedStyle: indented 60 | 61 | Layout/FirstArrayElementIndentation: 62 | EnforcedStyle: consistent 63 | 64 | Style/SymbolArray: 65 | Enabled: false 66 | 67 | Metrics/ParameterLists: 68 | Enabled: false 69 | 70 | Style/AccessModifierDeclarations: 71 | Enabled: false 72 | 73 | Style/MissingRespondToMissing: 74 | Enabled: false 75 | 76 | Style/ParallelAssignment: 77 | Enabled: false 78 | 79 | Style/TrailingCommaInArrayLiteral: 80 | EnforcedStyleForMultiline: comma 81 | 82 | Style/TrailingCommaInHashLiteral: 83 | EnforcedStyleForMultiline: comma 84 | 85 | Style/TrailingCommaInArguments: 86 | EnforcedStyleForMultiline: comma 87 | 88 | Style/Lambda: 89 | EnforcedStyle: literal 90 | 91 | Style/ClassAndModuleChildren: 92 | Enabled: false 93 | 94 | Style/BlockDelimiters: 95 | Enabled: false 96 | 97 | Layout/AccessModifierIndentation: 98 | Enabled: true 99 | EnforcedStyle: outdent 100 | 101 | Layout/CaseIndentation: 102 | EnforcedStyle: 'end' 103 | 104 | # Disabled to allow the outdented comment style 105 | Layout/CommentIndentation: 106 | Enabled: false 107 | 108 | Lint/SuppressedException: 109 | Enabled: false 110 | 111 | Metrics/AbcSize: 112 | Enabled: false 113 | Metrics/CyclomaticComplexity: 114 | Enabled: false 115 | Metrics/PerceivedComplexity: 116 | Enabled: false 117 | Metrics/MethodLength: 118 | Enabled: false 119 | Metrics/BlockLength: 120 | Enabled: false 121 | Metrics/ClassLength: 122 | Enabled: false 123 | 124 | Naming/PredicateName: 125 | Enabled: false 126 | 127 | Style/NumericPredicate: 128 | Enabled: false 129 | 130 | Security/YAMLLoad: 131 | Enabled: false 132 | 133 | Style/MutableConstant: 134 | Enabled: false 135 | -------------------------------------------------------------------------------- /lib/better_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | require 'erb' 5 | require 'open-uri' 6 | require 'forwardable' 7 | 8 | # Public: Rewrite of BetterSettings to enforce fail-fast and immutability, and 9 | # avoid extending a core class like Hash which can be problematic. 10 | class BetterSettings 11 | extend Forwardable 12 | 13 | VALID_SETTING_NAME = /^\w+$/ 14 | RESERVED_METHODS = %w[ 15 | settings 16 | root_settings 17 | ] 18 | 19 | attr_reader :settings 20 | 21 | def_delegators :settings, :to_h, :to_hash 22 | 23 | # Public: Initializes a new settings object from a Hash or compatible object. 24 | def initialize(hash, parent:) 25 | @settings = hash.to_h.freeze 26 | @parent = parent 27 | 28 | # Create a getter method for each setting. 29 | @settings.each { |key, value| create_accessor(key, value) } 30 | end 31 | 32 | # Internal: Returns a new Better Settings instance that combines the settings. 33 | def merge(other_settings) 34 | self.class.new(deep_merge(@settings, other_settings.to_h), parent: @parent) 35 | end 36 | 37 | # Internal: Display explicit errors for typos and missing settings. 38 | def method_missing(name, *) 39 | raise MissingSetting, "Missing setting '#{ name }' in #{ @parent }" 40 | end 41 | 42 | private 43 | 44 | # Internal: Wrap nested hashes as settings to allow accessing keys as methods. 45 | def auto_wrap(key, value) 46 | case value 47 | when Hash then self.class.new(value, parent: "'#{ key }' section in #{ @parent }") 48 | when Array then value.map { |item| auto_wrap(key, item) }.freeze 49 | else value.freeze 50 | end 51 | end 52 | 53 | # Internal: Defines a getter for the specified setting. 54 | def create_accessor(key, value) 55 | raise InvalidSettingKey if !key.is_a?(String) || key !~ VALID_SETTING_NAME || RESERVED_METHODS.include?(key) 56 | 57 | instance_variable_set("@#{ key }", auto_wrap(key, value)) 58 | singleton_class.send(:attr_reader, key) 59 | end 60 | 61 | # Internal: Recursively merges two hashes (in case ActiveSupport is not available). 62 | def deep_merge(this_hash, other_hash) 63 | this_hash.merge(other_hash) do |_key, this_val, other_val| 64 | if this_val.is_a?(Hash) && other_val.is_a?(Hash) 65 | deep_merge(this_val, other_val) 66 | else 67 | other_val 68 | end 69 | end 70 | end 71 | 72 | class MissingSetting < StandardError; end 73 | 74 | class InvalidSettingKey < StandardError; end 75 | 76 | class << self 77 | extend Forwardable 78 | def_delegators :root_settings, :to_h, :to_hash, :method_missing 79 | 80 | # Public: Loads a file as settings (merges it with any previously loaded settings). 81 | def source(file_name, namespace: false, optional: false) 82 | return if !File.exist?(file_name) && optional 83 | 84 | # Load the specified yaml file and instantiate a Settings object. 85 | settings = new(yaml_to_hash(file_name), parent: file_name) 86 | 87 | # Take one of the settings keys if one is specified. 88 | settings = settings.public_send(namespace) if namespace 89 | 90 | # Merge settings if a source had previously been specified. 91 | @root_settings = @root_settings ? @root_settings.merge(settings) : settings 92 | 93 | # Allow to call any settings methods directly on the class. 94 | singleton_class.extend(Forwardable) 95 | singleton_class.def_delegators :root_settings, *@root_settings.settings.keys 96 | end 97 | 98 | private 99 | 100 | # Internal: Methods called at the class level are delegated to this instance. 101 | def root_settings 102 | raise ArgumentError, '`source` must be specified for the settings' unless defined?(@root_settings) 103 | 104 | @root_settings 105 | end 106 | 107 | # Internal: Parses a yml file that can optionally use ERB templating. 108 | def yaml_to_hash(file_name) 109 | return {} if (content = File.open(file_name).read).empty? 110 | 111 | YAML.load(ERB.new(content).result, aliases: true).to_hash 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/better_settings/better_settings_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/settings' 5 | 6 | describe BetterSettings do 7 | def new_settings(value) 8 | Settings.new(value, parent: 'new_settings') 9 | end 10 | 11 | it 'accesses settings' do 12 | expect(Settings.setting2).to eq 5 13 | end 14 | 15 | it 'accesses nested settings' do 16 | expect(Settings.setting1.setting1_child).to eq 'saweet' 17 | end 18 | 19 | it 'accesses settings in nested arrays' do 20 | expect(Settings.array.first.name).to eq 'first' 21 | end 22 | 23 | it 'accesses deep nested settings' do 24 | expect(Settings.setting1.deep.another).to eq 'my value' 25 | end 26 | 27 | it 'accesses extra deep nested settings' do 28 | expect(Settings.setting1.deep.child.value).to eq 2 29 | end 30 | 31 | it 'enables erb' do 32 | expect(Settings.setting3).to eq 25 33 | end 34 | 35 | it 'namespaces settings' do 36 | expect(DevSettings.language.haskell.paradigm).to eq 'functional' 37 | expect(DevSettings.language.smalltalk.paradigm).to eq 'object-oriented' 38 | expect(DevSettings.environment).to eq 'development' 39 | end 40 | 41 | it 'distinguishes nested keys' do 42 | expect(Settings.language.haskell.paradigm).to eq 'functional' 43 | expect(Settings.language.smalltalk.paradigm).to eq 'object oriented' 44 | end 45 | 46 | it 'does not override global methods' do 47 | expect(Settings.global).to eq 'GLOBAL' 48 | expect(Settings.custom).to eq 'CUSTOM' 49 | end 50 | 51 | it 'raises a helpful error message' do 52 | expect { 53 | Settings.missing 54 | }.to raise_error(BetterSettings::MissingSetting, /Missing setting 'missing' in/) 55 | expect { 56 | Settings.language.missing 57 | }.to raise_error(BetterSettings::MissingSetting, /Missing setting 'missing' in 'language' section/) 58 | end 59 | 60 | it 'raises an error on a nil source argument' do 61 | expect { NoSource.foo.bar }.to raise_error(ArgumentError, '`source` must be specified for the settings') 62 | end 63 | 64 | it 'supports instance usage as well' do 65 | expect(new_settings(Settings.setting1).setting1_child).to eq 'saweet' 66 | end 67 | 68 | it 'handles invalid name settings' do 69 | expect { 70 | new_settings('some-dash-setting#' => 'dashtastic') 71 | }.to raise_error(BetterSettings::InvalidSettingKey) 72 | end 73 | 74 | it 'handles settings with nil value' do 75 | expect(Settings.nil).to eq nil 76 | end 77 | 78 | it 'handles settings with false value' do 79 | expect(Settings.false).to eq false 80 | end 81 | 82 | # If .name is called on BetterSettings itself, handle appropriately 83 | # by delegating to Hash 84 | it 'has the parent class always respond with Module.name' do 85 | expect(described_class.name).to eq 'BetterSettings' 86 | end 87 | 88 | # If .name is not a property, delegate to superclass 89 | it 'responds with Module.name' do 90 | expect(DevSettings.name).to eq 'DevSettings' 91 | end 92 | 93 | # If .name is a property, respond with that instead of delegating to superclass 94 | it 'allows a name setting to be overriden' do 95 | expect(Settings.name).to eq 'test' 96 | end 97 | 98 | describe 'to_h' do 99 | it 'handles empty file' do 100 | expect(NoSettings.to_h).to be_empty 101 | end 102 | 103 | it 'is similar to the internal representation' do 104 | expect(settings = Settings.send(:root_settings)).to be_is_a(Settings) 105 | expect(hash = settings.send(:settings)).to be_is_a(Hash) 106 | expect(Settings.to_h).to eq hash 107 | end 108 | 109 | it 'does not mutate the original when getting a copy' do 110 | result = Settings.language.to_h.merge('haskell' => 'awesome') 111 | expect(result.class).to eq Hash 112 | expect(result).to eq( 113 | 'haskell' => 'awesome', 114 | 'smalltalk' => { 'paradigm' => 'object oriented' }, 115 | ) 116 | expect(Settings.language.haskell.paradigm).to eq('functional') 117 | expect(Settings.language).not_to eq Settings.language.merge('paradigm' => 'functional') 118 | end 119 | end 120 | 121 | describe '#to_hash' do 122 | it 'returns a new instance of a Hash object' do 123 | expect(Settings.to_hash).to be_kind_of(Hash) 124 | expect(Settings.to_hash.class.name).to eq 'Hash' 125 | expect(Settings.to_hash.object_id).not_to eq Settings.object_id 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Better Settings 3 |

4 | 5 | Build Status 6 | 7 | 8 | Maintainability 9 | 10 | 11 | Test Coverage 12 | 13 | 14 | Gem Version 15 | 16 | 17 | License 18 | 19 |

20 |

21 | 22 | A robust settings library for Ruby. Access your settings by calling methods on a safe immutable object. 23 | 24 | ## Features ⚡️ 25 | 26 | - 🚀 __Light and Performant:__ settings are eagerly loaded, no `method_missing` tricks, no dependencies. 27 | - 💬 __Useful Error Messages:__ when trying to access a setting that does not exist. 28 | - 💎 __Immutability:__ once created settings can't be modified. 29 | - 🗂 __Multiple Files:__ useful to create multiple environment-specific source files. 30 | - ❕ __No Optional Setings:__ since it encourages unsafe access patterns. 31 | 32 | You can read more about it in [the blog announcement](https://maximomussini.com/posts/better-settings/). 33 | 34 | 35 | ## Installation 💿 36 | 37 | Add this line to your application's Gemfile: 38 | 39 | ```ruby 40 | gem 'better_settings' 41 | ``` 42 | 43 | And then execute: 44 | 45 | $ bundle 46 | 47 | Or install it yourself as: 48 | 49 | $ gem install better_settings 50 | 51 | ## Usage 🚀 52 | 53 | ### 1. Define a class 54 | 55 | Create a class in your application that extends `BetterSettings`: 56 | 57 | ```ruby 58 | # app/models/settings.rb 59 | class Settings < BetterSettings 60 | source Rails.root.join('config', 'application.yml'), namespace: Rails.env 61 | end 62 | ``` 63 | 64 | We use `Rails.root` in this example to obtain an absolute path to a plain YML file, 65 | but when using other Ruby frameworks you can use `File.expand_path` with `__dir__` instead. 66 | 67 | Also, we specified a `namespace` with the current environment. You can provide 68 | any value that corresponds to a key in the YAML file that you want to use. 69 | This allows to target different environments with the same file. 70 | 71 | ### 2. Create your settings 72 | 73 | Now, create a YAML file that contains all the possible namespaces: 74 | 75 | ```yaml 76 | # config/application.yml 77 | defaults: &defaults 78 | port: 80 79 | mailer: 80 | root: www.example.com 81 | dynamic: <%= "Did you know you can use ERB inside the YML file? Env is #{ Rails.env }." %> 82 | 83 | development: 84 | <<: *defaults 85 | port: 3000 86 | 87 | test: 88 | <<: *defaults 89 | 90 | production: 91 | <<: *defaults 92 | ``` 93 | 94 | The `defaults` group in this example won't be used directly, we are using YAML's 95 | syntax to reuse those values when we use `<<: *defaults`, allowing us to share 96 | these values across environments. 97 | 98 | ### 3. Access your settings 99 | 100 | You can use these settings anywhere, for example in a model: 101 | 102 | ```ruby 103 | class Post < ActiveRecord::Base 104 | self.per_page = Settings.pagination.posts_per_page 105 | end 106 | ``` 107 | 108 | or in the console: 109 | 110 | ``` 111 | >> Rails.env 112 | => "development" 113 | 114 | >> Settings.mailer 115 | => "#" 116 | 117 | >> Settings.mailer.root 118 | => "www.example.com 119 | 120 | >> Settings.port 121 | => 3000 122 | 123 | >> Settings.dynamic 124 | => "Did you know you can use ERB inside the YML file? Env is development." 125 | ``` 126 | 127 | ## Advanced Setup ⚙ 128 | 129 | You can create as many setting classes as you need, and name them in different ways, and read from as many files as necessary (nested keys will be merged). 130 | 131 | The way I like to use it, is by reading a few optional files for the _development_ and _test_ environments, which allows each developer to override some settings in their own local environment (and git ignoring `development.yml` and `test.yml`). 132 | 133 | ```ruby 134 | # app/models/settings.rb 135 | class Settings < BetterSettings 136 | source Rails.root.join('config/application.yml'), namespace: Rails.env 137 | source Rails.root.join('config/development.yml'), namespace: Rails.env, optional: true if Rails.env.development? 138 | source Rails.root.join('config/test.yml'), namespace: Rails.env, optional: true if Rails.env.test? 139 | end 140 | ``` 141 | 142 | Then `application.yml` looks like this: 143 | 144 | ```yaml 145 | # application.yml 146 | defaults: &defaults 147 | auto_logout: false 148 | secret_key_base: 'fake_secret_key_base' 149 | 150 | server_defaults: &server_defaults 151 | <<: *defaults 152 | auto_logout: true 153 | secret_key: <%= ENV['SECRET_KEY'] %> 154 | 155 | development: 156 | <<: *defaults 157 | host: 'localhost' 158 | 159 | test: 160 | <<: *defaults 161 | host: '127.0.0.1' 162 | 163 | staging: 164 | <<: *server_defaults 165 | host: 'staging.example.com' 166 | 167 | production: 168 | <<: *server_defaults 169 | host: 'example.com' 170 | ``` 171 | 172 | A developer might want to override some settings by defining a `development.yml` such as: 173 | 174 | ```yaml 175 | development: 176 | auto_logout: true 177 | ```` 178 | 179 | The main advantage is that those changes won't be tracked in source control :smiley: 180 | --------------------------------------------------------------------------------