├── .prettierignore ├── test ├── support │ └── rg.rb ├── test_helper.rb ├── configurable_from_env │ ├── version_test.rb │ └── environment_value_test.rb └── configurable_from_env_test.rb ├── lib ├── configurable_from_env │ ├── version.rb │ ├── environment_value.rb │ └── configurable.rb └── configurable_from_env.rb ├── CHANGELOG.md ├── .gitignore ├── bin ├── setup └── console ├── Appraisals ├── Gemfile ├── .github ├── workflows │ ├── release-drafter.yml │ └── ci.yml ├── dependabot.yml └── release-drafter.yml ├── gemfiles ├── rails_7.2.gemfile ├── rails_8.0.gemfile └── rails_main.gemfile ├── .kodiak.toml ├── .overcommit.yml ├── LICENSE.txt ├── configurable_from_env.gemspec ├── .rubocop.yml ├── Rakefile ├── CODE_OF_CONDUCT.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | /CODE_OF_CONDUCT.md 2 | -------------------------------------------------------------------------------- /test/support/rg.rb: -------------------------------------------------------------------------------- 1 | # Enable color test output 2 | require "minitest/rg" 3 | -------------------------------------------------------------------------------- /lib/configurable_from_env/version.rb: -------------------------------------------------------------------------------- 1 | module ConfigurableFromEnv 2 | VERSION = "0.3.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Release notes for this project are kept here: https://github.com/mattbrictson/configurable_from_env/releases 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /site/ 8 | /spec/reports/ 9 | /tmp/ 10 | /Gemfile.lock 11 | /gemfiles/*.lock 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "configurable_from_env" 3 | 4 | require "minitest/autorun" 5 | Dir[File.expand_path("support/**/*.rb", __dir__)].each { |rb| require(rb) } 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | which overcommit > /dev/null 2>&1 && overcommit --install 7 | bundle install 8 | 9 | # Do any other automated setup that you need to do here 10 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-7.2" do 2 | gem "activesupport", "~> 7.2.2" 3 | end 4 | 5 | appraise "rails-8.0" do 6 | gem "activesupport", "~> 8.0.0" 7 | end 8 | 9 | appraise "rails-main" do 10 | gem "activesupport", github: "rails/rails", branch: "main" 11 | end 12 | -------------------------------------------------------------------------------- /test/configurable_from_env/version_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module ConfigurableFromEnv 4 | class VersionTest < Minitest::Test 5 | def test_that_it_has_a_version_number 6 | refute_nil ::ConfigurableFromEnv::VERSION 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | gem "appraisal" 5 | gem "irb" 6 | gem "minitest", "~> 5.11" 7 | gem "minitest-rg", "~> 5.3" 8 | gem "rake", "~> 13.0" 9 | gem "rubocop", "1.81.7" 10 | gem "rubocop-minitest", "0.38.2" 11 | gem "rubocop-packaging", "0.6.0" 12 | gem "rubocop-performance", "1.26.1" 13 | gem "rubocop-rake", "0.7.1" 14 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: read 11 | 12 | jobs: 13 | update_release_draft: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: release-drafter/release-drafter@v6 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "configurable_from_env" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "minitest", "~> 5.11" 7 | gem "minitest-rg", "~> 5.3" 8 | gem "rake", "~> 13.0" 9 | gem "rubocop", "1.68.0" 10 | gem "rubocop-minitest", "0.36.0" 11 | gem "rubocop-packaging", "0.5.2" 12 | gem "rubocop-performance", "1.22.1" 13 | gem "rubocop-rake", "0.6.0" 14 | gem "activesupport", "~> 7.2.2" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "minitest", "~> 5.11" 7 | gem "minitest-rg", "~> 5.3" 8 | gem "rake", "~> 13.0" 9 | gem "rubocop", "1.68.0" 10 | gem "rubocop-minitest", "0.36.0" 11 | gem "rubocop-packaging", "0.5.2" 12 | gem "rubocop-performance", "1.22.1" 13 | gem "rubocop-rake", "0.6.0" 14 | gem "activesupport", "~> 8.0.0" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/rails_main.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "minitest", "~> 5.11" 7 | gem "minitest-rg", "~> 5.3" 8 | gem "rake", "~> 13.0" 9 | gem "rubocop", "1.68.0" 10 | gem "rubocop-minitest", "0.36.0" 11 | gem "rubocop-packaging", "0.5.2" 12 | gem "rubocop-performance", "1.22.1" 13 | gem "rubocop-rake", "0.6.0" 14 | gem "activesupport", github: "rails/rails", branch: "main" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /.kodiak.toml: -------------------------------------------------------------------------------- 1 | # .kodiak.toml 2 | # Minimal config. version is the only required field. 3 | version = 1 4 | 5 | [merge.automerge_dependencies] 6 | # auto merge all PRs opened by "dependabot" that are "minor" or "patch" version upgrades. "major" version upgrades will be ignored. 7 | versions = ["minor", "patch"] 8 | usernames = ["dependabot"] 9 | 10 | # if using `update.always`, add dependabot to `update.ignore_usernames` to allow 11 | # dependabot to update and close stale dependency upgrades. 12 | [update] 13 | ignored_usernames = ["dependabot"] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "16:00" 8 | timezone: America/Los_Angeles 9 | open-pull-requests-limit: 10 10 | labels: 11 | - "🏠 Housekeeping" 12 | - package-ecosystem: github-actions 13 | directory: "/" 14 | schedule: 15 | interval: monthly 16 | time: "16:00" 17 | timezone: America/Los_Angeles 18 | open-pull-requests-limit: 10 19 | labels: 20 | - "🏠 Housekeeping" 21 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Overcommit hooks run automatically on certain git operations, like "git commit". 2 | # For a complete list of options that you can use to customize hooks, see: 3 | # https://github.com/sds/overcommit 4 | 5 | gemfile: false 6 | verify_signatures: false 7 | 8 | PreCommit: 9 | BundleCheck: 10 | enabled: true 11 | 12 | FixMe: 13 | enabled: true 14 | keywords: ["FIXME"] 15 | exclude: 16 | - .overcommit.yml 17 | 18 | LocalPathsInGemfile: 19 | enabled: true 20 | 21 | RuboCop: 22 | enabled: true 23 | required_executable: bundle 24 | command: ["bundle", "exec", "rubocop"] 25 | on_warn: fail 26 | 27 | YamlSyntax: 28 | enabled: true 29 | 30 | PostCheckout: 31 | ALL: 32 | quiet: true 33 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "⚠️ Breaking Changes" 5 | label: "⚠️ Breaking" 6 | - title: "✨ New Features" 7 | label: "✨ Feature" 8 | - title: "🐛 Bug Fixes" 9 | label: "🐛 Bug Fix" 10 | - title: "📚 Documentation" 11 | label: "📚 Docs" 12 | - title: "🏠 Housekeeping" 13 | label: "🏠 Housekeeping" 14 | version-resolver: 15 | minor: 16 | labels: 17 | - "⚠️ Breaking" 18 | - "✨ Feature" 19 | default: patch 20 | change-template: "- $TITLE (#$NUMBER) @$AUTHOR" 21 | no-changes-template: "- No changes" 22 | template: | 23 | $CHANGES 24 | 25 | **Full Changelog:** https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 26 | -------------------------------------------------------------------------------- /lib/configurable_from_env.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_support/concern" 3 | require "active_support/core_ext/enumerable" 4 | require "active_support/core_ext/hash/keys" 5 | 6 | module ConfigurableFromEnv 7 | autoload :Configurable, "configurable_from_env/configurable" 8 | autoload :EnvironmentValue, "configurable_from_env/environment_value" 9 | autoload :VERSION, "configurable_from_env/version" 10 | 11 | extend ActiveSupport::Concern 12 | include Configurable 13 | 14 | module ClassMethods 15 | def config_accessor(*attributes, from_env: nil, **options, &) 16 | if from_env && attributes.many? 17 | raise ArgumentError, "Only one accessor at a time can be created using the :from_env option" 18 | end 19 | 20 | env_value = EnvironmentValue.from(from_env) 21 | accessor = super(*attributes, **options, &) 22 | default_provided = options.key?(:default) || block_given? 23 | 24 | env_value&.read(required: !default_provided) do |value| 25 | public_send(:"#{attributes.first}=", value) 26 | end 27 | 28 | accessor 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Matt Brictson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /configurable_from_env.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/configurable_from_env/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "configurable_from_env" 5 | spec.version = ConfigurableFromEnv::VERSION 6 | spec.authors = ["Matt Brictson"] 7 | spec.email = ["opensource@mattbrictson.com"] 8 | 9 | spec.summary = "Define accessors that are automatically populated via ENV" 10 | spec.homepage = "https://github.com/mattbrictson/configurable_from_env" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 3.2" 13 | 14 | spec.metadata = { 15 | "bug_tracker_uri" => "https://github.com/mattbrictson/configurable_from_env/issues", 16 | "changelog_uri" => "https://github.com/mattbrictson/configurable_from_env/releases", 17 | "source_code_uri" => "https://github.com/mattbrictson/configurable_from_env", 18 | "homepage_uri" => spec.homepage, 19 | "rubygems_mfa_required" => "true" 20 | } 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | spec.files = Dir.glob(%w[LICENSE.txt README.md {exe,lib}/**/*]).reject { |f| File.directory?(f) } 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | 28 | # Runtime dependencies 29 | spec.add_dependency "activesupport", ">= 7.2" 30 | end 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | rubocop: 9 | name: "Rubocop" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: "ruby" 16 | bundler-cache: true 17 | - run: bundle exec rubocop 18 | test: 19 | name: "Test / Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }}" 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | rails: ["7.2", "8.0", "main"] 24 | ruby: ["3.2", "3.3", "3.4", "head"] 25 | exclude: 26 | - rails: "8.0" 27 | ruby: "3.1" 28 | - rails: main 29 | ruby: "3.1" 30 | env: 31 | BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails }}.gemfile 32 | steps: 33 | - uses: actions/checkout@v6 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | bundler-cache: true 38 | - run: bundle exec rake test 39 | test-all: 40 | name: "Test / All" 41 | runs-on: ubuntu-latest 42 | needs: [test] 43 | if: always() 44 | steps: 45 | - name: All tests ok 46 | if: ${{ !(contains(needs.*.result, 'failure')) }} 47 | run: exit 0 48 | - name: Some tests failed 49 | if: ${{ contains(needs.*.result, 'failure') }} 50 | run: exit 1 51 | -------------------------------------------------------------------------------- /lib/configurable_from_env/environment_value.rb: -------------------------------------------------------------------------------- 1 | module ConfigurableFromEnv 2 | class EnvironmentValue 3 | TYPES = %i[boolean integer string].freeze 4 | 5 | BOOLEAN_VALUES = {}.merge( 6 | %w[1 true yes t y enable enabled on].to_h { [_1, true] }, 7 | %w[0 false no f n disable disabled off].to_h { [_1, false] }, 8 | { "" => false } 9 | ).freeze 10 | 11 | def self.from(definition) 12 | return nil if definition.nil? 13 | 14 | definition = { key: definition } unless definition.is_a?(Hash) 15 | definition.assert_valid_keys(:key, :type) 16 | new(**definition) 17 | end 18 | 19 | attr_reader :key, :type 20 | 21 | def initialize(key:, type: :string, env: ENV) 22 | unless TYPES.include?(type) 23 | raise ArgumentError, "Invalid type: #{type.inspect} (must be one of #{TYPES.map(&:inspect).join(', ')})" 24 | end 25 | 26 | @key = key 27 | @type = type 28 | @env = env 29 | end 30 | 31 | def read(required: true) 32 | if env.key?(key) 33 | value = convert(env[key]) 34 | block_given? ? yield(value) : value 35 | elsif required 36 | raise ArgumentError, "Missing required environment variable: #{key}" 37 | end 38 | end 39 | 40 | private 41 | 42 | attr_reader :env 43 | 44 | def convert(value) 45 | send(:"convert_to_#{type}", value) 46 | rescue ArgumentError 47 | raise ArgumentError, "Environment variable #{key} has an invalid #{type} value: #{value.inspect}" 48 | end 49 | 50 | def convert_to_boolean(value) 51 | BOOLEAN_VALUES.fetch(value&.downcase&.strip) do 52 | raise ArgumentError, "Boolean value must be one of #{BOOLEAN_VALUES.keys.join(', ')}" 53 | end 54 | end 55 | 56 | def convert_to_integer(value) 57 | Integer(value) 58 | end 59 | 60 | def convert_to_string(value) 61 | value.to_s 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/configurable_from_env_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurableFromEnvTest < Minitest::Test 4 | def setup 5 | @class = Class.new 6 | @class.include ConfigurableFromEnv 7 | end 8 | 9 | def teardown 10 | ENV.delete("TEST_API_KEY") 11 | ENV.delete("TEST_TIMEOUT") 12 | end 13 | 14 | def test_defines_accessor_with_no_env_source 15 | @class.config_accessor :logger 16 | 17 | assert_respond_to @class, :logger 18 | assert_respond_to @class, :logger= 19 | end 20 | 21 | def test_defines_multiple_accessors_with_no_env_source 22 | @class.config_accessor :http_client, :logger 23 | 24 | assert_respond_to @class, :http_client 25 | assert_respond_to @class, :http_client= 26 | assert_respond_to @class, :logger 27 | assert_respond_to @class, :logger= 28 | end 29 | 30 | def test_raises_if_multiple_accessors_declared_with_one_env_option 31 | error = assert_raises(ArgumentError) { @class.config_accessor :api_key, :logger, from_env: "TEST_API_KEY" } 32 | assert_match "Only one accessor at a time can be created using the :from_env option", error.message 33 | end 34 | 35 | def test_defines_accessor_with_key_and_implicit_type 36 | @class.config_accessor :api_key, from_env: "TEST_API_KEY", default: nil 37 | 38 | assert_respond_to @class, :api_key 39 | assert_respond_to @class, :api_key= 40 | end 41 | 42 | def test_defines_accessor_with_key_and_type 43 | @class.config_accessor :timeout, from_env: { key: "TEST_TIMEOUT", type: :integer }, default: 30 44 | 45 | assert_respond_to @class, :timeout 46 | assert_respond_to @class, :timeout= 47 | end 48 | 49 | def test_uses_default_value_when_env_var_is_absent 50 | @class.config_accessor :timeout, from_env: { key: "TEST_TIMEOUT", type: :integer }, default: 30 51 | 52 | assert_equal 30, @class.timeout 53 | end 54 | 55 | def test_assigns_integer_env_var_value_overriding_default 56 | ENV["TEST_TIMEOUT"] = "5" 57 | @class.config_accessor :timeout, from_env: { key: "TEST_TIMEOUT", type: :integer }, default: 30 58 | 59 | assert_equal 5, @class.timeout 60 | end 61 | 62 | def test_assigns_string_env_var_value_with_no_default 63 | ENV["TEST_API_KEY"] = "secret" 64 | @class.config_accessor :api_key, from_env: "TEST_API_KEY" 65 | 66 | assert_equal "secret", @class.api_key 67 | end 68 | 69 | def test_raises_if_no_default_and_env_var_is_absent 70 | error = assert_raises(ArgumentError) { @class.config_accessor :api_key, from_env: "TEST_API_KEY" } 71 | assert_match "Missing required environment variable: TEST_API_KEY", error.message 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-minitest 3 | - rubocop-packaging 4 | - rubocop-performance 5 | - rubocop-rake 6 | 7 | AllCops: 8 | DisplayCopNames: true 9 | DisplayStyleGuide: true 10 | NewCops: enable 11 | TargetRubyVersion: 3.2 12 | Exclude: 13 | - "lib/configurable_from_env/configurable.rb" 14 | - "tmp/**/*" 15 | - "vendor/**/*" 16 | 17 | Bundler/OrderedGems: 18 | Exclude: 19 | - "gemfiles/*" 20 | 21 | Layout/FirstArrayElementIndentation: 22 | EnforcedStyle: consistent 23 | 24 | Layout/FirstArrayElementLineBreak: 25 | Enabled: true 26 | 27 | Layout/FirstHashElementLineBreak: 28 | Enabled: true 29 | 30 | Layout/FirstMethodArgumentLineBreak: 31 | Enabled: true 32 | 33 | Layout/HashAlignment: 34 | EnforcedColonStyle: 35 | - table 36 | - key 37 | EnforcedHashRocketStyle: 38 | - table 39 | - key 40 | 41 | Layout/MultilineArrayLineBreaks: 42 | Enabled: true 43 | 44 | Layout/MultilineHashKeyLineBreaks: 45 | Enabled: true 46 | 47 | Layout/MultilineMethodArgumentLineBreaks: 48 | Enabled: true 49 | 50 | Layout/MultilineMethodCallIndentation: 51 | EnforcedStyle: indented 52 | 53 | Layout/SpaceAroundEqualsInParameterDefault: 54 | EnforcedStyle: no_space 55 | 56 | Metrics/AbcSize: 57 | Max: 20 58 | Exclude: 59 | - "test/**/*" 60 | 61 | Metrics/BlockLength: 62 | Exclude: 63 | - "*.gemspec" 64 | - "Rakefile" 65 | 66 | Metrics/ClassLength: 67 | Exclude: 68 | - "test/**/*" 69 | 70 | Metrics/MethodLength: 71 | Max: 18 72 | Exclude: 73 | - "test/**/*" 74 | 75 | Metrics/ParameterLists: 76 | Max: 6 77 | 78 | Minitest/AssertTruthy: 79 | Enabled: false 80 | 81 | Minitest/EmptyLineBeforeAssertionMethods: 82 | Enabled: false 83 | 84 | Minitest/MultipleAssertions: 85 | Max: 5 86 | 87 | Minitest/RefuteFalse: 88 | Enabled: false 89 | 90 | Naming/MemoizedInstanceVariableName: 91 | Enabled: false 92 | 93 | Naming/VariableNumber: 94 | Enabled: false 95 | 96 | Rake/Desc: 97 | Enabled: false 98 | 99 | Style/BarePercentLiterals: 100 | EnforcedStyle: percent_q 101 | 102 | Style/ClassAndModuleChildren: 103 | Enabled: false 104 | 105 | Style/Documentation: 106 | Enabled: false 107 | 108 | Style/DoubleNegation: 109 | Enabled: false 110 | 111 | Style/EmptyMethod: 112 | Enabled: false 113 | 114 | Style/FrozenStringLiteralComment: 115 | Enabled: false 116 | 117 | Style/NumericPredicate: 118 | Enabled: false 119 | 120 | Style/StringLiterals: 121 | EnforcedStyle: double_quotes 122 | 123 | Style/TrivialAccessors: 124 | AllowPredicates: true 125 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "rubocop/rake_task" 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << "test" 7 | t.libs << "lib" 8 | t.test_files = FileList["test/**/*_test.rb"] 9 | end 10 | 11 | RuboCop::RakeTask.new 12 | 13 | task default: %i[test rubocop] 14 | 15 | # == "rake release" enhancements ============================================== 16 | 17 | Rake::Task["release"].enhance do 18 | puts "Don't forget to publish the release on GitHub!" 19 | system "open https://github.com/mattbrictson/configurable_from_env/releases" 20 | end 21 | 22 | task :disable_overcommit do 23 | ENV["OVERCOMMIT_DISABLE"] = "1" 24 | end 25 | 26 | Rake::Task[:build].enhance [:disable_overcommit] 27 | 28 | task :verify_gemspec_files do 29 | git_files = `git ls-files -z`.split("\x0") 30 | gemspec_files = Gem::Specification.load("configurable_from_env.gemspec").files.sort 31 | ignored_by_git = gemspec_files - git_files 32 | next if ignored_by_git.empty? 33 | 34 | raise <<~ERROR 35 | 36 | The `spec.files` specified in configurable_from_env.gemspec include the following files 37 | that are being ignored by git. Did you forget to add them to the repo? If 38 | not, you may need to delete these files or modify the gemspec to ensure 39 | that they are not included in the gem by mistake: 40 | 41 | #{ignored_by_git.join("\n").gsub(/^/, ' ')} 42 | 43 | ERROR 44 | end 45 | 46 | Rake::Task[:build].enhance [:verify_gemspec_files] 47 | 48 | # == "rake bump" tasks ======================================================== 49 | 50 | task bump: %w[bump:bundler bump:ruby bump:year] 51 | 52 | namespace :bump do 53 | task :bundler do 54 | sh "bundle update --bundler" 55 | end 56 | 57 | task :ruby do 58 | replace_in_file "configurable_from_env.gemspec", /ruby_version = .*">= (.*)"/ => RubyVersions.lowest 59 | replace_in_file ".rubocop.yml", /TargetRubyVersion: (.*)/ => RubyVersions.lowest 60 | replace_in_file ".github/workflows/ci.yml", /ruby: (\[.+\])/ => RubyVersions.all.inspect 61 | end 62 | 63 | task :year do 64 | replace_in_file "LICENSE.txt", /\(c\) (\d+)/ => Date.today.year.to_s 65 | end 66 | end 67 | 68 | require "date" 69 | require "open-uri" 70 | require "yaml" 71 | 72 | def replace_in_file(path, replacements) 73 | contents = File.read(path) 74 | orig_contents = contents.dup 75 | replacements.each do |regexp, text| 76 | raise "Can't find #{regexp} in #{path}" unless regexp.match?(contents) 77 | 78 | contents.gsub!(regexp) do |match| 79 | match[regexp, 1] = text 80 | match 81 | end 82 | end 83 | File.write(path, contents) if contents != orig_contents 84 | end 85 | 86 | module RubyVersions 87 | class << self 88 | def lowest 89 | all.first 90 | end 91 | 92 | def all 93 | patches = versions.values_at(:stable, :security_maintenance).compact.flatten 94 | sorted_minor_versions = patches.map { |p| p[/\d+\.\d+/] }.sort_by(&:to_f) 95 | [*sorted_minor_versions, "head"] 96 | end 97 | 98 | private 99 | 100 | def versions 101 | @_versions ||= begin 102 | yaml = URI.open("https://raw.githubusercontent.com/ruby/www.ruby-lang.org/HEAD/_data/downloads.yml") 103 | YAML.safe_load(yaml, symbolize_names: true) 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/configurable_from_env/environment_value_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module ConfigurableFromEnv 4 | class EnvironmentValueTest < Minitest::Test 5 | def test_from_implicit_type 6 | value = EnvironmentValue.from("TEST") 7 | 8 | assert_equal "TEST", value.key 9 | assert_equal :string, value.type 10 | end 11 | 12 | def test_from_explicit_type 13 | value = EnvironmentValue.from({ key: "TEST", type: :boolean }) 14 | 15 | assert_equal "TEST", value.key 16 | assert_equal :boolean, value.type 17 | end 18 | 19 | def test_from_invalid_options 20 | error = assert_raises(ArgumentError) { EnvironmentValue.from({ key: "TEST", type: :boolean, optional: true }) } 21 | assert_match "Unknown key: :optional. Valid keys are: :key, :type", error.message 22 | end 23 | 24 | def test_from_unknown_type 25 | error = assert_raises(ArgumentError) { EnvironmentValue.from({ key: "TEST", type: :date }) } 26 | assert_match "Invalid type: :date (must be one of :boolean, :integer, :string)", error.message 27 | end 28 | 29 | def test_read_string 30 | value = EnvironmentValue.new(key: "TEST", type: :string, env: { "TEST" => "hello" }) 31 | 32 | assert_equal "hello", value.read 33 | end 34 | 35 | def test_read_yields_value 36 | value = EnvironmentValue.new(key: "TEST", type: :string, env: { "TEST" => "hello" }) 37 | yielded = :nothing 38 | value.read { yielded = _1 } 39 | 40 | assert_equal "hello", yielded 41 | end 42 | 43 | def test_read_required_value_raises_if_env_var_is_absent 44 | value = EnvironmentValue.new(key: "TEST", type: :string, env: {}) 45 | 46 | error = assert_raises(ArgumentError) { value.read(required: true) } 47 | assert_match "Missing required environment variable: TEST", error.message 48 | end 49 | 50 | def test_read_optional_value_returns_nil_and_does_not_yield_if_env_var_is_absent 51 | value = EnvironmentValue.new(key: "TEST", type: :string, env: {}) 52 | 53 | yielded = :nothing 54 | returned = value.read(required: false) { yielded = _1 } 55 | 56 | assert_nil returned 57 | assert_equal :nothing, yielded 58 | end 59 | 60 | def test_read_true_booleans 61 | %w[1 true yes t y enable enabled on].each do |env_value| 62 | value = EnvironmentValue.new(key: "TEST", type: :boolean, env: { "TEST" => env_value }) 63 | 64 | assert_equal true, value.read 65 | end 66 | end 67 | 68 | def test_read_false_booleans 69 | %w[0 false no f n disable disabled off].each do |env_value| 70 | value = EnvironmentValue.new(key: "TEST", type: :boolean, env: { "TEST" => env_value }) 71 | 72 | assert_equal false, value.read 73 | end 74 | end 75 | 76 | def test_read_empty_string_is_false_boolean 77 | value = EnvironmentValue.new(key: "TEST", type: :boolean, env: { "TEST" => "" }) 78 | 79 | assert_equal false, value.read 80 | end 81 | 82 | def test_read_upper_case_boolean 83 | value = EnvironmentValue.new(key: "TEST", type: :boolean, env: { "TEST" => "YES" }) 84 | 85 | assert_equal true, value.read 86 | end 87 | 88 | def test_read_invalid_boolean 89 | value = EnvironmentValue.new(key: "TEST", type: :boolean, env: { "TEST" => "what" }) 90 | 91 | error = assert_raises(ArgumentError) { value.read } 92 | assert_match 'Environment variable TEST has an invalid boolean value: "what"', error.message 93 | end 94 | 95 | def test_read_valid_integer 96 | value = EnvironmentValue.new(key: "TEST", type: :integer, env: { "TEST" => "25" }) 97 | 98 | assert_equal 25, value.read 99 | end 100 | 101 | def test_read_valid_negative_integer 102 | value = EnvironmentValue.new(key: "TEST", type: :integer, env: { "TEST" => "-1" }) 103 | 104 | assert_equal(-1, value.read) 105 | end 106 | 107 | def test_read_invalid_integer 108 | value = EnvironmentValue.new(key: "TEST", type: :integer, env: { "TEST" => "1A" }) 109 | 110 | error = assert_raises(ArgumentError) { value.read } 111 | assert_match 'Environment variable TEST has an invalid integer value: "1A"', error.message 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at opensource@mattbrictson.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # configurable_from_env 2 | 3 | [![Gem Version](https://img.shields.io/gem/v/configurable_from_env)](https://rubygems.org/gems/configurable_from_env) 4 | [![Gem Downloads](https://img.shields.io/gem/dt/configurable_from_env)](https://www.ruby-toolbox.com/projects/configurable_from_env) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mattbrictson/configurable_from_env/ci.yml)](https://github.com/mattbrictson/configurable_from_env/actions/workflows/ci.yml) 6 | 7 | The `configurable_from_env` gem allows you to define accessors that automatically populate via environment variables. It brings back Active Support's [`config_accessor`](https://github.com/rails/rails/blob/819a94934966eafb6bee6990b18372e1eb91159d/activesupport/lib/active_support/configurable.rb#L111) – which was [deprecated](https://github.com/rails/rails/pull/53970) in Rails 8.1 – and enhances it with a new `:from_env` option. 8 | 9 | > [!NOTE] 10 | > This project is experimental. Please open an issue or send me an email and let me know what you think! 11 | 12 | --- 13 | 14 | - [Quick start](#quick-start) 15 | - [Motivation](#motivation) 16 | - [Support](#support) 17 | - [License](#license) 18 | - [Code of conduct](#code-of-conduct) 19 | - [Contribution guide](#contribution-guide) 20 | 21 | ## Quick start 22 | 23 | Add the gem to your Gemfile and run `bundle install`: 24 | 25 | ```ruby 26 | gem "configurable_from_env" 27 | ``` 28 | 29 | Include the `ConfigurableFromEnv` mixin and then use `config_accessor` to define configurable attributes that are automatically populated from the environment. 30 | 31 | ```ruby 32 | class MyHttpClient 33 | include ConfigurableFromEnv 34 | 35 | # Define an api_key accessor that is automatically populated from ENV["MY_API_KEY"] 36 | config_accessor :api_key, from_env: "MY_API_KEY" 37 | 38 | # Validate and convert ENV value into a desired data type 39 | config_accessor :timeout, from_env: { key: "MY_TIMEOUT", type: :integer } 40 | 41 | # Fall back to a default value if the environment variable is absent 42 | config_accessor :verify_tls, from_env: { key: "MY_VERIFY_TLS", type: :boolean }, default: true 43 | 44 | def fetch_data 45 | # Config attributes are exposed as instance methods for easy access 46 | conn = Faraday.new( 47 | headers: { "X-API-Key" => api_key }, 48 | request: { timeout: timeout }, 49 | ssl: { verify: verify_tls } 50 | ) 51 | conn.get("https://my.api/data") 52 | end 53 | end 54 | ``` 55 | 56 | ## Motivation 57 | 58 | Rails lacks a simple way to declare configuration dependencies on `ENV` values. Generally, the framework guides you toward one of two common approaches: 59 | 60 | **Use `ENV.fetch` directly when you need a value.** This is the most direct technique, but it means that `ENV` dependencies are scattered throughout implementation code. Testing can become more difficult due to this implicit global variable dependency, and you may need a special library to mock `ENV` access. 61 | 62 | **Define a configuration object, and use an initializer to copy values from `ENV` into the config.** Third-party gems often use this approach. Consider Devise, which uses `config/initializers/devise.rb`. 63 | 64 | ```ruby 65 | Devise.setup do |config| 66 | config.secret_key = ENV.fetch("DEVISE_SECRET_KEY") 67 | ``` 68 | 69 | This is very flexible, and works well for libraries that need to be portable across many different app environments. However for application-level code it can repetitive, as each configuration attribute has to be declared multiple times, often in 3 separate files: 70 | 71 | 1. Define a configuration class that declares the attribute. 72 | 2. Create an initializer that sets the attribute. 73 | 3. Reference the configuration when using the attribute. 74 | 75 | **Regardless of where you put the `ENV` access, validating and converting values can be tedious.** Environment values are always strings, but often we need them to configure settings that are booleans or integers. 76 | 77 | ### A different approach 78 | 79 | `configurable_from_env` is an extremely lightweight solution (~80 LOC) to the shortcomings listed above. Consider this example: 80 | 81 | ```ruby 82 | class MyHttpClient 83 | include ConfigurableFromEnv 84 | config_accessor :timeout, from_env: { key: "MY_TIMEOUT", type: :integer }, default: 30 85 | ``` 86 | 87 | The benefits are: 88 | 89 | - Configurable attributes, their default values, how they map from environment variables, and their data types are all declared in one place, as opposed to scattered across initializers, classes, and/or YAML files. 90 | - The configuration is colocated with the code where it used (i.e. the `MyHttpClient` class, in the example). 91 | - Because these are simple accessors, values can be easily injected in unit tests without needing to mock `ENV`. 92 | - Specifying a `:type` takes care of validation and conversion of environment values with an intuitive and concise syntax. 93 | 94 | ## Support 95 | 96 | If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/mattbrictson/configurable_from_env/issues/new) and I will do my best to provide a helpful answer. Happy hacking! 97 | 98 | ## License 99 | 100 | The gem is available as open source under the terms of the [MIT License](LICENSE.txt). 101 | 102 | ## Code of conduct 103 | 104 | Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md). 105 | 106 | ## Contribution guide 107 | 108 | Pull requests are welcome! 109 | -------------------------------------------------------------------------------- /lib/configurable_from_env/configurable.rb: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rails/rails/blob/8-0-stable/activesupport/lib/active_support/configurable.rb 2 | # 3 | # Copyright (c) David Heinemeier Hansson 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | require "active_support" 25 | require "active_support/concern" 26 | require "active_support/ordered_options" 27 | 28 | module ConfigurableFromEnv 29 | # = Active Support \Configurable 30 | # 31 | # Configurable provides a config method to store and retrieve 32 | # configuration options as an OrderedOptions. 33 | module Configurable 34 | extend ActiveSupport::Concern 35 | 36 | class Configuration < ActiveSupport::InheritableOptions 37 | def compile_methods! 38 | self.class.compile_methods!(keys) 39 | end 40 | 41 | # Compiles reader methods so we don't have to go through method_missing. 42 | def self.compile_methods!(keys) 43 | keys.reject { |m| method_defined?(m) }.each do |key| 44 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 45 | def #{key}; _get(#{key.inspect}); end 46 | RUBY 47 | end 48 | end 49 | end 50 | 51 | module ClassMethods 52 | def config 53 | @_config ||= if respond_to?(:superclass) && superclass.respond_to?(:config) 54 | superclass.config.inheritable_copy 55 | else 56 | # create a new "anonymous" class that will host the compiled reader methods 57 | Class.new(Configuration).new 58 | end 59 | end 60 | 61 | def configure 62 | yield config 63 | end 64 | 65 | # Allows you to add shortcut so that you don't have to refer to attribute 66 | # through config. Also look at the example for config to contrast. 67 | # 68 | # Defines both class and instance config accessors. 69 | # 70 | # class User 71 | # include ActiveSupport::Configurable 72 | # config_accessor :allowed_access 73 | # end 74 | # 75 | # User.allowed_access # => nil 76 | # User.allowed_access = false 77 | # User.allowed_access # => false 78 | # 79 | # user = User.new 80 | # user.allowed_access # => false 81 | # user.allowed_access = true 82 | # user.allowed_access # => true 83 | # 84 | # User.allowed_access # => false 85 | # 86 | # The attribute name must be a valid method name in Ruby. 87 | # 88 | # class User 89 | # include ActiveSupport::Configurable 90 | # config_accessor :"1_Badname" 91 | # end 92 | # # => NameError: invalid config attribute name 93 | # 94 | # To omit the instance writer method, pass instance_writer: false. 95 | # To omit the instance reader method, pass instance_reader: false. 96 | # 97 | # class User 98 | # include ActiveSupport::Configurable 99 | # config_accessor :allowed_access, instance_reader: false, instance_writer: false 100 | # end 101 | # 102 | # User.allowed_access = false 103 | # User.allowed_access # => false 104 | # 105 | # User.new.allowed_access = true # => NoMethodError 106 | # User.new.allowed_access # => NoMethodError 107 | # 108 | # Or pass instance_accessor: false, to omit both instance methods. 109 | # 110 | # class User 111 | # include ActiveSupport::Configurable 112 | # config_accessor :allowed_access, instance_accessor: false 113 | # end 114 | # 115 | # User.allowed_access = false 116 | # User.allowed_access # => false 117 | # 118 | # User.new.allowed_access = true # => NoMethodError 119 | # User.new.allowed_access # => NoMethodError 120 | # 121 | # Also you can pass default or a block to set up the attribute with a default value. 122 | # 123 | # class User 124 | # include ActiveSupport::Configurable 125 | # config_accessor :allowed_access, default: false 126 | # config_accessor :hair_colors do 127 | # [:brown, :black, :blonde, :red] 128 | # end 129 | # end 130 | # 131 | # User.allowed_access # => false 132 | # User.hair_colors # => [:brown, :black, :blonde, :red] 133 | def config_accessor(*names, instance_reader: true, instance_writer: true, instance_accessor: true, default: nil) # :doc: 134 | names.each do |name| 135 | raise NameError.new("invalid config attribute name") unless /\A[_A-Za-z]\w*\z/.match?(name) 136 | 137 | reader, reader_line = "def #{name}; config.#{name}; end", __LINE__ 138 | writer, writer_line = "def #{name}=(value); config.#{name} = value; end", __LINE__ 139 | 140 | singleton_class.class_eval reader, __FILE__, reader_line 141 | singleton_class.class_eval writer, __FILE__, writer_line 142 | 143 | if instance_accessor 144 | class_eval reader, __FILE__, reader_line if instance_reader 145 | class_eval writer, __FILE__, writer_line if instance_writer 146 | end 147 | 148 | send("#{name}=", block_given? ? yield : default) 149 | end 150 | end 151 | private :config_accessor 152 | 153 | private 154 | def inherited(subclass) 155 | super 156 | subclass.class_eval do 157 | @_config = nil 158 | end 159 | end 160 | end 161 | 162 | # Reads and writes attributes from a configuration OrderedOptions. 163 | # 164 | # require "active_support/configurable" 165 | # 166 | # class User 167 | # include ActiveSupport::Configurable 168 | # end 169 | # 170 | # user = User.new 171 | # 172 | # user.config.allowed_access = true 173 | # user.config.level = 1 174 | # 175 | # user.config.allowed_access # => true 176 | # user.config.level # => 1 177 | def config 178 | @_config ||= self.class.config.inheritable_copy 179 | end 180 | end 181 | end 182 | --------------------------------------------------------------------------------