├── .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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
--------------------------------------------------------------------------------